1. 引言

在前几次任务里我们开发一个星级控件并逐渐为其增加一新的特性,在本次任务里,我们将开发一个较复杂的自定义控件,该自定义控件需要实现折叠面板的功能。用户可以向面板控件中自由添加控件,该控件呈现出来后会根据用户设置决定是否显示折叠按钮,如果允许则用户可以点击按钮展开/折叠按钮以显示或隐藏面板,并且可以在服务器端捕捉到展开/折叠事件以进行更多的控制,该控件运行效果图如下:


2. 分析

我们在确定该控件最终能够使用HTML呈现出来之后,接下来要考虑的是为该自定义控件选择一个合适的基类。从要实现的功能来看,该控件分为两部分,一部分是包含展开/折叠按钮的标题行,另一部分是包含用户放置按钮的容器,很显然这个容器我们可以使用Panel类,为了保留用户直接定义Panel中子标记的特性,使自定义控件直接继承自Panel类,根据设置决定是否显示标题行,并且根据面板状态显示恰当的展开或折叠图标。

由于Panel可以看作一个容器控件,那么当页面上使用多个该控件时会产生什么样的结果,容器中的子控件还能保证是唯一的吗?可以尝试编写一个继承自Panel类的自定义控件CustomerControl并向其控件集合(Controls属性)中添加一个ID为txt的文本框,当在页面上放置两个这样的自定义控件时会生成了两个文本框(毫无疑问,因为是两个自定义控件),但是它们的id属性均为txt,类似于以下代码:

<span id="cc1"><input name="txt" type="text" id="txt" /></span>
<span id="cc2"><input name="txt" type="text" id="txt" /></span>

为了避免这种情况发生,只需要使CustomerControl类实现INamingContainer接口,再次预览页面,将会发现生成的文本框已经具有唯一的id了:

<span id="cc1"><input name="cc1$txt" type="text" id="cc1_txt" /></span>
<span id="cc2"><input name="cc2$txt" type="text" id="cc2_txt" /></span>

因此,我们的自定义控件也需要实现INamingContainer接口,以确保所有子控件ID属性唯一。正如读者所看到的,只需要标记实现INamingContainer接口而不需要编写任何额外的方法,这也就是所谓的“标记接口”,与此类似的还有ISerializable等。

为了使折叠控件更灵活,允许开发人员设置是否可以展开/折叠控件,同时编写属性使得用户可以不使用服务器端提交方式,仅在客户端执行展开/折叠操作。还需要即使在用户禁止视图状态的情况下仍然能够记住控件的展开/折叠状态,因此需要使用控件状态保存状态设置,最后提供一些额外的属性帮助设置标题的样式。

由此得出,该自定义控件应具有以下属性:

属性描述
EnableDropDown 是否允许展开/折叠
EnableClientScript 是否允许使用客户端脚本执行展开/折叠动作
ShowExpanded 初始状态是否被展开
Cpation 标题
CaptionBackColor 标题背景色
CaptionForeColor 标题前景色

为了能够当用户展开或折叠时引发服务器端事件,可以简单的使用服务器端图片按钮控件显示展开/折叠图标,用户在处理该服务器端事件时可能需要知晓当前面板的状态(展开或折叠,可以简单的使用布尔型变量标识),需要通常事件参数的某个属性来标识,但是现有的事件参数类不适合完成此项功能,所以我们同样要编写自己的事件类,并且定义面板状态属性。

.NET中的事件基于委托,在ASP.NET中可以使用EventHandler委托(与此相关还定义了泛型委托)定义事件,事件参数包含了与事件有关的数据。有关委托和事件及泛型的知识请参阅相关书籍。

最后需要考虑的是如果使用提交引发服务器端事件,如何将该事件暴露给开发人员处理。实际上对于这种需求有多种实现方式:

  • 实现IPostBackEventHandler接口以处理回发事件。
  • 将子控件事件作为顶层事件公开。
  • 使用冒泡法将事件沿包含层次向上传播到合适的位置引发。

在本次任务中我们将使用第二种方式,另外两种方式将在以后的任务中介绍。为了将子控件事件作为顶层事件公开,需要经过以下几个步骤:

  1. 为自定义控件定义事件
  2. 为了确保事件在引发时已经被订阅编写辅助方法检查事件是否为空(null)
  3. 编写子控件事件处理程序,根据需要生成事件参数引发事件(调用第2步中辅助方法)并执行其他的操作。

以上是对面板控件的分析,接下来我们将按照分析的结果实现该控件。

3. 实现

3.1 在解决方案ControlLibrary类库中添加ExtendPanel类,并根据分析定义相关属性:

public class ExtendPanel : Panel, INamingContainer
{
[Themeable(false)]
public bool EnableDropDown
{
get
{
object o = ViewState["EnableDropDown"];
if (o == null)
return false;
return (bool)o;
}
set { ViewState["EnableDropDown"] = value; }
}

// 是否使用客户端脚本
[Themeable(false)]
public bool EnableClientScript
{
get
{
object o = ViewState["EnableClientScript"];
if (o == null)
return false;
return (bool)o;
}
set { ViewState["EnableClientScript"] = value; }
}

// 是否被展开
[Themeable(false)]
public bool ShowExpanded
{
get
{
object o = ViewState["ShowExpanded"];
if (o == null)
return true;
return (bool)o;
}
set
{
ViewState["ShowExpanded"] = value;
}
}

//标题
[Themeable(false)]
public string Caption
{
get
{
object o = ViewState["Caption"];
if (o == null)
return "Panel";
return (string)o;
}
set { ViewState["Caption"] = value; }
}

//标题背景色
public Color CaptionBackColor
{
get
{
object o = ViewState["CaptionBackColor"];
if (o == null)
return Color.SkyBlue;
return (Color)o;
}
set { ViewState["CaptionBackColor"] = value; }
}

//标题前景色
public Color CaptionForeColor
{
get
{
object o = ViewState["CaptionForeColor"];
if (o == null)
return Color.White;
return (Color)o;
}
set { ViewState["CaptionForeColor"] = value; }
}
}

对于EnableDropdown等属性在主题中定义不会影响到控件的呈现样式,所以这些属性使用Themable特性进行定义,该特性指定属性不受到主题和控件外观的影响。

3.2 接下来定义布尔型私有变量用于标识当前面板是展开状态还是折叠状态,为了避免视图状态的影响,将该属性值存储在控件状态中:

private bool _panelDisplayed;

protected override void OnInit(EventArgs e)
{
base.OnInit(e);
Page.RegisterRequiresControlState(this);//注册控件状态
}

protected override object SaveControlState()
{
Pair p = new Pair();
p.First = base.SaveControlState();

p.Second = _panelDisplayed;

return p;
}

protected override void LoadControlState(object savedState)
{
if (savedState == null)
return;

Pair p = (Pair)savedState;

base.LoadControlState(p.First);

_panelDisplayed = (bool)p.Second;
}

3.3 重写CreateChildControls方法,根据允许下拉(EnableDropDown)属性设置决定是否显示标题栏:

protected override void CreateChildControls()
{
if (EnableDropDown)//如果允许下拉则创建标题条
{
base.CreateChildControls();
CreateControlHierarchy();
}
else
{
base.CreateChildControls();//如果不允许下拉,则显示原有控件
}
}

3.4 在CreateChildControls方法中调用了CreateControlHierarchy方法用于创建子控件层次,该方法首先执行了两部分操作,创建表格并添加第一行显示标题和操作图标;将原Panel容器中的控件加入到表格的第二行中,同时将表格添加到控件集合中并清空原有控件:

protected virtual void CreateControlHierarchy()
{
Table t = new Table();

TableRow row1 = new TableRow();
t.Rows.Add(row1);

TableCell cell1 = new TableCell();
row1.Cells.Add(cell1);
cell1.Text = "&nbsp;" + Caption;

TableCell cell2 = new TableCell();
row1.Cells.Add(cell2);
cell2.HorizontalAlign = HorizontalAlign.Right;

TableRow row2 = new TableRow();
t.Rows.Add(row2);
TableCell body = new TableCell();
body.ID = "Body";
row2.Cells.Add(body);
body.ColumnSpan = 2;

Control[] rg = new Control[Controls.Count];
Controls.CopyTo(rg, 0);

foreach (Control ctl in rg)
body.Controls.Add(ctl);

Controls.Clear();
Controls.Add(t);

由于服务器端控件均是引用类型,所以不能直接将控件添加到某个控件集合中,而必须调用Controls.CopyTo将原有控件复制到目标控件数组中,否则在调用Controls.Clear方法时仍然会将控件移除而导致错误的运行结果。

3.5 接下来在该方法中判断是否使用客户端展开/折叠动作(EnableClientScript)属性,如果该属性为false,即表明要执行服务器提交动作,因此向标题行单元格中添加服务器端图片按钮,并处理该控件点击事件(稍后会实现该事件处理程序):

 WebControl img;
if (!EnableClientScript)
{
img = new ImageButton();
((ImageButton)img).Click += new ImageClickEventHandler(OnClick);
cell2.Controls.Add(img);
}

3.6 如果在客户端实现展开/折叠动作则则向页面注册客户端脚本并附加到图标的点击事件上:

else
{
img = new System.Web.UI.WebControls.Image();
img.ID = "Icon";
cell2.Controls.Add(img);

// 添加样式
img.Attributes["onmouseover"] = "this.style.cursor = \"hand\";";
img.Attributes["onmouseout"] = "this.style.cursor = \"\";";
img.Attributes["onclick"] = "__toggle()";
if (!Page.ClientScript.IsClientScriptBlockRegistered("__toggle"))
{
string js = BuildScript(body);
Page.ClientScript.RegisterClientScriptBlock(this.GetType(), "__toggle", js, true);
}
}

在以上代码段中调用了BuildScript方法用于生成JavaScript脚本,以下是该方法实现(当然可以使用资源文件实现):

private string BuildScript(TableCell body)
{
StringBuilder sb = new StringBuilder();
sb.AppendLine("function __toggle() {");
sb.AppendFormat(" var body = document.getElementById(\"{0}\");\r\n", body.ClientID);
sb.AppendLine(" var display = body.style.display;");
sb.AppendLine(" if (display == \"\") {");
sb.AppendLine(" body.style.display = \"none\";");
sb.AppendLine(" } else {");
sb.AppendLine(" body.style.display = \"\";");
sb.AppendLine(" }");
sb.AppendLine("}");

return sb.ToString();
}

3.7 在CreateControlHierarchy方法的最后根据当前面板状态显示或隐藏面板内容并显示相应的按钮图片,调用了两个方法:

 ShowChildControls(_panelDisplayed);
ShowImageButton(_panelDisplayed);
}

3.8 实现ShowChildControls方法以显示或隐藏面板内容:

private void ShowChildControls(bool display)
{
if (Controls.Count != 1)
return;

Table t = (Table)Controls[0];
TableRow r = t.Rows[1];
TableCell body = r.Cells[0];
body.Style["display"] = (display ? "" : "none");
}

3.9 在ControlLibrary类库中添加Image目录,将collapse.bmp和expand.bmp图片放置到该目录中,设置生成动作为嵌入并在AssemblyInfo.cs中添加资源文件的注册,实现ShowImageButton根据面板状态显示恰当的图标:

private void ShowImageButton(bool display)
{
if (Controls.Count != 1)
return;

Table t = (Table)Controls[0];
TableRow r = t.Rows[0];
TableCell icon = r.Cells[1];

//设置相应图片
System.Web.UI.WebControls.Image img =
(System.Web.UI.WebControls.Image)icon.Controls[0];

string imageName = "ControlLibrary.Image.expand.bmp";
if (display && !EnableClientScript)
imageName = "ControlLibrary.Image.collapse.bmp";
img.ImageUrl = Page.ClientScript.GetWebResourceUrl(this.GetType(),
imageName);
}

3.10 接下来进行服务器端点击事件的处理,首先在ExtendPanel类中使用泛型委托声明事件,该泛型委托意味着使用PanelClickEventArgs作为事件参数类型,并且该类必须继承自EventArgs类:

public event EventHandler<PanelClickEventArgs> PanelClick;

定义PanelClientEventArgs事件参数类并添加BeingClosed属性标识面板状态:

public class PanelClickEventArgs : EventArgs
{
public bool BeingClosed
{
get;
set;
}
}

3.11 根据自控件事件的分析,在ExtendPanel类中定义辅助方法检查事件是否已被订阅:

protected virtual void OnPanelClick(PanelClickEventArgs args)
{
if (PanelClick != null)
PanelClick(this, args);
}

3.12 触发服务器端图片按钮点击事件时,在事件处理方法OnClick中生成事件参数类后调用OnPanelClick方法,并且更新面板和图标显示,如此订阅的事件处理程序就可以参与到图片按钮点击过程中:

private void OnClick(object sender, ImageClickEventArgs e)
{
PanelClickEventArgs args = new PanelClickEventArgs();
args.BeingClosed = _panelDisplayed;
OnPanelClick(args);






//更新显示状态
_panelDisplayed = !_panelDisplayed;

ShowChildControls(_panelDisplayed);
ShowImageButton(_panelDisplayed);
}

3.13 最后重写Render方法呈现控件,该方法调用PreparentControlForRendering方法将控件的样式应用的创建的表格上并根据属性设置标题的前景色和背景色:

protected override void Render(HtmlTextWriter writer)
{
PrepareControlForRendering();
base.Render(writer);
}

protected virtual void PrepareControlForRendering()
{
if (Controls.Count != 1)
return;

// 应用样式
Table t = (Table)Controls[0];
t.CopyBaseAttributes(this);
if (ControlStyleCreated)
t.ApplyStyle(ControlStyle);

t.CellPadding = 1;
t.CellSpacing = 0;

//设置标题样式
TableRow row1 = t.Rows[0];
row1.BackColor = CaptionBackColor;
row1.ForeColor = CaptionForeColor;
}

3.14 在解决方案的Web网站中创建测试页,声明并定义自定义面板控件,测试运行结果。

4. 总结

在本次任务里我们创建了一个可以折叠/展开的自定义面板控件,应用EventHandler泛型委托和自定义事件类将图片的点击事件公开为面板的顶层事件,这样使用者就可以订阅该事件进行自定义处理。该面板控件两个比较重要的属性是EnableDropDown和EnableClientScript属性,根据前者决定是否需要显示附加的标题栏,后者决定是生成JavaScript脚本以在客户端进行展开/折叠操作,还是使用图片按钮处理服务器点击事件。如果点击事件提交到服务器端处理,则使用自定义事件类保存面板状态并由图片按钮的点击事件引发面板顶层事件,使用户能够自定义点击事件的处理。

在下次任务里,我们将介绍事件处理的另外一种方式——使用IPostEventHandler接口,在原有星级控件的基础上增加评分的功能,允许用户自由选择评分并引发服务器端事件,以能够得到用户选择的分数。


ASP.NET自定义控件系列文章

前言

第一天 简单的星级控件 

第二天 带有自定义样式的星级控件

第三天 使用控件状态的星级控件

第四天 折叠面板自定义控件

第五天 可以评分的星级控件

第六天 可以绑定数据源的星级控件

第七天 开发具有丰富特性的列表控件

第八天 显示多个条目星级评分的数据绑定控件

第九天 自定义GridView

第十天 实现分页功能的DataList


全部源码下载

本系列文章PDF版本下载