Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Exposing Dynamic Events in the WinForms Designer

0.00/5 (No votes)
20 Jan 2011 1  
A solution to declaring dynamic events on control arrays at design time
Sample Image

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)
{
    // all sanity omitted
    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.

  • EnumMemberAttribute

    This attribute is specific for your control and must identify each generated event by its properties. As the demo control uses Enum names and values to identify the contained buttons, EnumMemberAttribute exposes appropriate properties.

  • [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);
        }
    
        /// Gets the type of component this event is bound to.
        public override Type ComponentType
        {
            get { return componentType; }
        }
    
        /// Gets the type of delegate for the event. 
        /// Returned type is a TypeAlias delegator !
        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)
        {
            // e.Member is either interceptedEventDescriptor or 
            // its associated EventPropertyDescriptor
            return;
        }
    
        if (reEntrantCodeCancel)
        {
            // e.Member is never interceptedEventDescriptor or 
            // its associated EventPropertyDescriptor
            // Cancel triggered Undo operation for a previous intercepted 
            // EventPropertyDescriptor or other descriptor
            throw CheckoutException.Canceled;
        }
    
        CustomEventDescriptor ced = e.Member as CustomEventDescriptor;
        if (ced == null)
        {
            return;
        }
    
        //-- EventPropertyDescriptor is setting value
        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)
        {
            // -- create new listener or attach existing having identical name
            string methodName = svc.CreateUniqueMethodName
    		(Component, interceptedEventDescriptor);
            methodName += enumMemberAttribute.EnumName;
    
            eventProperty.SetValue(Component, methodName);
        }
        else
        {
            // -- always reset existing listener
            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.

    // listener for the button, identified by System.Windows.Forms.AnchorStyles.Left:
    
    // in Form1.cs
    private void buttonContainer1_ButtonClick_Left(object sender, EventArgs e)
    {
    }
    
    // in Form1.Designer.cs
    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)];
    
        // {System.ComponentModel.Design.Serialization.EnumCodeDomSerializer}
        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)
    {
        // Designer has added CustomEventDescriptors
        EventDescriptorCollection eventDescriptors =
        TypeDescriptor.GetEvents(instance, new Attribute[] 
        { new DesignOnlyAttribute(true) });
    
        IEventBindingService service =
        (IEventBindingService)manager.GetService(typeof(IEventBindingService));
    
        foreach (CodeMethodInvokeExpression cmie in list)
        {
            // void AddEventHandler(object enumValue /*0*/, Delegate listener /*1*/)
            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)
    {
        // Equality failed due to VS Assembly hell !!!
        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

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here