Click here to Skip to main content
15,882,055 members
Articles / Desktop Programming / WPF

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

Rate me:
Please Sign up or sign in to vote.
5.00/5 (1 vote)
20 Oct 2013CPOL6 min read 17.2K   232   8  
A property editor implemented as an ItemsControl

Introduction

This is Part 2 in my two part series about customizing the WPF ItemsControl:

  1. Yes, this also is an ItemsControl - Part 1: A Graph Designer with custom DataTemplate
  2. 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

C#
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

C#
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:

  1. SettablePropertyProxy: allows the setting of a property with just about any value. An example is an integer or a string.
  2. SingleSelectablePropertyProxy: Allows the setting of a property from a finit list of values. An example is an enum.
  3. MultiSelectablePropertyProxy: Allows the setting of a property from a finit list of values which can be combined. An example is an enum with a Flags 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

C#
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

C#
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
}
XML
<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 PropertyEditors.

SingleSelectablePropertyProxy.cs

C#
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));
	}
}
XML
<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 SelectMembers in the list. The fact that only a single value can be selected is managed by the SingleSelectMemberList.

C#
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

C#
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)));
	}
}
XML
<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.

;
C#
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:

  1. A holder which is used to hold the editor
  2. 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

XML
<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 values
  • DisplayName: does the same as Display but for properties of objects
  • Description: 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:

  1. Override the standard editor for a specific type: this can be done by adding your own editor to the TypeEditorRegistry.
  2. 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:

  1. Create an object of type TypeEditorRegistry
  2. Add an entry for the type and the editor you want to use
  3. Set the EditorRegistry of the objecteditor to the object created in step 1

In code you get:

C#
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

XML
<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

XML
<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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Belgium Belgium
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
-- There are no messages in this forum --