Introduction
A common problem exists for container controls with dynamically loaded child controls: How can we expose events for individual children, when we don't know them at compile time? The ButtonContainer
control used in the demo project exemplifies the problem; it creates a button for every value defined for the Enum
type, which is specified by the 'EnumType
' property. A common 'ButtonClick
' event signals when a button was clicked. By specifying the button as the sender, the consumer knows the button instance.
public class ButtonContainer : UserControl
{
public event EventHandler ButtonClick;
private Type enumType = typeof(AnchorStyles);
public Type EnumType
{
get { return enumType; }
set
{
enumType = value;
createButtons();
}
}
private void createButtons()
{
foreach (string name in Enum.GetNames(enumType))
{
Button btn = new Button();
btn.Name = name;
btn.Text = name;
...
btn.Click += new EventHandler(btn_Click);
Controls.Add(btn);
}
}
void btn_Click(object sender, EventArgs e)
{
Button btn = (Button) sender;
if (ButtonClick != null)
{
ButtonClick(btn, EventArgs.Empty);
}
}
}
Now to expose individual 'Click
' events associated with a single button, we cannot declare an array of events: It won't compile.
public event EventHandler[] SingleClick;
The known alternative is to provide 'AddEventHandler
' and 'RemoveEventHandler
' methods, that allow to attach/detach a listener at runtime for an individual event (identified here by an enum
value).
[NonSerialized]
private Dictionary<object,> eventDictionary;
public void AddEventHandler(object value, EventHandler listener)
{
eventDictionary = eventDictionary ?? new Dictionary<object,>();
if (eventDictionary.ContainsKey(value))
{
EventHandler newDel = (EventHandler)Delegate.Combine
(eventDictionary[value], listener);
eventDictionary[value] = newDel;
}
else
{
eventDictionary.Add(value, listener);
}
}
public void RemoveEventHandler(object value, EventHandler listener)
{
if (eventDictionary.ContainsKey(value))
{
EventHandler newDel = (EventHandler)Delegate.Remove
(eventDictionary[value], listener);
if (newDel != null)
{
eventDictionary[value] = newDel;
}
else
{
eventDictionary.Remove(value);
}
}
}
private void OnButtonClick(Button sender, object value)
{
if (eventDictionary == null) return;
if (eventDictionary.ContainsKey(value))
{
EventHandler listener = eventDictionary[value];
listener.Invoke(sender, EventArgs.Empty);
}
}
This is currently the state on the subject of dynamic events, when you hunt the web. This article extends the workaround, by providing design time support which allows to manage dynamic events from the property grid as usual.
Background: Events at Design Time
An event is described by an EventDescriptor
instance, created by the TypeDescriptor.CreateEvent
method and is obtainable via TypeDescriptor.GetEvents()
for a type. The IEventBindingService
hashes the descriptor and creates an associated PropertyDescriptor
(a private EventBindingService+EventPropertyDescriptor
instance), which stores the attached listener method name as its string
value.
When a component is serialized to designer code, an event with attached listener is represented in CodeDom as a CodeAttachEventStatement
. In deserialization, CodeDomSerializerBase
obtains the associated EventPropertyDescriptor
from IEventBindingService
and persists the listener by method name. The component's EventHandlerList
which holds attached listeners at runtime is unused.
The VS property grid queries TypeDescriptor
for all EventDescriptor
's of the component, and encapsulates associated EventPropertyDescriptor
's within its own internal grid entries, which are shown on the events tab. Like properties and attributes, events can be filtered through IDesignerFilter
and ITypeDescriptorFilterService
implementations for a type or specific component.
What We Can't Do
- Creating an EventDescriptor for a non existing event:
TypeDescriptor.CreateEvent()
actually returns a descriptor, but it will throw exceptions, as soon as Reflection finds out there is no defined event.
- Using a custom EventPropertyDescriptor:
IEventBindingService
recognizes only its own private EventPropertyDescriptor
.
- Using a custom EventBindingService:
The service is not specific for a component Site
, it is responsible for all components on the DesignSurface
. Exchanging a surface service from our component type is tricky, as we have to ensure that it is added with the first loaded component and removed with the last disposed. Additionally frequent reloading of controls occurs, when a project is recompiled.
While we could solve above synchronization problem, we still need to get initial EventPropertyDescriptor
values, as the default IEventBindingService
is always loaded first by the designer loader, way before our component is loaded.
An implementation of the service is only trivial, if we 'borrow' Microsoft's internal VSCodeDomDesignerLoader+VSCodeDomEventBindingService
code. Our component would then need to reference some 'Microsoft.VisualStudio.*
' assemblies, which is undesirable.
The Solution
The solution consists of a ControlDesigner
, a CodeDomSerializer
, an EventDescriptor
, a TypeDelegator
and an Attribute
class.
[AttributeUsage(AttributeTargets.Class , AllowMultiple= false, Inherited= true)]
internal sealed class EnumMemberAttribute : Attribute
{
public EnumMemberAttribute(Type type, object value, string name);
public object EnumValue { get;}
public string EnumName { get;}
public Type EnumType { get;}
}
TypeAlias
The TypeDelegator
provides a very convenient way to inherit from the System.Type
class, as it delegates all methods to the type passed in the constructor. Our TypeAlias
wraps the event type (demo: System.EventHandler
). By overriding Equals()
and GetHashCode()
, we can create separate event types for each EnumMemberAttribute
, which behave as the event type, yet are individual in comparison.
TypeAlias
is the little hero of the solution, saving us on two occasions, as we will see later. I must credit Schalk van Wyk for inspiration.
internal sealed class TypeAlias : TypeDelegator
{
private readonly EnumMemberAttribute enumMember;
public TypeAlias(Type type, EnumMemberAttribute enumMember) : base(type)
{
this.enumMember = enumMember;
}
public EnumMemberAttribute EnumMember
{
get { return enumMember; }
}
public override bool Equals(object obj)
{
TypeAlias that = obj as TypeAlias;
if (that != null)
{
return (that.typeImpl == typeImpl) && (that.enumMember == enumMember);
}
return base.Equals(obj);
}
public override int GetHashCode()
{
return base.GetHashCode() ^ enumMember.GetHashCode();
}
}
CustomEventDescriptor
As will be evident from further reading, we use an inherited EventDescriptor
to represent each dynamic event. CustomEventDescriptor
returns a TypeAlias
delegator instead of the event type.
internal sealed class CustomEventDescriptor : EventDescriptor
{
private readonly TypeAlias eventTypeAlias;
private readonly Type componentType;
public CustomEventDescriptor(EventDescriptor descr,
EnumMemberAttribute enumMember)
: base(descr, null)
{
componentType = descr.ComponentType;
eventTypeAlias = new TypeAlias(descr.EventType, enumMember);
}
public override Type ComponentType
{
get { return componentType; }
}
public override Type EventType
{
get { return eventTypeAlias; }
}
irrelevant implemented members (EventDescriptor is an abstract class) ...
}
ButtonContainerDesigner
On the designer we declare a dummy event, which is available only at design time.
public event EventHandler ButtonClick_;
Designer creates the necessary attributes, to identify each dynamic event for the control.
private IEnumerable<EnumMemberAttribute> createAttributes()
{
ButtonContainer control = (ButtonContainer)Control;
List<EnumMemberAttribute> attributes = new List<EnumMemberAttribute>();
foreach (string name in Enum.GetNames(control.EnumType))
{
EnumMemberAttribute attr = new EnumMemberAttribute
(control.EnumType, Enum.Parse(control.EnumType, name), name);
attributes.Add(attr);
}
return attributes;
}
For all dynamic events, we create EventDescriptor
instances, pointing all to the dummy design event and sharing its name, yet which are displayed in the property grid with their individual names as separate events. As it turned out, the property grid uses one identical EventPropertyDescriptor
for all generated descriptors, although I could verify that the IEventBindingService
had hashed correctly and provided a different EventPropertyDescriptor
for each EventDescriptor
instance. Adding CustomEventDescriptor
instances instead, circumvents this unwanted merging behaviour. With the help of our TypeAlias
, the property grid perceives the generated events as having different event types and each entry uses the correct associated EventPropertyDescriptor
.
protected override void PreFilterEvents(IDictionary events)
{
Type componentType = GetType();
foreach (EnumMemberAttribute enumMemberAttribute in createAttributes())
{
string enumName = enumMemberAttribute.EnumName;
EventDescriptor ed = TypeDescriptor.CreateEvent
(componentType, "ButtonClick_", typeof(EventHandler),
new DesignOnlyAttribute(true),
new DisplayNameAttribute("ButtonClick_" + enumName),
new MergablePropertyAttribute(false),
enumMemberAttribute);
CustomEventDescriptor ced =
new CustomEventDescriptor(ed, enumMemberAttribute);
events.Add("ButtonClick_" + enumName, ced);
}
}
When an empty entry is double clicked in the property grid, its underlying EventPropertyDescriptor
creates a new listener method in the form file, generates a CodeAttachEventStatement
in the designer file and jumps to the listener method. The operation is wrapped in a DesignerTransaction
and is signalled via the IComponentChangeService
.
We intercept this by listening on IComponentChangeService.ComponentChanging
and prevent it silently by throwing a CheckoutException
. Attaching to the Application.Idle
event, allows us to process the intercepted descriptor, after the DesignerTransaction
was cancelled. Two separate bool
variables account for re-entering code, once we cancel original value and once we set our value on Application.Idle
.
private CustomEventDescriptor interceptedEventDescriptor;
private bool reEntrantCodeCancel;
private bool reEntrantCodeSet;
void IComponentChangeService_ComponentChanging
(object sender, ComponentChangingEventArgs e)
{
if (e.Component != Component || e.Member == null) return;
if (reEntrantCodeSet)
{
return;
}
if (reEntrantCodeCancel)
{
throw CheckoutException.Canceled;
}
CustomEventDescriptor ced = e.Member as CustomEventDescriptor;
if (ced == null)
{
return;
}
interceptedEventDescriptor = ced;
reEntrantCodeCancel = true;
Application.Idle += new EventHandler(Application_Idle);
throw CheckoutException.Canceled;
}
Now we can set the value using the individual displayed event name instead of the shared dummy name. We do not know the original entered value (could be typed in by user), we always use our algorithm to generate the listener name. If a listener method with appropriate name already exists, IEventBindingService
will attach it, otherwise the service will create it for us. If EventPropertyDescriptor
has already a listener attached, we reset it to null
and IEventBindingService
will remove the listener method from the file.
void Application_Idle(object sender, EventArgs e)
{
Application.Idle -= Application_Idle;
reEntrantCodeCancel = false;
reEntrantCodeSet = true;
IEventBindingService svc =
(IEventBindingService)GetService(typeof(IEventBindingService));
PropertyDescriptor eventProperty =
svc.GetEventProperty(interceptedEventDescriptor);
EnumMemberAttribute enumMemberAttribute =
(EnumMemberAttribute)interceptedEventDescriptor.Attributes
[typeof(EnumMemberAttribute)];
object curValue = eventProperty.GetValue(Component);
if (curValue == null)
{
string methodName = svc.CreateUniqueMethodName
(Component, interceptedEventDescriptor);
methodName += enumMemberAttribute.EnumName;
eventProperty.SetValue(Component, methodName);
}
else
{
eventProperty.SetValue(Component, null);
}
interceptedEventDescriptor = null;
reEntrantCodeSet = false;
}
At this point, the correct listener method was created, but the generated CodeAttachEventStatement
will not compile, as the dummy event exists only at design time.
private void buttonContainer1_ButtonClick_Left(object sender, EventArgs e)
{
}
this.buttonContainer1.ButtonClick_ +=
new System.EventHandler(this.buttonContainer1_ButtonClick_Left);
DesignEventSerializer
The serializer takes care of exchanging the erroneous CodeAttachEventStatement
, for a valid CodeMethodInvokeExpression
that targets the 'AddEventHandler
' method present on the control. The statement above is converted to:
this.buttonContainer1.AddEventHandler(System.Windows.Forms.AnchorStyles.Left,
new System.EventHandler(this.buttonContainer1_ButtonClick_Left));
We let the default ControlCodeDomSerializer
serialize the control into a CodeStatementCollection
, and exchange statements as fit.
public override object Serialize
(IDesignerSerializationManager manager, object value)
{
CodeDomSerializer defaultSerializer =
(CodeDomSerializer)manager.GetSerializer
(typeof(System.Windows.Forms.Control), typeof(CodeDomSerializer));
CodeStatementCollection statements =
(CodeStatementCollection)defaultSerializer.Serialize(manager, value);
foreach (CodeAttachEventStatement cas in
findAttachEventStatements(statements, "ButtonClick_"))
{
CodeMethodInvokeExpression cmie =
createMethodInvokeExpression(manager, "AddEventHandler", cas);
statements.Remove(cas);
statements.Add(cmie);
}
return statements;
}
We can get the needed data to construct the CodeMethodInvokeExpression
from the original CodeAttachEventStatement
. The CodeDelegateCreateExpression
which represents the listener method and the CodeFieldReferenceExpression
representing our control are directly available and reusable. Bummer!!! Where is the enum
value, we need to properly identify the event ? CodeDom
statements are not decorated by attributes, no way to obtain the EnumMemberAttribute
, we have used so far. We could infer the enum
name from the listener name, which is not a safe practice. Designers and serializers are truly separated entities by design and intent, should we provide some synchronization here ? Bummer!!!
private static CodeMethodInvokeExpression createMethodInvokeExpression
(IDesignerSerializationManager manager, string methodName,
CodeAttachEventStatement cas)
{
CodeDelegateCreateExpression listener =
(CodeDelegateCreateExpression) cas.Listener;
CodeFieldReferenceExpression targetObject = cas.Event.TargetObject;
CodeExpression enumValue = ???;
CodeMethodInvokeExpression cmie =
new CodeMethodInvokeExpression
(targetObject, methodName, enumValue == ??? , listener);
return cmie;
}
Well, every CodeDom CodeObject
exposes a 'UserData
' IDictionary
property, where the internal designer serialization infrastructure stores auxiliary name/value pairs to help involved serializers in their respective operations. In the case of a CodeAttachEventStatement
, only the event type is stored here. Since our CustomEventDescriptor
returned a TypeAlias
as event type, this was stored in the dictionary and delivers us the missing attribute. So little TypeDelegator
made our day twice and smuggled the needed data across the vast serialization infrastructure.
private static CodeMethodInvokeExpression createMethodInvokeExpression
(IDesignerSerializationManager manager, string methodName,
CodeAttachEventStatement cas)
{
CodeDelegateCreateExpression listener =
(CodeDelegateCreateExpression) cas.Listener;
CodeFieldReferenceExpression targetObject = cas.Event.TargetObject;
Debug.Assert(!(cas.UserData[typeof(Delegate)] is Delegate));
TypeAlias typeAlias = (TypeAlias)cas.UserData[typeof(Delegate)];
CodeDomSerializer serializer = (CodeDomSerializer)manager.GetSerializer
(typeAlias.EnumMember.EnumType, typeof(CodeDomSerializer));
CodeFieldReferenceExpression enumValue =
(CodeFieldReferenceExpression)serializer.Serialize
(manager, typeAlias.EnumMember.EnumValue);
return new CodeMethodInvokeExpression
(targetObject, methodName, enumValue, listener);
}
A second responsibility of the serializer is to obtain the initial values for the EventPropertyDescriptor
, when the control is deserialized. Before we let the default ControlCodeDomSerializer
deserialize the control from the CodeStatementCollection
, we strip out all 'AddEventHandler
' method invocations. Default serialization deserializes any event listener CodeDom
expression to null
, it would fill control's eventDictionary
field with null
values.
Instead, we infer from each CodeMethodInvokeExpression
the listener name and the enum
value identifying the proper event, find the corresponding EventPropertyDescriptor
and set the listener name as its value.
private void setEventProperties
(IDesignerSerializationManager manager,
object instance, IEnumerable<CodeMethodInvokeExpression> list)
{
EventDescriptorCollection eventDescriptors =
TypeDescriptor.GetEvents(instance, new Attribute[]
{ new DesignOnlyAttribute(true) });
IEventBindingService service =
(IEventBindingService)manager.GetService(typeof(IEventBindingService));
foreach (CodeMethodInvokeExpression cmie in list)
{
object enumValue = DeserializeExpression(manager, null, cmie.Parameters[0]);
string listenerName = getListenerName(manager, cmie.Parameters[1]);
CustomEventDescriptor ced =
findEventDescriptor(eventDescriptors, "ButtonClick_", enumValue);
PropertyDescriptor eventProperty = service.GetEventProperty(ced);
eventProperty.SetValue(instance, listenerName);
}
}
The serializer code with its many ForEach
loops would definitely benefit from Linq, alas I had to target NET 2.0. I credit myself for the CodeDom visualizer, which helped in serializer development.
The Limitation
When we attach an ordinary event in the WinForms designer, the property grid presents us a list of existing compatible methods, we may choose from or create a new one by double click. For our dynamic events, we always generate an identical listener name per event, regardless of what whatever the user entered. So the concept of compatible methods is exchanged for one of identical method. If a method with our expected name already exists, we attach it otherwise we create it. No different method name allowed, period. The user is however free to rename listeners from the code editor.
This behaviour is exactly what I want, avoiding falsities arising from similar listener names plus the events should not merge across different controls. As a consequence, I use a custom TypeConverter
, that returns only a existing listener instead of compatible methods as standard values.The converter is exchanged via Reflection by setting a private
field on the respective EventPropertyDescriptor
.
If you want compatible methods, you must come up with a way to read the entered value directly from the property grid.
Name conflicts are unlikely with the identical method concept and I omitted name validation completely. You may want to study Microsoft's implementation in
Microsoft.VisualStudio.Shell.Design.Serialization.CodeDom.<br />CodeDomEventBindingService
. The required
CodeTypeDeclaration
is available as a service in the designer:
CodeTypeDeclaration declaration =
(CodeTypeDeclaration) GetService(typeof (CodeTypeDeclaration));
A Little Lesson in Visual Studio Assembly Hell
The following method is an excerpt from DesignEventSerializer
and finds the corresponding descriptor for a given enum
value. As long as the descriptor is contained in the passed collection, we should always find it, correct ?
private static CustomEventDescriptor findEventDescriptor
(EventDescriptorCollection eventDescriptors, string eventName, object enumValue)
{
foreach (EventDescriptor ed in eventDescriptors)
{
if (ed.Name != eventName)
{
continue;
}
EnumMemberAttribute attr =
(EnumMemberAttribute)ed.Attributes[typeof(EnumMemberAttribute)];
if (attr == null)
{
continue;
}
if (enumValue.Equals(attr.EnumValue))
{
return ed as CustomEventDescriptor;
}
}
Debug.Fail("We should always find the EventDescriptor!");
return null;
}
True, if the enum
type belongs to .NET Framework and/or is GAC'ed. Plain wrong, if the enum
type is defined in the project itself and the project is recompiled. The reason being, that VS may create and load a new shadow copy of the project assembly when recompiling. When we compare the enum
type, it is pretty likely that we end up comparing types, loaded from a different Assembly.Location
. For us, it may seem that they are identical types having identical assembly qualified names, but NET framework is stubborn: No equality, never ever! That's why I additionally compare by hashcode here and you should generally pay attention to assembly hell, when adopting my solution.
if (enumValue.Equals(attr.EnumValue))
{
return ed as CustomEventDescriptor;
}
if (enumValue.GetHashCode() == attr.EnumValue.GetHashCode())
{
if (enumValue.GetType().AssemblyQualifiedName ==
attr.EnumValue.GetType().AssemblyQualifiedName)
{
Debug.Assert(enumValue.GetType().Assembly.Location
!= attr.EnumValue.GetType().Assembly.Location);
return ed as CustomEventDescriptor;
}
}
Using the Code
To adopt my solution, change the attribute properties to whatever is used by your control to identify the dynamic events. Hunt down the usage of my original properties and change as needed.
Rename the dummy event in the designer, I propose appending an underscore to the common event name defined on the control. I have used a static
class sharing string
fields for names of dummy event and 'AddEventHandler
' method, simplifying changes.
TypeAlias
, CustomEventDescriptor
and 90% of DesignEventSerializer
are reusable.
My presented solution accounts only for one dynamic 'event family' per control, if you require more, improving my code should not prove difficult.
To debug the demo solution at design time, use the control library 'DynamicEvents
' as Startup project. On its project Properties page, point at your devenv.exe installation and specify the path to the 'Test'DynamicEvents' project as command line argument.
History
- 19th January, 2010: Initial post