Introduction
I've been looking at how to program a property grid declaratively, mainly because I don't want to create hard-coded classes just so I can use a property grid for some configuration settings which will most likely require changes as the project evolves. So I looked at what's involved in managing a property grid. This article illustrates the following:
- declarative programming of a property grid
- binding the container data to a control
- firing an event when a property changes
- serializing/deserializing the container
This article uses the code previously discussed in the following articles:
How Does It Work?
The PropertyGrid
provides a SelectedObject
method which you set an instance which you'd like to use the PropertyGrid
to expose public properties for editing. Obviously, this requires that a class exists along with an instance of that class, so that not only can the PropertyGrid
determine the public properties but also the property values can be updated when the user makes changes. Approaching this problem declaratively requires that a class be constructed, compiled, and instantiated at runtime. This MxContainer
class does exactly this--using declarative XML and the MycroXaml (or MyXaml) parser, it instantiates a class based on attributes defined in the markup.
One of the features of the PropertyGrid
is that you can decorate the property methods with attributes that the PropertyGrid
uses to alter the behavior of the property when displayed. The MxContainer
class provides the MxProperty
class whose instances are added to the MxProperties
collection. The MxProperty
class supports the following property decorators:
Category
Description
ReadOnly
DefaultValue
DefaultProperty
(class decorator)
Besides handling basic types like string
, DateTime
, Font
, Color
, and bool
, the PropertyGrid
also knows how to display the members of an enumeration. The MxContainer
class supports enumerations by adding MxEnum
instances to its MxEnumerations
collections. These generate enumerations in the emitted code, which in turn can be applied as a property type.
The MxContainer
class also implements an OnValueChanged
event for each MxProperty
instance. This event, when assigned to a handler, is automatically converted to a specific OnXxxChanged
event, where "Xxx
" is the specific property name.
The Declarative Markup
The above screenshot is created with the following declarative XML:
="1.0" ="utf-8"
<MycroXaml Name="Form"
xmlns:wf="System.Windows.Forms, System.Windows.Forms, Version=1.0.5000.0,
Culture=neutral, PublicKeyToken=b77a5c561934e089"
xmlns:mc="MycroXaml.MxContainer, MycroXaml.MxContainer">
<wf:Form Name="AppMainForm" StartPosition="CenterScreen"
ClientSize="600, 400" Text="Declarative Property Grid Demo">
<mc:MxContainer Name="Container" DefaultProperty="OutputDirectory">
<mc:MxEnumerations>
<mc:MxEnum Name="MyEnum" Values="Executable, Library, Console"/>
</mc:MxEnumerations>
<mc:MxProperties>
<mc:MxProperty Name="Font" Type="Font"
Category="Editor" Description="The text editor font."
InitialValue="new Font("MS Sans Serif", 10)"/>
<mc:MxProperty Name="Color" Type="Color" InitialValue="Color.Green"
Category="Editor" Description="The text editor color."/>
<mc:MxProperty Name="OutputDirectory" Type="string"
InitialValue=""c:\\test""
DefaultValue=""c:\\test""
Category="Project Settings" Description="Output directory."
OnValueChanged="OnOutputDirectoryChanged"/>
<mc:MxProperty Name="Exclude" Type="bool"
Category="Project Settings"
Description="Exclude this project from the build."
InitialValue="true" DefaultValue="true"/>
<mc:MxProperty Name="OutputType" Type="MyEnum"
Category="Project Settings" Description="Project output type."
DefaultValue="MyEnum.Executable"/>
<mc:MxProperty Name="CreatedOn" Type="DateTime"
InitialValue="DateTime.Today" ReadOnly="true"
Category="Project Information" Description="Project start date."/>
<mc:MxProperty Name="LastModifiedOn" Type="DateTime"
InitialValue="DateTime.Today" ReadOnly="true"
Category="Project Information"
Description="Project last changed date."/>
</mc:MxProperties>
</mc:MxContainer>
<wf:Controls>
<wf:PropertyGrid Name="PropertyGrid" Dock="Fill"
SelectedObject="{Container}" HelpBackColor="LightSteelBlue"/>
<wf:TextBox Name="TextBox" Dock="Left" Text="Foobar" Multiline="true"
Width="300" BorderStyle="Fixed3D">
<wf:DataBindings>
<mc:DataBinding PropertyName="ForeColor" DataSource="{Container}"
DataMember="Color"/>
<mc:DataBinding PropertyName="Font" DataSource="{Container}"
DataMember="Font"/>
</wf:DataBindings>
</wf:TextBox>
<wf:Panel Dock="Top" Height="40">
<wf:Controls>
<wf:Button Text="Serialize" Location="10, 10" Size="80, 25"
FlatStyle="System" Click="OnSerialize"/>
<wf:Button Text="Deserialize" Location="100, 10" Size="80, 25"
FlatStyle="System" Click="OnDeserialize"/>
</wf:Controls>
</wf:Panel>
</wf:Controls>
</wf:Form>
</MycroXaml>
The salient features of the above markup are:
- support for enumerations
- the construction of a runtime container
- defining attribute decorators for the constructed class' properties
- use of "inline" code to initialize values
- data binding to alter
TextBox
properties automatically
Implementation
The core of the work is done by constructing a runtime class and instantiating it. The implementation is actually rather mundane--iterating through several collections to create the C# code, then invoking the compiler class to compile it. I didn't use Reflection.Emit
because I'm not familiar with it and I like to inspect the constructed code visually for errors.
MxContainer
MxContainer
constructs a class based on the XML definition. Note the following code:
sb.Append("\tpublic class "+className+" : MxDataContainer\r\n");
Notice how this class is derived from MxDataContainer
. This is so that the application can interact with the container through Set
/GetValue
methods since the application, at compile time, has no type information regarding the runtime constructed class. These and other methods support the automatic transfer of data from the control to the container and from the container to the control. Some of the code in this class can probably leverage data binding better than I have done, but this implementation does work.
Runtime Compiler
Once the class has been constructed, it is compiled:
RunTimeCompiler rtc=new RunTimeCompiler();
ArrayList refs=new ArrayList();
refs.Add("System.dll");
refs.Add("System.Data.dll");
refs.Add("System.Drawing.dll");
refs.Add("MycroXaml.MxContainer.dll");
Assembly assembly=rtc.Compile(refs, "C#", sb.ToString(), String.Empty);
Trace.Assert(assembly != null, "Compiler errors");
refObj=(MxDataContainer)
Activator.CreateInstance(assembly.GetModules(false)[0].GetTypes()[0]);
The problem here is anticipating the kinds of property types that the programmer might be using. Ideally, an assemblies collection should be added to the container so that the referenced assemblies can be defined declaratively rather than hard-coded. However, I've found that for all practical purposes, the above implementation works just fine, so I've never changed it!
The OnValueChanged Event
In the markup, each MxProperty
supports an OnValueChanged
event which can be wired up to an event handler. However, you can't use the same OnValueChanged
event for each property in the constructed class. Rather, each property needs its own OnXxxChanged
event handler, and the event assigned in the MxProperty
tag has to be re-assigned to the constructed property event handler. This is done with:
EventInfo ei=refObj.GetType().GetEvent("On"+prop.Name+"Changed");
Delegate srcDlgt=prop.ValueChangedDelegates[0];
Delegate dlgt=Delegate.CreateDelegate(ei.EventHandlerType, srcDlgt.Target,
srcDlgt.Method.Name);
ei.AddEventHandler(refObj, dlgt);
where "prop
" is an MxProperty
instance and ValueChangedDelegates
is a property of MxProperty
, returning the invocation list for the OnValueChanged
event:
public Delegate[] ValueChangedDelegates
{
get {return OnValueChanged.GetInvocationList();}
}
We're only interested in the first instance of the invocation list, since only one instance can be assigned declaratively. The above code thus illustrates how to copy an event handler from one event to another.
DataBinding
Databinding has to be done with a helper class because the Binding
class does not support a default constructor. So, the DataBinding
helper class looks like this:
public class DataBinding : ISupportInitialize, MycroXaml.Parser.IMycroXaml
{
protected string propertyName;
protected object dataSource;
protected string dataMember;
protected ControlBindingsCollection cbc;
protected Binding binding;
public String PropertyName
{
get{return propertyName;}
set {propertyName=value;}
}
public object DataSource
{
get {return dataSource;}
set {dataSource=value;}
}
public String DataMember
{
get {return dataMember;}
set {dataMember=value;}
}
public void Initialize(object parent)
{
cbc=parent as ControlBindingsCollection;
}
public object ReturnedObject
{
get {return binding;}
}
public void BeginInit()
{
}
public virtual void EndInit()
{
try
{
binding=new Binding(propertyName, dataSource, dataMember);
if (dataSource is MycroXaml.MxContainer.MxDataContainer)
{
((MycroXaml.MxContainer.MxDataContainer)dataSource).Add(cbc.Control, binding);
}
cbc.Add(binding);
}
catch(Exception e)
{
Trace.Fail(e.Message);
}
}
}
Notice how this class uses ISupportInitialize
to instantiate the Binding
class once all the property values have been assigned, and it also manually adds the resulting Binding
instance to the ControlBindingsCollection
. This cannot be done in the parser because the Add
method is declared with a "new
", overwriting the base class implementation. Since the method definitions are the same, acquiring the Add
method using reflection results in ambiguous methods. This is something the parser cannot easily handle.
Serialization/Deserialization
A configuration class, runtime generated or not, is pretty useless unless you can save the configuration and restore it later. As discussed in my article on simple serialization, a runtime constructed class cannot take advantage of the XmlSerializer
or BinaryFormatter
classes since the constructed class' type information is not known at compile time. Serialization is thus done with the simple serializer/deserializer I previously wrote. After deserialization, the controls to which the container properties are bound have to be updated manually (at least, I've not figured out how to do it automatically, as it probably has to do with not supporting the correct OnXxxChanged
construct--further investigation is needed). The lines:
propertyGrid.Refresh();
container.BeginEdit();
cause an update of the PropertyGrid
with the new values and update all data bound controls. (BeginEdit
is probably not the best name for this method, but the intention is to ensure that the controls are all updated with the latest container values before the user begins editing those values.)
Wrapping It All Together
So, what does the code look like that actually constructs the above UI? How does it all come together?
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text;
using System.Windows.Forms;
using System.Xml;
using Mtc.SimpleSerializer;
using MycroXaml.Parser;
using MycroXaml.MxContainer;
namespace PropertyGridDemo
{
public class Startup
{
protected MxDataContainer container;
protected PropertyGrid propertyGrid;
[STAThread]
static void Main()
{
new Startup();
}
public Startup()
{
Parser mp=new Parser();
StreamReader sr;
string text;
XmlDocument doc;
sr=new StreamReader("propertyGrid.xml");
text=sr.ReadToEnd();
sr.Close();
doc=new XmlDocument();
try
{
doc.LoadXml(text);
}
catch(Exception e)
{
Trace.Fail("Malformed xml:\r\n"+e.Message);
}
Form form=(Form)mp.Load(doc, "Form", this);
container=(MxDataContainer)mp.GetInstance("Container");
propertyGrid=(PropertyGrid)mp.GetInstance("PropertyGrid");
form.ShowDialog();
}
public void OnSerialize(object sender, EventArgs e)
{
Serializer s=new Serializer();
s.Start();
s.Serialize(container);
string text=s.Finish();
StreamWriter sw=new StreamWriter("data.xml");
sw.Write(text);
sw.Close();
}
public void OnDeserialize(object sender, EventArgs e)
{
StreamReader sr=new StreamReader("data.xml");
string text=sr.ReadToEnd();
sr.Close();
Deserializer d=new Deserializer();
d.Start(text);
d.Deserialize(container, 0);
propertyGrid.Refresh();
container.BeginEdit();
}
public void OnOutputDirectoryChanged(object sender,
ContainerEventArgs cea)
{
}
}
}
That's it! The MycroXaml
parser handles the instantiation of the object graph, the MycroXaml.MxContainer
assembly handles the runtime construction of the container, and the SimpleSerializer
assembly handles serialization/deserialization of the container. The UI and property grid container have been moved over to declarative code, so all that the imperative code has to do is fire things up and handle the events.
Conclusion
I find that using a declarative approach to construct custom containers suitable for manipulation with a PropertyGrid
is a simple and flexible approach. Combined with the ease of setting up data binding, event handling, and serialization, the classes presented here should save time by providing a general and flexible solution. Personally, I find that separating declarative program elements from imperative ones to be a very powerful mechanism to deal with the realities of software engineering--not enough design time, feature requests/changes during implementation, and the unknown territory (!) of maintenance and enhancements.