Step by step guide for developing custom server control with custom collection
This guide is step by step instruction to create custom web control. The describe control contains complex property (Collection of classes) that the user can edit using custom type editor. Collection data will be preserving by control as control sub Html tags. This guide also contains most common issues relating to development such a control. The control that we about to develop name is commands and it part of NWAF framework that I'm working on. This control task is to let users to set commands to server control events laid down on web page. Commands has a property holding collection of classes that containing control name, event name and command name set to specify event. The user can add command to control events by using custom editor. Our custom editor show controls on page in hierarchal view. Choosing a control show it server side events and while selecting given event the user can set command name for that event. Controls event commands persist as inner HTML under commands control tags. The HTML output of our control should look like:
<cc1:Commands id="Commands2" style="Z-INDEX: 105; LEFT: 24px; POSITION: absolute; TOP: 107px" runat="server" Height="2px" Width="15px"> <cc1:InnerCommand EventName="Click" ControlID="Button1" CommandName="a"></cc1:InnerCommand> <cc1:InnerCommand EventName="Command" ControlID="Button1" CommandName="b"></cc1:InnerCommand> <cc1:InnerCommand EventName="SelectedIndexChanged" ControlID="DropDownList1" CommandName="natty"></cc1:InnerCommand> <cc1:InnerCommand EventName="TextChanged" ControlID="TextBox1" CommandName="roni"></cc1:InnerCommand> </cc1:Commands> |
The overall class design of that controls is:
InnerCommand – Represent single user set of command name to control event. holds three members ControlName, EventName and Command name.
InnerCommands – Represent collection of user commands set of page controls. Inherit from Collection base and supply collection of command settings.
Commands – Custom control class.
Editor – Inherits from UITypeEditor. Override EditValue to load custom editor from.
VisualEditor – Custom editor form.
We will start from data aspect of the control. InnerCommand represent single entry of collection data. InnerCommand is regular classes exposing three public properties. for every property that we want to be part in control persistence we need to add NotifyParentProperty attribute setting to true. NotifyParentProperty bubble notification message to WebControl that use properties mark with NotifyParentProperty attribute in persistence process. Using the describe notification WebControl is marked as dirty in the designer. ASP.NET uses this data to serialize complex properties data as HTML elements.
public class InnerCommand { private string m_ControlID; private string m_EventName; private string m_CommandName; public InnerCommand() {} public InnerCommand(string ControlID,string EventName,string CommandName) { m_ControlID = ControlID; m_EventName = EventName; m_CommandName = CommandName; } [NotifyParentProperty(true)] public string ControlID { get { return m_ControlID; } set { m_ControlID = value; } } [NotifyParentProperty(true)] public string EventName { get { return m_EventName; } set { m_EventName = value; } } [NotifyParentProperty(true)] public string CommandName { get { return m_CommandName; } set { m_CommandName = value; } } } |
InnerCommands Inherits from CollectionBase and holds the InnerCommand objects already defined by user. CollectionBase supplies basic functionality for collections implementation which let ASP.NET treat InnerCommands as collection. Pay attention to the implementation of class indexer and IndexOf method. This implementation prevents the annoying "Ambiguous match found " message as describe in KB-823194. I choose control name and event name as unique string key for each collection item.
public class InnerCommands : CollectionBase { public CommandControl.InnerCommand this[object index] { get { return (InnerCommand) this.List[IndexOf(index)]; } set { this.List[IndexOf(index)] = value; } } public void Add(CommandControl.InnerCommand Tab) { this.List.Add(Tab); } public void Insert(int index, CommandControl.InnerCommand item) { this.List.Insert(index,item); } public void Remove(CommandControl.InnerCommand Tab) { List.Remove(Tab); } public bool Contains(CommandControl.InnerCommand Tab) { return this.List.Contains(Tab); } //Collection IndexOf method public int IndexOf(object obj) { if (obj is int) return (int)obj; if (obj is string) { for (int i = 0; i < List.Count; i++) if (((InnerCommand)List[i]).ControlID + "_" + ((InnerCommand)List[i]).EventName == obj.ToString()) return i; return -1; } else { throw new ArgumentException("Only a string or an integer is permitted for the indexer."); } } public void CopyTo(CommandControl.InnerCommand[] array, int index) { List.CopyTo(array, index); } public bool Contains(string key) { bool RV = false; IEnumerator oEnum = this.GetEnumerator(); while(oEnum.MoveNext()) { string inkey = ((InnerCommand)oEnum.Current).ControlID + "_" + ((InnerCommand)oEnum.Current).EventName; if (string.Compare(key,inkey,true) == 0) return true; } return RV; } public void Remove(string key) { IEnumerator oEnum = this.GetEnumerator(); while(oEnum.MoveNext()) { string inkey = ((InnerCommand)oEnum.Current).ControlID + "_" + ((InnerCommand)oEnum.Current).EventName; if (string.Compare(key,inkey,true) == 0) this.Remove((InnerCommand)oEnum.Current); } } } |
Editor Class : To show custom form as collection editor you need to create your class derived from UITypeEditor. Your class should override EditValue and UITypeEditorEditStyle. UITypeEditorEditStyle set the display mode or custom editor to modal dialog.
EditValue responsible to show custom collection editor, get the collection created by the user back and notify the control so the collection elements will be preserve as sub HTML elements of the control. Use provider GetService method to get IWindowsFormsEditorService. Use IWindowsFormsEditorService ShowDialog to show your custom form and to get back collection. In this sample I use the page constructor to set form internal data, but you can use methods to receive data from Editor Class.
Two things to notice here before you look at the code.
1) It might sound stupid but ensure that your page DialogResult property set to DialogResult.OK. Otherwise you won't see any HTML that persist your data. (Seen it happened!).
2) You have to use context.OnComponentChanged(); otherwise your control wont know that internal data changed and won't preserve collection data.
public class Editor : System.Drawing.Design.UITypeEditor { public Editor() : base() { } public override object EditValue(System.ComponentModel.ITypeDescriptorContext context, IServiceProvider provider, object value) { if(context != null && context.Instance != null && provider != null) { System.Windows.Forms.Design.IWindowsFormsEditorService edSvc = (System.Windows.Forms.Design.IWindowsFormsEditorService) provider.GetService(typeof(System.Windows.Forms.Design.IWindowsFormsEditorService)); if (edSvc != null) { System.Web.UI.Control oControl = (System.Web.UI.Control)context.Instance; VisualEditor form = new VisualEditor(oControl,(CommandControl.InnerCommands)value); System.Windows.Forms.DialogResult result = edSvc.ShowDialog(form); context.OnComponentChanging(); if (result == System.Windows.Forms.DialogResult.OK) { value = form.Comands; context.OnComponentChanged(); } } return value; } return base.EditValue (context, provider, value); } public override System.Drawing.Design.UITypeEditorEditStyle GetEditStyle(System.ComponentModel.ITypeDescriptorContext context) { if(context != null && context.Instance != null) { return System.Drawing.Design.UITypeEditorEditStyle.Modal; } return base.GetEditStyle (context); } } |
The form code is quiet strait forward. The page gets the current control command collection and the control instance. Using the current control instance I can go recursion through page controls to show all controls on the host page. Current control command collection is used to show command names already set to controls events.
public class VisualEditor : System.Windows.Forms.Form { private System.ComponentModel.Container components = null; private System.Windows.Forms.TreeView treeView1; private System.Windows.Forms.ListBox listBox1; private System.Windows.Forms.TextBox txtCommand; private System.Windows.Forms.Label label1; private System.Windows.Forms.Label label2; private System.Windows.Forms.Label label3; private System.Windows.Forms.Button btnSave; private System.Web.UI.Control m_Control; private CommandControl.InnerCommands m_commands = new CommandControl.InnerCommands(); public CommandControl.InnerCommands Comands { get { return m_commands; } } public VisualEditor(System.Web.UI.Control oControl, CommandControl.InnerCommands commands) { if (commands == null) { m_commands = new CommandControl.InnerCommands(); } else { m_commands = commands; } m_Control = oControl; InitializeComponent(); } public VisualEditor() { InitializeComponent(); } protected override void Dispose( bool disposing ) { if( disposing ) { if(components != null) { components.Dispose(); } } base.Dispose( disposing ); } #region Windows Form Designer generated code private void InitializeComponent() { this.treeView1 = new System.Windows.Forms.TreeView(); this.listBox1 = new System.Windows.Forms.ListBox(); this.txtCommand = new System.Windows.Forms.TextBox(); this.label1 = new System.Windows.Forms.Label(); this.label2 = new System.Windows.Forms.Label(); this.label3 = new System.Windows.Forms.Label(); this.btnSave = new System.Windows.Forms.Button(); this.SuspendLayout(); this.treeView1.ImageIndex = -1; this.treeView1.Location = new System.Drawing.Point(8, 40); this.treeView1.Name = "treeView1"; this.treeView1.SelectedImageIndex = -1; this.treeView1.Size = new System.Drawing.Size(400, 240); this.treeView1.TabIndex = 0; this.treeView1.AfterSelect += new System.Windows.Forms.TreeViewEventHandler(this.treeView1_AfterSelect); this.listBox1.Location = new System.Drawing.Point(8, 320); this.listBox1.Name = "listBox1"; this.listBox1.Size = new System.Drawing.Size(400, 108); this.listBox1.TabIndex = 1; this.listBox1.SelectedValueChanged += new System.EventHandler(this.listBox1_SelectedValueChanged); this.txtCommand.Location = new System.Drawing.Point(8, 464); this.txtCommand.Name = "txtCommand"; this.txtCommand.Size = new System.Drawing.Size(400, 20); this.txtCommand.TabIndex = 2; this.txtCommand.Text = ""; this.txtCommand.Leave += new System.EventHandler(this.txtCommand_Leave); this.label1.Location = new System.Drawing.Point(8, 8); this.label1.Name = "label1"; this.label1.Size = new System.Drawing.Size(168, 24); this.label1.TabIndex = 3; this.label1.Text = "Controls:"; this.label2.Location = new System.Drawing.Point(8, 296); this.label2.Name = "label2"; this.label2.Size = new System.Drawing.Size(168, 24); this.label2.TabIndex = 4; this.label2.Text = "Events:"; this.label3.Location = new System.Drawing.Point(8, 440); this.label3.Name = "label3"; this.label3.Size = new System.Drawing.Size(168, 24); this.label3.TabIndex = 5; this.label3.Text = "Command:"; this.btnSave.Location = new System.Drawing.Point(112, 496); this.btnSave.Name = "btnSave"; this.btnSave.Size = new System.Drawing.Size(200, 24); this.btnSave.TabIndex = 6; this.btnSave.Text = "Save"; this.btnSave.Click += new System.EventHandler(this.btnSave_Click); this.AutoScaleBaseSize = new System.Drawing.Size(5, 13); this.ClientSize = new System.Drawing.Size(416, 534); this.Controls.Add(this.btnSave); this.Controls.Add(this.label3); this.Controls.Add(this.label2); this.Controls.Add(this.label1); this.Controls.Add(this.txtCommand); this.Controls.Add(this.listBox1); this.Controls.Add(this.treeView1); this.Name = "VisualEditor"; this.Text = "VisualEditor"; this.Load += new System.EventHandler(this.VisualEditor_Load); this.ResumeLayout(false); } #endregion private void VisualEditor_Load(object sender, System.EventArgs e) { System.Windows.Forms.TreeNode oNode = new System.Windows.Forms.TreeNode("Page Controls"); this.treeView1.Nodes.Add(oNode); loadTree(oNode,m_Control.Parent); } private void loadTree(System.Windows.Forms.TreeNode oNode, System.Web.UI.Control control) { //System.Windows.Forms.MessageBox.Show(control.ClientID); foreach (System.Web.UI.Control o in control.Controls) { if ( o.ID != "") { System.Windows.Forms.TreeNode oNod = new System.Windows.Forms.TreeNode(o.ClientID); oNode.Nodes.Add(oNod); loadTree(oNod,o); } } } private void treeView1_AfterSelect(object sender, System.Windows.Forms.TreeViewEventArgs e) { this.listBox1.Items.Clear(); System.Web.UI.Control oControl = m_Control.Parent.FindControl(e.Node.Text); if (oControl != null) { System.Type oType = oControl.GetType(); System.Reflection.EventInfo[] oEI = oType.GetEvents(); for(int i = 0; i < oEI.Length ; i++) { this.listBox1.Items.Add( oEI[i].Name); } } } private void btnSave_Click(object sender, System.EventArgs e) { this.DialogResult = DialogResult.OK; this.Close(); } private void txtCommand_Leave(object sender, System.EventArgs e) { if (((TextBox)sender).Text != "" ) { if( m_commands.Contains(this.treeView1.SelectedNode.Text + "_" + listBox1.SelectedItem)) { m_commands[this.treeView1.SelectedNode.Text + "_" + listBox1.SelectedItem].CommandName = ((TextBox)sender).Text; } else { CommandControl.InnerCommand oCommand = new CommandControl.InnerCommand(this.treeView1.SelectedNode.Text,(string)listBox1.SelectedItem,((TextBox)sender).Text); m_commands.Add(oCommand); } } } private void listBox1_SelectedValueChanged(object sender, System.EventArgs e) { if (this.treeView1.SelectedNode != null && ((ListBox)sender).SelectedItem != null) { if( m_commands.Contains(this.treeView1.SelectedNode.Text + "_" + (string)((ListBox)sender).SelectedItem)) { txtCommand.Text = m_commands[this.treeView1.SelectedNode.Text + "_" + (string)((ListBox)sender).SelectedItem].CommandName ; } else { txtCommand.Text = ""; } } else { txtCommand.Text = ""; } } } |
What we left with is the control itself. The control inherits from WebControl and implements collection property CommandsList. CommandsList (read only) just return instance of InnerCommnds holding Control collection internal data. Beside CommandsList this control implement Render method to render Collection Data as XML data inside hidden field.
Preserving is done here just by using attributes. The control class should be decorated with ParseChildren and PersistChildren attributes if your control class inherits from Control. Books and documentation suggest that if your class inherits from WebControl those attributes already applied to webControl and you don’t need to use them. I strongly recommend declaring those attributes since I saw cases where persistence doesn't work or errors raised if those attributes are missing. PersistChildren should be set to false so the control persist content as property and not as inner controls. ParseChildren(true) tells page parser to parse data as property within control tags.
Collection property should be decorated with the following attributes:
DesignerSerializationVisibility: this attribute should be set to DesignerSerializationVisibility.Content. DesignerSerializationVisibility.Content tells the designer that their sub-properties should be serialized.
PersistenceMode: PersistenceMode imply to the parser to persist property as inner property. to persist an inner default property correctly PersistenceMode must be set to PersistenceMode.InnerDefaultProperty. if you won't use PersistenceMode.InnerDefaultProperty page parser might add tags with property name encapsulating collection item tags:
<CommandsList> <cc1:InnerCommand EventName="SelectedIndexChanged" ControlID="DropDownList1" CommandName="sdfsd"></cc1:InnerCommand> <CommandsList/> |
And you'll get the following parser error: "Parser error: 'collectionItems' must have items of type 'CollectionItem'. 'PropertName' is of type System.Web.UI.HtmlControls.HtmlGenericControl"
Editor: editor attribute tells property window that the decorate property got its own implementation of editing property value. Editor constructor takes two parameters. The first is the type of the custom editor. The second is the type that your editor derived from.
[ToolboxData("<{0}:Commands runat='server'></{0}:Commands>") ,ParseChildren(true,"CommandsList"), PersistChildren(false) ] public class Commands : System.Web.UI.WebControls.WebControl { private InnerCommands m_InnerCommands; public Commands() { } [Editor (typeof(CommandsEditor.Editor),typeof(System.Drawing.Design.UITypeEditor))] [PersistenceMode(PersistenceMode.InnerDefaultProperty), DesignerSerializationVisibility(DesignerSerializationVisibility.Content)] public InnerCommands CommandsList { get { if (m_InnerCommands == null) { m_InnerCommands = new InnerCommands(); } return m_InnerCommands; } } protected override void Render(HtmlTextWriter writer) { System.Text.StringBuilder oSB = new System.Text.StringBuilder(); // translate CommandControl.InnerCommands to string oSB.Append("<commands>"); foreach( CommandControl.InnerCommand oCmd in m_InnerCommands) { oSB.Append("<control "); oSB.Append(" name=\""); oSB.Append(oCmd.CommandName); oSB.Append("\">"); oSB.Append("<event "); oSB.Append(" name=\""); oSB.Append(oCmd.EventName); oSB.Append("\""); oSB.Append(" command=\""); oSB.Append(oCmd.CommandName); oSB.Append("\""); oSB.Append(" />"); oSB.Append("</control>"); } oSB.Append("</commands>"); string strCommand = ""; if (HttpContext.Current != null) { strCommand = HttpContext.Current.Server.HtmlEncode( oSB.ToString()); } else { strCommand = oSB.ToString(); } strCommand = "<input type=\"hidden\" name=\"__NWAFCOMMANDS\" value=\"" + strCommand + "\"/>"; if (this.Site.DesignMode) { writer.Write("Select Commands property </br> to select commands </br> for controls events."); } else { writer.Write(strCommand); } base.Render (writer); } } |