Yes, this also is an ItemsControl - Part 2: A minimalistic Property Editor





5.00/5 (1 vote)
A property editor implemented as an ItemsControl
Introduction
This is Part 2 in my two part series about customizing the WPF ItemsControl:
- Yes, this also is an ItemsControl - Part 1: A Graph Designer with custom DataTemplate
- Yes, this also is an ItemsControl - Part 2: A minimalistic Property Editor
This time I will show you an object editor control. Again, this is not the first (or best) implementation available for WPF: there are a number of other implementations available on the web like the one available in the Property Tools project, or this WPF Property Grid. You can also find one in the XCEED Extended WPF Toolkit. Most of these have far more functionality then the one I present here. Which is why I present you mine here: it's relativelly simple with not much styling making it easier to grasp how it works. It also shows how powerfull the ItemsControl is.
And thus again:
Disclaimer: This is a proof of concept. As such, the code has no intention to be feature complete, neither being of high quality or exception proof.
Implementation
Choosing the base control
I've choosen to derive directly from the ItemsControl
because none of the existing derivations of ItemsControl
provide any useful functionality I could use. On the contrary: a ListBox
for example allows for selection of one of the items in it, something which isn't desired in an object editor.
Implementing the ObjectEditor
control
The ObjectEditor
derives directly from the ItemsControl
and only adds two properties:
ObjectToEdit
: this is the object whose properties we will edit.EditorRegistry
: a mapping of types and controls to use for editing those types.ShowCategories
: use category grouping.
Inside the constructor of the control we set the ItemTemplateSelector to the type TypeEditorDataTemplateSelector
which implements the selection of the editors based on the type of the properties of the object. For this, it derives from DataTemplateSelector which is a .NET class providing the protocol for this functionality. The TypeEditorDataTemplateSelector
class implements the default mapping.
There are however two ways to override this default mapping. The first is to use the EditorRegistry
which allows for adding editors for specific types or for using specific editors for certain types. The second is to use the EditorResourceKey
attribute on the property for which you want to use a custom editor.
ContentItemsControl.cs
public class ObjectEditor : ItemsControl
{
public ObjectEditor()
{
ItemTemplateSelector = new TypeEditorDataTemplateSelector();
}
public object ObjectToEdit
{
get { return objectToEdit; }
set
{
objectToEdit = value;
ItemsSource = ObjectAnalyzer.GetProperties(objectToEdit);
}
}
public TypeEditorRegistry EditorRegistry
{
get
{
if (ItemTemplateSelector is TypeEditorDataTemplateSelector)
return (ItemTemplateSelector as TypeEditorDataTemplateSelector).EditorRegistry;
return null;
}
set
{
if (ItemTemplateSelector is TypeEditorDataTemplateSelector)
(ItemTemplateSelector as TypeEditorDataTemplateSelector).EditorRegistry = value;
}
}
}
TypeEditorDataTemplateSelector.cs
public class TypeEditorDataTemplateSelector : DataTemplateSelector
{
public TypeEditorRegistry EditorRegistry
{
get;
set;
}
public override DataTemplate
SelectTemplate(object item, DependencyObject container)
{
FrameworkElement element = container as FrameworkElement;
if (element != null && item != null && item is PropertyEditor)
{
PropertyEditor propertyDescriptor = item as PropertyEditor;
String editorResoucreKey = propertyProxy.EditorResourceKey;
if (editorResoucreKey != null)
{
DataTemplate dataTemplate = Application.Current.FindResource(editorResoucreKey) as DataTemplate;
return dataTemplate;
}
if (EditorRegistry != null && EditorRegistry.ContainsKey(propertyDescriptor.DataType))
{
return EditorRegistry[propertyDescriptor.DataType];
}
if (propertyDescriptor.DataType == typeof(int))
{
ComponentResourceKey integerKey = new ComponentResourceKey(typeof(ObjectEditor), "integerEditorTemplate");
return element.FindResource(integerKey) as DataTemplate;
}
else if (propertyDescriptor.DataType == typeof(string))
{
ComponentResourceKey stringKey = new ComponentResourceKey(typeof(ObjectEditor), "defaultEditorTemplate");
return element.FindResource(stringKey) as DataTemplate;
}
else if(propertyProxy.DataType == typeof(bool) && !propertyProxy.HasAllowedValues)
{
ComponentResourceKey stringKey = new ComponentResourceKey(typeof(ObjectEditor), "boolEditorTemplate");
return element.FindResource(stringKey) as DataTemplate;
}
else if (propertyDescriptor.DataType.IsEnum)
{
if (propertyDescriptor.DataType.GetCustomAttributes(typeof(FlagsAttribute), true).Count() == 0)
{
ComponentResourceKey stringKey = new ComponentResourceKey(typeof(ObjectEditor), "singleSelectEditorTemplate");
return element.FindResource(stringKey) as DataTemplate;
}
else
{
ComponentResourceKey stringKey = new ComponentResourceKey(typeof(ObjectEditor), "multiSelectEditorTemplate");
return element.FindResource(stringKey) as DataTemplate;
}
}
else if (propertyProxy.HasAllowedValues)
{
ComponentResourceKey stringKey = new ComponentResourceKey(typeof(ObjectEditor), "singleSelectEditorTemplate");
return element.FindResource(stringKey) as DataTemplate;
}
else
{
throw new NotSupportedException();
}
}
return null;
}
}
Filling the ObjectEditor
To fill the ObjectEditor
with items we use the ObjectProxy
. This class is used to convert an object to a list of PropertyProxy
objects, which has three derived classes:
SettablePropertyProxy
: allows the setting of a property with just about any value. An example is an integer or a string.SingleSelectablePropertyProxy
: Allows the setting of a property from a finit list of values. An example is anenum
.MultiSelectablePropertyProxy
: Allows the setting of a property from a finit list of values which can be combined. An example is anenum
with aFlags
attribute.
The PropertyProxy
is responsible for getting and setting the value of the property of the object.
Depending on the type of subclass this happens by binding directly to the value or by binding to a list of possible values which have a selected property.
ObjectProxy.cs
public class ObjectProxy
{
// More code preceding
public static ObservableCollection<PropertyProxy> GetProperties(object obj)
{
ObservableCollection<PropertyProxy> result = new ObservableCollection<PropertyProxy>();
PropertyDescriptorCollection propColl = TypeDescriptor.GetProperties(obj);
foreach (PropertyDescriptor property in TypeDescriptor.GetProperties(obj))
{
if (!property.IsBrowsable)
{
continue;
}
PropertyProxy editor = null;
if (IsSingleSelectable(property))
{
editor = new SingleSelectablePropertyProxy(property, obj);
}
else if (IsMultiSelectable(property))
{
editor = new MultiSelectablePropertyProxy(property, obj);
}
else
{
editor = new SettablePropertyProxy(property, obj);
}
result.Add(editor);
foreach (TypeConverter converter in typeConverterList.Where(x => x.Key == editor.DataType).Select(x => x.Value))
{
if (converter.CanConvertTo(typeof(string)))
{
editor.Converter = converter;
break;
}
}
}
return result;
}
private static bool IsSingleSelectable(PropertyDescriptor property)
{
if (property.PropertyType.IsEnum
&& property.PropertyType.GetCustomAttributes(typeof(FlagsAttribute), true).Count() == 0)
return true;
if (property.Attributes.OfType<AllowedStringValue>().Any())
{
return true;
}
return false;
}
private static bool IsMultiSelectable(PropertyDescriptor property)
{
if (property.PropertyType.IsEnum
&& property.PropertyType.GetCustomAttributes(typeof(FlagsAttribute), true).Count() != 0)
return true;
return false;
}
// More code following
}
SettablePropertyProxy.cs
public class SettablePropertyProxy : PropertyProxy, IDataErrorInfo
{
// More code preceding
public object Value
{
get
{
if (Converter == null)
{
throw new NotSupportedException();
}
return (string)Converter.ConvertTo(Property.GetValue(ObjectToEdit), typeof(string));
}
set
{
if (Converter == null)
{
throw new NotSupportedException();
}
if (Property.PropertyType.IsAssignableFrom(value.GetType()))
{
Property.SetValue(ObjectToEdit, value);
return;
}
Property.SetValue(ObjectToEdit, Converter.ConvertFrom(null, null, value));
}
}
// More code following
}
<DataTemplate x:Key="{ComponentResourceKey {x:Type local:ObjectEditor}, defaultEditorTemplate}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
</Grid.RowDefinitions>
<TextBox Grid.Row="0">
<TextBox.Text>
<Binding Mode="TwoWay" Path="Value" UpdateSourceTrigger="PropertyChanged"
ValidatesOnDataErrors="True" NotifyOnValidationError="True">
</Binding>
</TextBox.Text>
</TextBox>
</Grid>
</DataTemplate>
As you can see from the above code, by binding to the Value
property you ultimately set the property represented by this object. The SettablePropertyProxy
has a Converter
property which allows for the type conversion of the value from the editor, which will typicaly be a string, to the type of the object property. The Converter
property is set in the ObjectAnalyzer
during construction of the PropertyEditor
s.
SingleSelectablePropertyProxy.cs
public class SingleSelectablePropertyProxy : PropertyEditor
{
public SingleSelectablePropertyProxy(PropertyDescriptor propertyDescriptor, object target)
: base(propertyDescriptor, target)
{
valueList = new SingleSelectMemberList();
if (propertyDescriptor.PropertyType.IsEnum)
{
foreach (FieldInfo field in propertyDescriptor.PropertyType.GetFields(BindingFlags.Static | BindingFlags.Public))
{
SelectMember member = new SelectMember() { Value = field.Name, Display = field.Name };
object[] displayAttributes = field.GetCustomAttributes(typeof(DisplayAttribute), false);
if(displayAttributes.Count() != 0)
{
DisplayAttribute displayAttribute = displayAttributes[0] as DisplayAttribute;
member.Display = displayAttribute.Name;
}
if (propertyDescriptor.GetValue(target).ToString() == field.Name)
{
member.IsSelected = true;
}
valueList.Add(member);
}
}
else
{
// Values can also be provided using the AllowedStringValue attribute
// Creation of the list however is basically the same
}
valueList.SelectedValueChanged += new EventHandler<SelectedValue>(valueList_SelectedValueChanged);
}
public SingleSelectMemberList ValueList
{
get
{
return valueList;
}
}
private void valueList_SelectedValueChanged(object sender, SelectedValue e)
{
Property.SetValue(ObjectToEdit, Enum.Parse(Property.PropertyType, e.Value));
}
}
<DataTemplate x:Key="{ComponentResourceKey {x:Type local:ObjectEditor}, singleSelectEditorTemplate}">
<Grid Background="#FFC0C0C0">
<Grid.RowDefinitions>
<RowDefinition />
</Grid.RowDefinitions>
<ListBox Grid.Row="0" ItemsSource="{Binding ValueList}">
<ListBox.Resources>
<Style x:Key="{x:Type ListBoxItem}" TargetType="ListBoxItem">
<Setter Property="SnapsToDevicePixels" Value="true"/>
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<RadioButton x:Name="radio" IsChecked="{Binding IsSelected}">
<RadioButton.Content>
<ContentPresenter />
</RadioButton.Content>
</RadioButton>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.Resources>
</ListBox>
</Grid>
</DataTemplate>
In the constructor a list of possible values is created. In the case of a SingleSelectablePropertyProxy
this is a SingleSelectMemberList
. In the XAML we bind the ItemsSource
of a ListBox
to this list. The ListBoxItems
have a RadioButton
as content which is bound to the IsSelected
property of the SelectMember
s in the list. The fact that only a single value can be selected is managed by the SingleSelectMemberList
.
public class SingleSelectMemberList : ObservableCollection<SelectMember>
{
void EnumMemberList_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (isChangingSelected)
return;
isChangingSelected = true;
SelectMember previousSelection = null;
foreach (SelectMember member in Items)
{
if (member.IsSelected && member != sender)
previousSelection = member;
}
previousSelection.IsSelected = false;
if (SelectedValueChanged != null)
{
SelectedValueChanged(this, new SelectedValue() { Value = (sender as SelectMember).Value });
}
isChangingSelected = false;
}
}
MultiSelectablePropertyProxy.cs
public class MultiSelectablePropertyProxy : PropertyProxy
{
public MultiSelectablePropertyProxy(PropertyDescriptor propertyDescriptor, object objectToEdit)
: base(propertyDescriptor, objectToEdit)
{
HasAllowedValues = true;
valueList = new MultiSelectMemberList();
foreach (object field in Enum.GetValues(propertyDescriptor.PropertyType))
{
SelectMember member = new SelectMember() { Value = field.ToString() };
if (propertyDescriptor.GetValue(objectToEdit).ToString().Contains(field.ToString()))
{
member.IsSelected = true;
}
valueList.Add(member);
}
valueList.SelectedValuesChanged += new EventHandler<SelectedValueList>(valueList_SelectedValueChanged);
}
public MultiSelectMemberList ValueList
{
get
{
return valueList;
}
}
void valueList_SelectedValueChanged(object sender, SelectedValueList e)
{
string valueList = "";
if (e.Values.Count == 0)
{
Property.SetValue(ObjectToEdit, 0);
return;
}
foreach (string value in e.Values)
{
valueList = valueList + "," + value;
}
Property.SetValue(ObjectToEdit, Enum.Parse(Property.PropertyType, valueList.Substring(1)));
}
}
<DataTemplate x:Key="{ComponentResourceKey {x:Type local:ObjectEditor}, multiSelectEditorTemplate}">
<Grid Background="#FFC0C0C0">
<Grid.RowDefinitions>
<RowDefinition />
</Grid.RowDefinitions>
<ListBox Grid.Row="0" ItemsSource="{Binding ValueList}">
<ListBox.Resources>
<Style x:Key="{x:Type ListBoxItem}" TargetType="ListBoxItem">
<Setter Property="SnapsToDevicePixels" Value="true"/>
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<CheckBox x:Name="radio" IsChecked="{Binding IsSelected}">
<CheckBox.Content>
<ContentPresenter />
</CheckBox.Content>
</CheckBox>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.Resources>
</ListBox>
</Grid>
</DataTemplate>
The MultiSelectablePropertyProxy
is very similar to the SingleSelectablePropertyProxy
: it also binds to a list of possible values, this time represented by the MultiSelectMemberList
, which this time manages the fact that multiple items can be selected. Here also in XAML we bind to this MultiSelectMemberList but the IsSelected member is now bound to a CheckBox because this is more in line with what we've come to expect when being able to select multiple items.
void EnumMemberList_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
List<String> selectedValues = new List<string>(Items.Where(x => x.IsSelected == true).Select(x => x.Value).AsEnumerable());
if (SelectedValuesChanged != null)
{
SelectedValuesChanged(this, new SelectedValueList() { Values = selectedValues });
}
}
Visualization
The visualization of a property involves two things:
- A holder which is used to hold the editor
- The editor itself
The holder is represented by the ObjectEditorItem
class and its style.
The datacontext of the ObjectEditorItem
is the PropertyProxy
derived class created during the object analysis, so you can use any of its properties to bind to. The style must also have a ContentPresenter
inside which the editor is displayed. The filling of this ContentPresenter
is entirely handled by the WPF framework.
ObjectEditorItem.xaml
<Style TargetType="{x:Type local:ObjectEditorItem}" BasedOn="{StaticResource {x:Type ContentControl}}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:ObjectEditorItem}">
<Grid Background="#FFC0C0C0">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding DisplayName}" ToolTip="{Binding Description}">
<TextBlock.Style>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="FontWeight" Value="Bold" />
</Style>
</TextBlock.Style>
</TextBlock>
<ContentPresenter Grid.Row="1"></ContentPresenter>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
The sample code
Default Editors
Simple Properties
This demonstrates the editing of an object with simple properties. That is, properties with basic types like int, string, etc... and no customization whatsoever. Thus, the object to edit has simple standard .NET properties with no attributes or anything special.
Validate Properties
This example demonstrates the support for the standard .NET validation attributes, like StringLength
, Range
, etc...
Display Properties
This demonstrates the use of some annotations which modify the way properties are displayed.
Browsable
: with this attribute you can make properties invisible to the objecteditor.Display
: allows to change the name used for enumeration valuesDisplayName
: does the same asDisplay
but for properties of objectsDescription
: provide a description for a property of an object
All these attributes are standard .NET attributes, also supported by the conventional propertyeditor.
Allowed Values
If a property can only have a limited set of values you typically use a enumeration. However, it is possible that for some reason you can not use and for this I provide the AllowedValue
attribute. This example demonstrates its use.
Custom Editors
Of course, it is possible you would like to provide your own custom editor for a property of an object. There are two ways of defining custom editors:
- Override the standard editor for a specific type: this can be done by adding your own editor to the
TypeEditorRegistry
. - Specify a specific editor for a specific property: this is done by using the
Editor
attribute.
Using TypeEditorRegistry
To override the standard editor used for a certain type you:
- Create an object of type
TypeEditorRegistry
- Add an entry for the type and the editor you want to use
- Set the EditorRegistry of the objecteditor to the object created in step 1
In code you get:
TypeEditorRegistry editorRegistry = new TypeEditorRegistry();
editorRegistry.Add(typeof(int), (DataTemplate)Application.Current.Resources["upDownIntegerEditorTemplate"]);
MyObjectEditor.EditorRegistry = editorRegistry;
Using EditorResourceKey
To override the editor used for a specific property you should use the EditorResourceKey attribute
Custom Holder
There is also the ability to override the holder used for the editors by setting the ItemContainerStyle
property of the ObjectEditor
<c:ObjectEditor x:Name="MyObjectEditor" >
<ItemsControl.ItemContainerStyle>
<Style TargetType="c:ObjectEditorItem">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="c:ObjectEditorItem">
<GroupBox Header="{Binding DisplayName}">
<ContentPresenter Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
/>
</GroupBox>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ItemsControl.ItemContainerStyle>
</c:ObjectEditor>
Group Category
There is also the ability to implement category grouping by setting the GroupStyle property of the ObjectEditor
<c:ObjectEditor x:Name="MyObjectEditor" >
<ItemsControl.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Path=Name}"/>
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ItemsControl.GroupStyle>
</c:ObjectEditor>
Todo
Here's a list of things that come to mind:
- Add support for the editor attribute
- Add support for more types
- Add support for complex types