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); } } |