Professional Documents
Culture Documents
Property Grids
Many applications need to present sets of information that can be edited. The data could be patient details in a healthcare system, shape attributes in a drawing program, control properties in a form designer, or any number of other kinds of information. Windows Forms provides a control that makes presenting and editing such data easy, while allowing great flexibility in the way in which information is presentedthe PropertyGrid control. Visual Studio .NET itself uses the PropertyGrid control to present properties for all controls and other components in the Forms Designer. This means that even if you don't plan to use a PropertyGrid directly in your own applications, it is helpful to have a good understanding of how it works so you may control the way your components' properties are presented. Visual Studio .NET lets you customize the appearance of the property pages for your components using the techniques described here. In this chapter, we will start by looking at how to display the properties of a simple object in a PropertyGrid. Then, we will see how to enable editing of custom types by using type converters. Finally, we will see how to add our own custom property editing user interfaces for when a text-based representation is insufficient.
Displaying an instance of this class in a PropertyGrid is trivial. Simply drag a PropertyGrid control onto a form, and then in that form's constructor, create an instance of the object you wish to display, and pass it to the grid by setting its SelectedObject property, as shown in Example 8-3. The PropertyGrid control may not appear on your toolbox. To add this control to the toolbox, right-click on the toolbox and select Customize Toolbox.... Select the .NET Framework Components tab and make sure that the checkbox for the Property Grid control is checked.
' VB code Dim cd As New CustomerDetails() cd.FirstName = "John" cd.LastName = "D'Oh" cd.Address = "742, Evergreen Terrace, Springfield" cd.DateOfBirth = #5/12/1956#
PropertyGrid1.SelectedObject = cd
The property grid will then examine the object and discover that it has four public properties, which it will present for editing as shown in Figure 8-1. Figure 8-1. A simple object in a PropertyGrid
The grid is shown here with its default settingsthe toolbar is present along the top, the properties are sorted by category, and the description pane is visible. For the object being displayed, none of this is particularly useful, because the properties are not categorized (which is why everything appears under Misc), and they do not have descriptions. These
unused features are easy enough to switch offsetting the ToolbarVisible and HelpVisible properties to false will remove the toolbar and description pane, while setting the PropertySort property to PropertySort.Alphabetical will prevent the grid from trying to display category names. However, descriptions and categorizations can be very useful, so it would be better to modify our CustomerDetails class to make use of them. The way we supply the PropertyGrid with category and description information is to annotate our properties with custom attributes. In fact, we use the same Category and Description attributes that we would when annotating a control's properties for Visual Studio .NET's benefit, as described in Chapter 5. This should come as no surprise, because the Forms Designer uses a PropertyGrid to display a control's properties. So if we add relevant attributes, as shown in Example 8-4, the PropertyGrid will use them to display the appropriate categories and descriptions for our object. Example 8-4. Annotating a property
// C# code [Category("Name")] [Description("The customer's first name")] public string FirstName { . . . As before ' VB code <Category("Name"), _ Description("The customer's first name")> _ Public Property FirstName As String . . . As before
These attributes are all defined in the System.ComponentModel namespace, so make sure you have a using (in C#) or Imports (in VB) statement at the top of your file to bring that namespace into scope. Figure 8-2 shows how the PropertyGrid uses these attributes when presenting our object's properties. Figure 8-2. Properties shown with categories and descriptions
As with components displayed in Visual Studio .NET, any properties marked with the [Browsable(false)] attribute will not be displayed by default. But this is not the only
way of filtering which properties the grid will displaythe PropertyGrid control provides a property called BrowsableAttributes. This can be set to an AttributeCollection containing a list of attributes that must be present on a property for it to be displayed. The code shown in Example 8-5 will cause only those properties belonging to the Name category to be shown. (Note that the grid will only show those properties that have all the attributes specified. So if you were to build a list with two different category attributes in it, you would end up with nothing in the grid.) You don't have to use category attributes in this listany kind of attribute can be specified, so if you want to filter properties on some other criteria, you could define your own custom attribute class. Example 8-5. Filtering on category
// C# code propertyGrid.BrowsableAttributes = new AttributeCollection( new Attribute[] { new CategoryAttribute("Name") }); ' VB code PropertyGrid1.BrowsableAttributes = New AttributeCollection( _ New Attribute() {New CategoryAttribute("Name")})
The ability to supply category names and descriptions is a powerful usability enhancement. However, the problem with the examples we have seen so far is that they hardcode strings into the source code. This is bad practice because it makes it difficult to display localized versions of the strings when your software runs in other locales. You can avoid this by making localizable versions of these attributes.
public LocalizableCategoryAttribute(string n, Type resBase) : base (n) { t = resBase; } protected override string GetLocalizedString(string value) { ResourceManager rm = new ResourceManager(t); string tx = rm.GetString(value); if (tx != null) return tx; } } return base.GetLocalizedString(value);
Note that when deriving from an existing attribute, you must redeclare the valid target types, hence, the AttributeUsage attribute. (The Category attribute declares itself to be valid for all target types, so we follow suit.) The overridden GetLocalizedString method just uses a ResourceManager to look up the localized version of the string. If this fails, it defers to the base class (which will just return the original string). To create a ResourceManager, we need to supply enough information for the framework to locate the appropriate resource file. The standard way of doing this is to use a Type objectresources are typically associated with a type. In Visual Studio .NET the way you manage this is to name the resource file after the class it is to be associated with. So to use localizable resources on our CustomerDetails class, we would add a new Assembly
Resource File called CustomerDetails.resx. Having done this, we can then use the localizable form of this attribute on our class's properties:
// C# code [LocalizableCategory("Name", typeof (CustomerDetails))] public string FirstName { get { return firstName; } set { firstName = value; } } ' VB code <LocalizableCategory("Name", GetType(CustomerDetails))> _ Public Property FirstName As String Get Return sFirstName End Get Set sFirstName = Value End Set End Property
So when the PropertyGrid control attempts to use our modified CustomerDetails object, it will look for the Category attribute as usual, but it will actually get our LocalizableCategory instead. When the grid asks the attribute for the category name, our GetLocalizedString method will be called. This will ask the ResourceManager to find a definition for the string that is appropriate to the current locale. If the ResourceManager cannot find one, our attribute will just return the unlocalized string. To see this in action, let us add a culture-specific resource file to our project, as shown in Figure 8-3. Figure 8-3. A culture-specific resource file
Figure 8-3 shows a .resx file as presented by Visual Studio .NET. This particular file is called CustomerDetails.fr-FR.resx. The fr-FR part indicates that this file contains French resources. This will cause Visual Studio .NET to compile it into a so-called satellite assembly (a culture-specific resource-only assembly) and place it in the fr-FR subdirectory. The first fr in the resource filename indicates the language: French. The second FR indicates the region: France. Region and language are specified independently, because either on its own is not enough French is spoken in many countries, many of which also speak other languages. For example, Canada (fr-CA) or Belgium (fr-BE).
If we run our application in a French locale, when the property grid asks our LocalizableCategory for the category name, our GetLocalizedString method will pass the hardcoded name (e.g., Demographics) to the GetString method of the ResourceManager. The resource manager will look for a satellite assembly in the fr-FR subdirectory because the current culture is French. It will find the satellite assembly containing the resource file shown in Figure 8-3, and will look up the entry whose name is Demographics, and return its value, Dmographiques. Consequently, when the property grid appears, the category names appear in French, not in English, as shown in Figure 8-4. Figure 8-4. Translated category names
We are not done yetthe description and property names are still in English. The description can be fixed in much the same way that categories werewe define our own custom attribute that derives from the Description attribute. As before, the Description attribute was designed to be derived from, so this is relatively straightforward, although for some reason the prescribed way of supporting localization is somewhat differentwe are expected to translate the string just once, and store it in a protected property called DescriptionValue. Examples Example 8-8 and Example 8-9 show an implementation of this. Example 8-8. A localizable description attribute using C#
[AttributeUsage(AttributeTargets.All)] public class LocalizableDescriptionAttribute : DescriptionAttribute { private Type t; public LocalizableDescriptionAttribute(string name, Type resBase) : base(name) { t = resBase; } private bool localized = false; public override string Description { get { if (!localized) { localized = true; ResourceManager rm = new ResourceManager(t);
} return base.Description; } }
This conforms to the idiom required by the Description attribute, and it works in a slightly curious fashion. In our override of the Description property's get method, we are required to read the DescriptionValue property, translate it, and then write back the translated value. We must then defer to the base class's get implementation, which just returns the value of DescriptionValue. This is a somewhat roundabout way of doing things, but it is what the documentation for DescriptionAttribute instructs us to do. Apart from the slightly peculiar way in which the overridden Description property works, this class uses the same technique as we used for our localizable categoryit relies on a ResourceManager to find the appropriate string for the current culture. However, you will probably want to use this attribute slightly differently, as Example 8-10 shows. Example 8-10. Using a localizable description attribute
// C# [LocalizableDescription("LastName.Description", typeof(CustomerDetails))]
public string LastName { . . . as before ' VB <LocalizableDescription("LastName.Description", _ GetType(CustomerDetails))> _ Public Property LastName() As String . . . as before
Example 8-10 shows the LocalizableDescription attribute in use. Notice that the string being supplied to the attribute (LastName.Description) is not the full description. This is because, for non-English cultures, this string will be used to look up the translated string. Using a full English sentence as a key to look up information is error prone (not to mention inefficient). There is nothing stopping you from using the full sentence in the attribute, it is just that you are more likely to run into problems. (However, it does have the advantage that you don't need to supply an entry for the string in the default resources.) If you use the technique shown in Example 8-10 you will obviously need to supply entries for these strings in your neutral resources (i.e., the resources compiled into the main executable, not a satellite assembly) so that the correct strings appear for your default culture. As Figure 8-5 shows, both the category names and the descriptions are now localized. However, we are still not quite done. The property names are still displayed in English. To change this, we will need to use something called a TypeConverter, which enables us to modify the way in which a PropertyGrid presents properties. In fact, we can do far more with a TypeConverter than just changing the displayed name of the property. Figure 8-5. Translated categories and descriptions
The PropertyGrid turns out not to have a long list of types that it knows how to display and edit. The control itself knows nothing about, say, the DateTime or Color types, and yet it is still able to present them for editing. This is because it has a very flexible open architecture that allows any type to make itself editable. A type can provide various levels of support for the PropertyGrid, even going as far as supplying a special-purpose user interface for editing that type (like the pickers that appear for Color and ContentAlignment). We will see how to do that later, but for many types, simple text editing will suffice. So at the bare minimum, our type must support conversion to and from textits value will be converted to text when it is first displayed in the grid. If the user changes that text, the new string must be converted back to an instance of our type for the edit to take effect. We will now introduce a custom type to the CustomerDetails example and then add support for basic type conversion to and from a string. Rather than storing the customer name as first and last name strings, we will define a separate CustomerName type, as shown in Examples Example 8-11 and Example 8-12. The CustomerDetails class defined in Examples Example 8-1 and Example 8-2 will be modified to expose a single property Name of type CustomerName instead of the original FirstName and LastName properties. This CustomerName class is shown in Examples Example 8-11 and Example 8-12. Example 8-11. Custom type to hold name written in C#
public class CustomerName { private string firstName, lastName; public string FirstName { get { return firstName; } set { firstName = value; } } public string LastName { get { return lastName; } set { lastName = value; } } }
Public Property LastName() As STring Get Return sLastName End Get Set sLastName = Value End Set End Property End Class
So far we have not provided any support for the benefit of PropertyGrid. Consequently, when we display the modified CustomerDetails in the grid, its Name field is not especially helpful. Figure 8-6 shows how the property will be displayed. Figure 8-6. An unsupported type in a PropertyGrid The field is displayed, but not with any useful informationjust the name of the field's type. The reason that the type name has appeared is that PropertyGrid just calls the ToString method on types it doesn't know how to deal with, and the default implementation of ToString is to return the type name. We can override this in the CustomerName class easily enough to provide some more useful information:
// C# code public override string ToString() { return string.Format("{0}, {1}", LastName, FirstName); } ' VB code Public Overrides Function ToString() As String Return String.Format("{0}, {1}", LastName, FirstName) End Function
This improves matters slightlyas you can see in Figure 8-7, the grid now shows a meaningful representation of the property's value. But it is grayed out, and the PropertyGrid will not let the user edit the text. This is because although the PropertyGrid was able to convert our type to a string by calling ToString, it does not know how to convert a string back to an instance of our type. It cannot allow the value to be edited because it has no way of writing the edited value back into our object. Figure 8-7. Using ToString in a PropertyGrid To allow our CustomerName type to be edited in a PropertyGrid, we will need to provide a conversion facility allowing the property to be set using a string. We do this by writing a class that derives from TypeConverter. Examples Example 8-13 and Example 8-14 show how to do this for our type. It overrides two methods. First, it overrides CanConvertFrom this method can be called to find out whether a particular source type can be converted
into our CustomerName type. The PropertyGrid control will call this method to find out whether it will be able to convert from a string to a CustomerNamevalues are typically edited as text on a property grid. So we test the sourceType parameter and return true if it we are being asked to convert to a string. Example 8-13. A custom TypeConverter using C#
public class CustomerNameConverter : TypeConverter { public override bool CanConvertFrom( ITypeDescriptorContext context, Type sourceType) { return sourceType == typeof(string); } public override object ConvertFrom( ITypeDescriptorContext context, CultureInfo culture, object value) { if (value == null) return null; // Make sure this is a string string sval = value as string; if (sval == null) throw new NotSupportedException("Unsupported type"); // If comma is present, treat this as "Last, First" string[] names = sval.Split(','); if (names.Length == 2) { CustomerName name = new CustomerName(); name.LastName = names[0].Trim(); name.FirstName = names[1].Trim(); return name; } else if (names.Length == 1) { // No comma, must be "First Last" names = sval.Split(' '); if (names.Length == 2) { CustomerName name = new CustomerName(); name.FirstName = names[0].Trim(); name.LastName = names[1].Trim(); return name; } } // Unable to make sense of the string throw new NotSupportedException("Invalid format"); } }
Public Class CustomerNameConverter Inherits TypeConverter Public Overloads Overrides Function CanConvertFrom( _ context As ITypeDescriptorContext, _ sourceType As Type) _ As Boolean Return sourceType Is GetType(String) End Function Public Overloads Overrides Function ConvertFrom( _ context As ITypeDescriptorContext, _ culture As CultureInfo, _ value As Object) _ As Object If value Is Nothing Then Return Nothing ' Make sure this is a string Dim sVal As String If TypeName(value) <> "String" Then Throw New NotSupportedException("Unsupported type") Else sVal = DirectCast(value, String) End If ' If comma is present, treat this as "Last, First" Dim names() As String = sval.Split(","c) If names.Length = 2 Then Dim name As New CustomerName() name.LastName = names(0).Trim() name.FirstName = names(1).Trim() Return name Else If names.Length = 1 Then ' No comma, must be "First Last" names = sval.Split(" "c) If names.Length = 2 Then Dim name As New CustomerName() name.FirstName = names(0).Trim() name.LastName = names(1).Trim() Return name End If End If ' Unable to make sense of the string Throw New NotSupportedException("Invalid format") End Function End Class
The second method that we override is the ConvertFrom method. This is where we do the conversion, parsing the string to create a CustomerName. (This particular example allows the name to be passed in two formats: "First Last" and "Last, First." It uses the presence or absence of a comma to work out which format is being used.) There are two other methods we might consider overriding here: CanConvertTo and ConvertTo. These perform the reverse transformation; for CustomerNameConverter, this
means converting from a CustomerName to a string. In this case, there is no need to override thesethe implementation supplied by the base class, TypeConverter, already handles conversion to a string by calling ToString. Because we provided a suitable ToString method on CustomerName, we don't need to add anything here. But if you wanted to support editing of a type whose ToString method returned an inappropriate value, you could bypass it by overriding ConvertTo in the TypeConverter. (Because the default CanConvertTo method always returns true for string, you do not need to override it when providing custom string conversion in ConvertTo. You would only need to override CanConvertTo if you decide to support other conversions.) This TypeConverter now provides the conversion facilities required by the PropertyGrid. The only remaining question is this: how does the PropertyGrid know that it should use this CustomerNameConverter when editing a CustomerName? It won't just guess from the class namesit needs a more positive hint than that. The answer is that when the PropertyGrid encounters a data type that it doesn't intrinsically know how to deal with, it will look to see if that type has the TypeConverter attribute. So we can use this to annotate our CustomerName class, as shown in Example 8-15. Example 8-15. Associating a type with its TypeConverter
// C# code
[TypeConverter(typeof(CustomerNameConverter))]
public class CustomerName { . . . as before ' VB code <TypeConverter(GetType(CustomerNameConverter))> _ Public Class CustomerName . . . as before
If the TypeConverter attribute is present, the PropertyGrid will use the converter that it specifies to do all conversion to and from strings. With this attribute in place, our Name field becomes editable. When we make a change to the Name field, the PropertyGrid will pass the edited text to the ConvertFrom method of the CustomerNameConverter, which will parse the string and build a new CustomerName based on its contents. In other words, our property can now be edited like any other. The PropertyGrid control will search for the TypeConverter attribute both on the definition of the type being edited, and also on the property. This can be useful for two reasons. If your class has a property of a type whose definition you don't control, that type may well not have an associated converter. This is not a problem, because you can write your own converter and just specify it on the property where you use the type in question, as Example 8-16 shows. Similarly, you might be using a type you didn't write that does have an associated converter, but for some reason you need to use a different one (e.g., to deal with localization issues). The PropertyGrid will check for the TypeConverter attribute on the property first, so you can replace a type's default converter with your own.
' VB code Public Class CustomerDetails Private oName As New CustomerName() Private sAddress As String Private dob As Date <TypeConverter(GetType(CustomerNameConverter))> _ Public Property Name() As CustomerName Get Return oName End Get Set(ByVal Value As CustomerName) oName = Value End Set End Property . . . . as before
Being able to edit a CustomerName is good, but we can do better. Windows Forms often uses similar propertiesjust as CustomerName has its FirstName and LastName properties, the Size property of a form also has some subproperties, Width and Height. And while you can edit a control's size by typing in the width and height as a single string, it also allows the Size property to be expanded, with the Width and Height properties displayed as children. We can do exactly the same thingPropertyGrid lets any property display such nested properties. All we need to do is supply an appropriate TypeConverter.
This facility is enabled by using an appropriate TypeConverter. The simplest approach is to use the converter supplied by the system for this purpose ExpandableObjectConverter:
[TypeConverter(typeof(ExpandableObjectConverter))]
This will use reflection to discover what properties are available, and supply these to the PropertyGrid, enabling it to display them as shown in Figure 8-8. Figure 8-8. Nested properties in a PropertyGrid
There are two problems with this. First, because we are no longer using the purpose-built CustomerNameConverter (we replaced that with ExpandableObjectConverter) the PropertyGrid no longer has any way of converting text back to a CustomerNamethe ExpandableObjectConverter has no knowledge of the parsing rules we are using for the conversion. This means that the text against the Name field in the grid is no longer editable; the Name property can only be changed by editing its nested properties. Second, the PropertyGrid will not update the text next to the Name field when you edit either the FirstName or LastName fields because it doesn't know that the fields are related. The simple way to solve both of these problems is to change the CustomerName class's ToString method to return an empty string. This makes the fact that the Name field cannot be edited less obtrusive, because there will be nothing there to edit. And if the field is always empty, it no longer matters that it isn't updated when the nested properties change. However, we can do better than this. Properties of type Size and Position allow their values to be edited either as a whole or through their nested properties. We can provide the same flexibility with our own types. To make sure that the main property value is refreshed every time one of its nested properties changes, simply mark all nested properties with the RefreshProperties attribute:
[RefreshProperties(RefreshProperties.Repaint)]
This will cause the PropertyGrid to check the parent property's value after the nested property changes, and update the display if necessary. But having done this, we still need to arrange for the parent property (Name in this case) to be editable directly. What we really want is a type converter that has the nested object facility of the ExpandableObjectConverter, but that also has the parsing logic of our CustomerNameConverter. This is easy to achievesimply modify the
CustomerNameConverter so that it inherits from ExpandableObjectConverter, and change the TypeConverter attribute on the CustomerName class back to refer to CustomerNameConverter, as in Example 8-15.
Although our nested properties now appear to be behaving in the same way as Size and similar standard properties, there is a more subtle difference: the framework tends to use value types for such properties, but our CustomerName is a class. Unfortunately, if you try to change CustomerName to be a value type, you will find that this example stops working. This is because value types require special treatment when used in a PropertyGrid. 8.2.1.1 Value types Some properties use value typesthere are several examples in the Control class alone (Location, Size, and Bounds, for example; their respective types are Point, Size, and Rectangle, which are all value types). But these are slightly trickier for the PropertyGrid to usenested properties don't work without special treatment. This is mainly because to change a value type property, you must update the whole property. (If you retrieve the Size property, and then change the Width, you will simply be changing the Width in your local copy of the Size. You must then write this modified Size back to update the property.) Suppose that CustomerName were a value type, and that we wished to display its nested properties in a PropertyGrid as before. If the user modifies the FirstName nested property of the Name, the grid somehow has to apply that change back to the property. It cannot modify the FirstName in situ its only option is to build a CustomerName with the correct value and assign that to the Name. But how is it supposed to know how to create a CustomerName? Again, the TypeConverter comes to the rescueit has two methods we can override to provide the PropertyGrid with the facility that it requires. Examples Example 8-17 and Example 8-18 illustrate this.
[1]
This is for exactly the same reason that you can't do it in code. If you tried to write customer.Name.FirstName = "Fred"; the compiler would complain that this is not possible because the Name property uses a value type.
[1]
Examples Example 8-17 and Example 8-18 illustrate how to provide support for a value type in a TypeConverter. The PropertyGrid will call the GetCreateInstanceSupported method to find out whether our converter provides a value creation facility. We simply return true to indicate that we do. We then supply the CreateInstance method, which it will call when it needs us to create a new value. For example, if the user edits the FirstName nested property, the PropertyGrid will call CreateInstance. It will pass in an IDictionary containing the modified FirstName value and the original LastName value as strings. We convert these strings back into a complete CustomerName that the PropertyGrid can then use to set the Name property on the CustomerDetails object.
[2] [2]
You do not need to use the RefreshProperties attribute on the properties of value types. This is because the parent property is always updated in its entirety whenever any of the nested properties change, so the grid always refreshes it. The ExpandableObjectConverter type provides a convenient way of allowing nested properties to be edited, but there is nothing magic about itit just overrides a couple of methods of the TypeConverter class that are there to support nested properties. It can be useful to do this yourself if you need more control over the way in which properties are presented. For example, this is the only way to make your property names localizable. So we will now look in a little more detail at the mechanism on which nested properties are based.
overriding the appropriate methods of your class's TypeConverter. This allows you to customize how the PropertyGrid sees your type, and therefore to control how it appears in the grid. Whenever it displays any object (whether it is the main object or an object supplying nested properties), the PropertyGrid first obtains a set of PropertyDescriptor objects to determine what needs to be shown in the grid. It will attempt to get this list from the object's associated TypeConverter (if it has one). First, it will call the converter's GetPropertiesSupported method to find out whether this particular TypeConverter is able to supply property descriptors. If the GetPropertiesSupported method returns true, it will then call GetProperties to retrieve a list of PropertyDescriptor objects. The grid will display whatever properties are in this list, regardless of what properties the object might really have. (If it is unable to get these descriptors from a converter, it falls back to calling the TypeDescriptor class's GetProperties method for the main object in the grid, which builds the list using reflection; for nested objects, it falls back to not displaying any properties at all.) This means that by supplying a type converter and overriding these two methods, we have complete control over what properties the grid will display. We can filter the properties, modify how they will appear, or even fake them up entirely. If we create a PropertyDescriptor for which there is no real underlying property, the grid will never know, because it never interacts with properties directlyit always goes through a PropertyDescriptor. Building fake descriptors is somewhat harder than filtering because this requires you to write your own class that inherits from PropertyDescriptor. This is not completely trivial, because PropertyDescriptor has many abstract methods. However, TypeConverter provides a nested class, SimplePropertyDescriptor, that makes it much easier. It derives from PropertyDescriptor for you and provides implementations for most of the methods. If you plan to create your own property descriptors, it is usually easiest to use SimplePropertyDescriptor as a base class. Unfortunately, Visual Basic .NET currently has a limitation that prevents it from using this class. SimplePropertyDescriptor is a defined as a protected nested class. According to the .NET type system rules, this means that the only way to define a class derived from SimplePropertyDescriptor is to make that derived class a nested class inside a class that derives from TypeConverter. This works fine in C#, but Visual Basic unfortunately does not support this, due to a bug in the compiler. Until this bug is fixed, there is no way of using the SimplePropertyDescriptor class in Visual Basic .NET. Consequently, the examples in this section will be in C# only.
The ExpandableObjectConverter just builds a list of property descriptors for whichever object it is being asked to represent. This is trivial, because the TypeDescriptor class will do this for you. Example 8-19 shows a custom TypeConverter that is exactly equivalent to the ExpandableObjectConverter. Example 8-19. Do-it-yourself ExpandableObjectConverter
public class MyExpandableObjectConverter : TypeConverter { public override bool GetPropertiesSupported( ITypeDescriptorContext context) { return true; } public override PropertyDescriptorCollection GetProperties( ITypeDescriptorContext context, object value, Attribute[] attributes) { return TypeDescriptor.GetProperties(value, attributes, true); } }
This is not especially useful as it standsyou might as well use the built-in ExpandableObjectConverter class. However, not only is it interesting to see how easy it is to provide property descriptors, it can also act as a useful starting point. It is fairly easy to modify this class to build a TypeConverter that provides filtered views by removing items from the collection returned by the TypeDescriptor class. For example, we can use this facility to complete what we started earlier: we can write a TypeConverter that makes property names localizable. 8.2.2.1 Localization with property descriptors Earlier in this chapter, we saw how to create localizable versions of the Category and Description attributes. This enabled the category names and property descriptions to be shown in the appropriate language for the current culture. We will now finish the job by writing a TypeConverter that can localize the property names displayed in the grid. This seems as though it might be a hard problemafter all, the names of a class's properties are part of the source code and are not easily modifiable through the normal localization techniques. Fortunately, as we have just seen, the PropertyGrid does not access properties directlyit always goes through a level of indirection in the form of a PropertyDescriptor. All we need to do is provide a TypeConverter that supplies PropertyDescriptor objects with the names we want. The PropertyDescriptor class was designed with this kind of thing in mind, because it supports two different names for any property. The descriptor's Name property is the real
name, i.e., the name in the source code. But is also has a DisplayName property, which is the name that is to be displayed in the user interface whenever this property is shown. The PropertyGrid always uses the DisplayName, so all we need to do is make sure that it contains the localized version of the property name. Although the framework supplies some classes that derive from PropertyDescriptor, none of the concrete ones allows the DisplayName to be different from the Name. This means we will have to write our own. Fortunately, we are writing one as part of a TypeConverterthis is good because TypeConverter provides a useful abstract base class for writing your own PropertyDescriptor, called TypeConverter.SimplePropertyDescriptor. This does most of the work we require, so we only need to write a small amount of code to build a concrete class derived from PropertyDescriptor that meets our needs.
TypeConverter.SimplePropertyDescriptor is a nested class of TypeConverter, and it is marked as protected. This means that a
Before we look at the code, we will consider how our localizing TypeConverter will be used in practice. We want it to be simple, having as little impact as possible on code that uses it. This complicates the implementation a little, but this converter only has to be written once, whereas the client code will be written everywhere that our converter is used, so it makes sense to complicate the converter to simplify its use. If we call our custom converter LocalizableTypeConverter, client code will look like this:
[TypeConverter(typeof(LocalizableTypeConverter))]
In other words, the impact is no worse than supporting any other TypeConverter. There is one snag with thisbecause our TypeConverter will be localizing strings, it will need to create a ResourceManager. In order to create a resource manager, we need access to a Type object (or at least an Assembly). Fortunately, our TypeConverter will be able to discover the type of the class it has been attached to, and it can use that as its resource source. So in this case, it would use CustomerDetails. While this makes for a minimum of code, it does rather increase the number of .resx files you will need in your projectit will require a resource file for every class that uses this converter. So we will therefore define an optional custom attribute that allows a different type to be specified as the basis
for resources. Example 8-20 shows a type that uses this attribute to share a resource file with the CustomerDetails class. Example 8-20. Specifying a type for resource location
[TypeConverter(typeof(LocalizableTypeConverter))] [LocalizationResourceType(typeof(CustomerDetails))] public class CustomerName { . . . as before
These attributes are all that we will require the client code to use. Our localizing type converter will use the real names of the properties to look up the localized names in the resource manager. So simply adding entries in the culture-specific resource file mapping, say, Name to Nom, will be all that is required to localize the property names. Let us look at the code for the LocalizableTypeConverter and associated classes. Rather than presenting all the code in one go, we will look at it one piece at a time. Don't worry there are no missing pieces. First is the LocalizationResourceType attribute, which is shown in Example 8-21. Example 8-21. The LocalizationResourceType attribute
[AttributeUsage(AttributeTargets.All)] public class LocalizationResourceTypeAttribute : Attribute { private Type t; public LocalizationResourceTypeAttribute(Type resBase) { t = resBase; } public Type ResBase { get { return t; } } }
This is a straightforward custom attribute that holds a Type object for the benefit of the TypeConverter. Example 8-20 shows how this attribute will be used. This is just a perfectly normal and not very exciting custom attribute class, so we will move on to the converter's class declaration and its one field:
public class LocalizableTypeConverter : TypeConverter { private Type resBase = null;
Our class inherits from TypeConverter, because it is a type converter. The resBase field is used to hold the Type object for the class that will be used to initialize a ResourceManager. This will determine the name of the resource file that will contain the localized versions of the names. Next is the first override:
public override bool GetPropertiesSupported( ITypeDescriptorContext context)
{ }
return true;
Here we are simply indicating that our TypeConverter will supply PropertyDescriptor objects. The whole purpose of this class is to supply the PropertyGrid with appropriately tweaked descriptors, but it will only ask us for descriptors if we return true from this method, as we did in Example 8-19. Next is the GetProperties method, where we create our descriptors:
public override PropertyDescriptorCollection GetProperties( ITypeDescriptorContext context, object value, Attribute[] attributes) { EnsureAttrsRead(value); PropertyDescriptorCollection pdc;
pdc = TypeDescriptor.GetProperties(value, attributes, true);
PropertyDescriptor[] props = new PropertyDescriptor[pdc.Count]; for (int i = 0; i < pdc.Count; ++i) { Attribute[] attrs = new Attribute[pdc[i].Attributes.Count]; pdc[i].Attributes.CopyTo(attrs, 0); props[i] = new LocalizablePropertyDescriptor(resBase,
pdc[i], attrs);
The EnsureAttrsRead method, shown below, makes sure that we have checked for the presence of the LocalizationResourceType attribute before proceeding. We use the TypeDescriptor class to provide us with a complete set of nonlocalized PropertyDescriptor objects. We will rely on these to do most of the work, because we only want to change one aspect of their behavior; most of this function is concerned with building a copy of the information associated with these descriptors. So we build a new list of descriptors, using our LocalizablePropertyDescriptor class (shown later). This is the class that will provide the localized name in its DisplayName property. Next, the EnsureAttrsRead method checks for the LocalizationResourceType attribute:
private void EnsureAttrsRead(object o) { if (resBase == null) { object[] attr = o.GetType().GetCustomAttributes( typeof(LocalizationResourceTypeAttribute), true); if (attr != null && attr.Length != 0) { resBase = ((LocalizationResourceTypeAttribute)
This method is passed a single parameter: the object whose property names we are translating. It checks to see if that object's type definition has the LocalizationResourceType attribute. If it does, we store the Type object that it specifies in the resBase field. If the attribute is not present, we fall back to using the Type of the object itself. Next is the descriptor class itself. Rather than deriving directly from PropertyDescriptor, we use the helper base class provided by TypeConverter:
private class LocalizablePropertyDescriptor : TypeConverter.SimplePropertyDescriptor { private Type resBase; private string localizedName = null; private PropertyDescriptor realProp; public LocalizablePropertyDescriptor(Type resBase, PropertyDescriptor prop, Attribute[] attributes) : base (prop.ComponentType, prop.Name, prop.PropertyType, attributes) { this.resBase = resBase; realProp = prop; }
As before, the resBase property holds the Type object that will be used to initialize the ResourceManager. The localizedName field will hold the localized name once it has been looked upwe cache it here to avoid doing the lookup more than once. The realProp class holds a reference to the original PropertyDescriptor returned by TypeDescriptor.GetPropertieswe rely on this because our class does nothing more than localizing the display name. It defers to the real descriptor for everything else. The TypeConverter.SimplePropertyDescriptor class provides implementations for most of the abstract methods of PropertyDescriptor, but not GetValue or SetValue. This is because it doesn't presume that your descriptor will necessarily represent a real property, so it lets you implement them however you like. We just defer to the original PropertyDescriptor, which will just read and write the property respectively:
public override object GetValue(object component) { return realProp.GetValue(component); } public override void SetValue(object component, object value) {
realProp.SetValue(component, value);
Finally, we come to the part that this has all been building up tothe DisplayName property where we substitute the localized version of the name:
public override string DisplayName { get { if (localizedName == null) { ResourceManager rm = new ResourceManager(resBase); string tx = rm.GetString(base.DisplayName); if (tx != null) localizedName = tx; else localizedName = base.DisplayName; } return localizedName; } }
This looks almost identical to the core of the localizable category and description attributes. This is because they do much the same thingwe obtain a resource manager and use it to look up the localized string. This descriptor makes sure that it only does this lookup once, by caching the result in the localizedName property. So with this code in place, and the relevant attributes in use, all that is required are some suitable entries in the culture-specific resource file as shown in Figure 8-9. Our PropertyGrid is now fully localized, as Figure 8-10 shows. (The property values still look remarkably un-French, but because values are usually supplied by the user, it is not our job to localize them.) Figure 8-9. Localized strings for property names
There is a useful side effect of using these localization classes. Notice how in Figure 8-10 the DateOfBirth property has been translated with spaces between the words. Without our custom type converter in place, the displayed property names were just the real names as used in the source code, which precludes the use of spaces and most punctuation. But now we are free to use any text we like as the display name. You can employ such readability enhancements in your native languagethe ResourceManager is quite happy to look up resources even in the default culture, so long as you provide an appropriate .resx file. So if you supply a culture-neutral resource file, you can create entries mapping "DateOfBirth" onto "Date of Birth." So the fact that these classes allow you to decouple the display name from the real name is useful even when not translating text to another language. A culture-neutral resource file is one without a culture in the file name, such as CustomerDetails.resx. Such resources are built into the main assembly, not satellite assemblies.
There is one limitation with these classes. If you use the LocalizableTypeConverter, you can no longer use other converters, such as the ExpandableObject or converters of your own devising. For the latter this is fairly easy to fixsimply modify your own converters to inherit from LocalizableTypeConverter instead of TypeConverter. Of course, you can't do this with ExpandableObjectonly Microsoft gets to decide what that derives from. Fortunately, LocalizableTypeConverter already does everything that ExpandableObject does, so in practice it doesn't matter. Our CustomerDetails class has evolved since the version shown in Example 8-1, so Example 8-22 shows the modified class with all the relevant attributes in place. Example 8-22. CustomerDetails class with localizable categories and descriptions
[TypeConverter(typeof(LocalizableTypeConverter))] public class CustomerDetails { private CustomerName name; private string address; private DateTime dob;
[LocalizableCategory("Name", typeof (CustomerDetails))] [LocalizableDescription("Name.Description", typeof(CustomerDetails))] public CustomerName Name { get { return name; } set { name = value; } } [LocalizedCategory("Demographics", typeof (CustomerDetails))] [LocalizedDescription("DateOfBirth.Description", typeof(CustomerDetails))] public DateTime DateOfBirth { get { return dob; } set { dob = value; } } [LocalizedCategory("Location", typeof (CustomerDetails))] [LocalizedDescription("Address.Description", typeof(CustomerDetails))] public string Address { get { return address; } set { address = value; } } }
So, we have seen how to control which properties appear in the grid, what they are called, and how type conversions occur when moving data to and from the grid. But so far, the user interface for each individual property has consisted of nothing more exciting than an editable text field. We will now see how to add our own editing user interfaces to items on a PropertyGrid.
Let us add a new property to our CustomerDetails class (as shown in Example 8-22) so that we can supply a custom editing user interface for it. The new property is Happiness, and it indicates the level of customer satisfaction, on a range of 0 to 100%. It is shown in Examples Example 8-23 and Example 8-24. The editor has been specified with the Editor attribute. (The second parameter is always required to be UITypeEditor in the current version of the framework.) The property's type here is int or Integer, but we can provide custom UI editors for any type, whether it is a custom type or a built-in type. Example 8-23. Happiness property with editor using C#
private int happy;
[Editor(typeof(HappinessEditor), typeof(UITypeEditor))]
The simplest editing user interface that we can show is a modal dialog. Creating an editor class to provide this is very straightforward. Examples Example 8-25 and Example 8-26 show a custom type editor that simply presents a message box as its user interface. It asks the user if they are happy (yes or no), and sets their happiness as either 100% or 0% accordingly. (If the user hits cancel, the happiness is left unaltered.) Example 8-25. Modal dialog custom type editor using C#
public class HappinessEditor : UITypeEditor { public override UITypeEditorEditStyle GetEditStyle( ITypeDescriptorContext context) { return UITypeEditorEditStyle.Modal; } public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value) { DialogResult rc = MessageBox.Show("Are you happy?", "Happiness", MessageBoxButtons.YesNoCancel); if (rc == DialogResult.Yes)
The PropertyGrid will indicate the availability of this editor by placing a small button with an ellipsis in the property's value field when it is given the focus, as shown in Figure 8-11. The PropertyGrid knows to show the button because of the value returned by our editor class's GetEditStyle method. Figure 8-11. A modal editing UI offered in a PropertyGrid Modal dialog editors are easy to write, but they are usually not the most convenient kind of editor to use. Most of the system-supplied editors use the drop-down style, because it is less disruptive to the use of the program and makes it feel as if the property is an integrated part of the PropertyGrid. Showing a drop-down editor is almost as easy as showing a modal dialog. The main difference is that we have to supply a control rather than a form. A UserControl is likely to be the easiest option, although you can use a custom control. (In fact, you could even use one of the built-in controls.)
Examples Example 8-27 and Example 8-28 show a type editor that displays a control called HappinessControl. The most interesting part of this is the way in which it displays the control as a drop-down editor in the PropertyGrid. To do this, we must ask the grid for a service object. We do this through the IServiceProvider passed as the provider argumentthis is a generic interface that allows hosted components to ask their environment for certain facilities. In this case, we are asking the grid control for the service that lets us display drop-down editors, which is provided through the IWindowsFormsEditorService interface. Example 8-27. A drop-down type editor using C#
public class HappinessEditor : UITypeEditor { public override UITypeEditorEditStyle GetEditStyle( ITypeDescriptorContext context) { return UITypeEditorEditStyle.DropDown; } public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value) {
IWindowsFormsEditorService wfes = provider.GetService( typeof(IWindowsFormsEditorService)) as IWindowsFormsEditorService;
If Not wfes Is Nothing Then Dim hc As New HappinessControl() hc.Value = CInt(value) wfes.DropDownControl(hc) value = hc.Value End If Return value End Function End Class
When we call the DropDownControl method on the service object, it displays our control in the appropriate position on the PropertyGrid. It adjusts its size so that it is the same width as the value field, as shown in Figure 8-12. (The HappinessControl is just a UserControl. The most interesting code is the part that draws the face, which we will see shortly.) Figure 8-12. A drop-down editor in action
As you can see, in contrast to the rather dry yes/no interface provided by the MessageBox class or the VB MsgBox function, here we have gone for a slightly more emotive interface the customer satisfaction level is indicated by how happy or sad the face looks. The code from the Happiness control that draws this is shown in Examples Example 8-29 and Example 8-30. Example 8-29. Putting a happy face on the control using C#
public static void PaintFace(Graphics g, Rectangle r, int happiness) { r.Width -= 1; r.Height -= 1; float w = r.Width; float h = r.Height; // Draw face g.FillEllipse(Brushes.Yellow, r); g.DrawEllipse(Pens.Black, r); // Draw eyes float eyeLevel = h / 4; float eyeOffset = w / 4; float eyeSize = w / 6; g.FillEllipse(Brushes.Black, w/2 - eyeOffset, eyeLevel, eyeSize, eyeSize); g.FillEllipse(Brushes.Black, w/2 + eyeOffset - eyeSize + 1, eyeLevel, eyeSize, eyeSize);
// Draw smile float smileWidth = w/3; float smileLevel = (h*7)/10; float offs = ((happiness - 50) * h/4)/100; PointF[] points = { new PointF ((w - smileWidth)/2, smileLevel), new PointF ((w - smileWidth)/2, smileLevel + offs), new PointF ((w + smileWidth)/2+1, smileLevel + offs), new PointF ((w + smileWidth)/2+1, smileLevel) }; g.DrawBeziers(Pens.Black, points);
The PropertyGrid control also lets us draw into the value field even when our editor is not running. Because the PaintFace method shown in Examples Example 8-29 and Example 8-30 has been written to scale its drawing to whatever space is available, we can call the same code from our HappinessEditor class to draw a small version of the face into the PropertyGrid, as shown in Examples Example 8-31 and Example 8-32.
To get our drawing into the value field, we are required to override the GetPaintValueSupported method and return true. Having done this, the PropertyGrid will call our PaintValue method whenever it repaints the value field. We just call the static PaintFace method supplied by the HappinessControl. Notice how we turn on the high-quality smoothing mode on the Graphics object firstthis enables antialiasing, which is particularly important on small drawings. Without switching this on, the drawing would look rather ragged. Having changed the smoothing mode, it is important to restore it to its original value before the method terminates, because we are drawing with the same Graphics object that the PropertyGrid uses to draw itself. The results of this painting can be seen in Figure 8-13. Figure 8-13. Drawing in the value field
8.4 Summary
The PropertyGrid provides a simple but effective way of presenting data for editing. It uses reflection to discover which properties are available for editing. It allows every aspect of the property editing process to be controlled by using a TypeConverter. We can
manage conversions between the text displayed and edited onscreen, and the types stored in the object. We can control which properties are displayed, and how they are presented. We can even supply our own editor user interfaces for when simple text editing is insufficient. The PropertyGrid is also central to the Windows Forms Designer, so an understanding of how it works is crucial to integrating your controls with the development environment successfully. In the next chapter, we will look in detail at Visual Studio .NET's other design-time integration features.