[WPF自定义控件库]简单的表单布局控件
1. wpf布局一个表单
<grid width="400" horizontalalignment="center" verticalalignment="center"> <grid.rowdefinitions> <rowdefinition height="auto" /> <rowdefinition height="auto" /> <rowdefinition height="auto" /> </grid.rowdefinitions> <grid.columndefinitions> <columndefinition width="auto" /> <columndefinition width="*" /> </grid.columndefinitions> <textblock text="用户名" horizontalalignment="right" verticalalignment="center" margin="4" /> <textbox grid.column="1" margin="4" /> <textblock text="密码" horizontalalignment="right" verticalalignment="center" margin="4" grid.row="1" /> <passwordbox grid.row="1" grid.column="1" margin="4" /> <textblock grid.row="2" text="确认密码" horizontalalignment="right" verticalalignment="center" margin="4" /> <passwordbox grid.column="1" grid.row="2" margin="4" /> </grid>
在wpf中布局表单一直都很传统,例如使用上面的xaml,它通过grid布局一个表单。这样出来的结果整整齐齐,看上去没什么问题,但当系统里有几十个表单页以后需要统一将标签改为上对齐,或者标签和控件中加一个:
号等需求都会难倒开发人员。一个好的做法是使用某些控件库提供的表单控件;如果不想引入一个这么“重”的东西,可以自己定义一个简单的表单控件。
这篇文章介绍一个简单的用于布局表单的form
控件,虽然是一个很老的方案,但我很喜欢这个控件,不仅因为它简单实用,而且是一个很好的结合了itemscontrol、contentcontrol、附加属性的教学例子。
form是一个自定义的itemscontrol,部分代码可以参考自定义itemscontrol这篇文章。
2. 一个古老的方法
即使抛开验证信息、确认取消这些更高级的需求(表单的其它功能真的很多很多,但这篇文章只谈论布局),表单布局仍是个十分复杂的工作。幸好十年前scottgu分享过一个,很有参考价值:
wpf & silverlight lob form layout - searching for a better solution: karl shifflett has another great wpf blog post that covers a cool way to perform flexible form layout for lob scenarios.
<pt:form x:name="formmain" style="{dynamicresource standardform}" grid.row="1"> <pt:formheader> <pt:formheader.content> <stackpanel orientation="horizontal"> <image source="user.png" width="24" height="24" margin="0,0,11,0" /> <textblock verticalalignment="center" text="general information" fontsize="14" /> </stackpanel> </pt:formheader.content> </pt:formheader> <textbox pt:formitem.labelcontent="_first name" /> <textbox pt:formitem.labelcontent="_last name" /> <textbox pt:formitem.labelcontent="_phone" width="150" horizontalalignment="left" /> <checkbox pt:formitem.labelcontent="is _active" /> </pt:form>
使用代码和截图如上所示。这个方案最大的好处是只需在form中声明表单的逻辑结构,隐藏了布局的细节和具体实现,而且可以通过style设定不同表单的外观。
3. 我的实现
从十年前开始我就一直用这个方案布局表单,不过我对原本的方案进行了改进:
- 由于原本的代码是vb.net,我把它改为了c#。
- 原本的方案提供了十分多的属性,我只保留了最基本的几个,其它都靠style处理。因为我希望form是一个80/20原则下的产物,很少的代码,很短的编程时间,可以处理大部分的需求。
3.1 用formitem封装表单元素
在文章开头的表单中,textbox、password等是它的逻辑结构,其它都只是它外观和装饰,可以使用自定义的itemscntrol控件分离表单的逻辑结构和外观。之前自定义itemscontrol这篇文章介绍过,自定义itemscontrol可以首先定义itemcontainer,所以在实现form
的功能前首先实现formitem
的功能。
3.1.1 如何使用
<stackpanel grid.issharedsizescope="true"> <kino:formitem label="用户名" isrequired="true"> <textbox /> </kino:formitem> <kino:formitem label="密码" isrequired="true"> <passwordbox /> </kino:formitem> <kino:formitem label="国家与地区(请选择居住地)"> <combobox /> </kino:formitem> </stackpanel>
form的方案是将每一个表单元素放进单独的formitem,再由form负责布局。formitem也可以单独使用,例如把formitem放进stackpanel布局。
formitem并不会为ui提供丰富的属性选项,那是需要赚钱的控件库才会提供的需求,而且除了demo外应该没什么机会要为每个form设定不同的外观。在一个程序内,通常只有以下两种情况:
通用表单的布局,一般最多只有几种,只需要给出对应数量的全局样式就足够应付。
复杂而独特的布局,应该不会很多,所以不在form面对的80%应用场景,这种情况就特殊处理吧。
如果有一个程序有几十个表单而且每个表单布局全都不同,那么应该和产品经理好好沟通让ta不要这么任性。
3.1.2 formitem的具体实现
<style targettype="local:formitem"> <setter property="istabstop" value="false" /> <setter property="margin" value="12,0,12,12" /> <setter property="padding" value="8,0,0,0" /> <setter property="labeltemplate"> <setter.value> <datatemplate> <textblock text="{binding}" verticalalignment="center" /> </datatemplate> </setter.value> </setter> <setter property="template"> <setter.value> <controltemplate targettype="local:formitem"> <grid x:name="root"> <grid.columndefinitions> <columndefinition width="auto" sharedsizegroup="header" /> <columndefinition /> </grid.columndefinitions> <grid.rowdefinitions> <rowdefinition height="auto" /> <rowdefinition height="auto" /> </grid.rowdefinitions> <stackpanel orientation="horizontal" horizontalalignment="right"> <textblock x:name="isrequiredmark" margin="0,0,2,0" verticalalignment="center" grid.column="2" visibility="{binding isrequired,relativesource={relativesource mode=templatedparent},converter={staticresource booleantovisibilityconverter}}" text="*" foreground="red" /> <contentpresenter content="{templatebinding label}" textblock.foreground="#ff444444" contenttemplate="{templatebinding labeltemplate}" visibility="{binding label,relativesource={relativesource mode=templatedparent},converter={staticresource emptyobjecttovisibilityconverter}}" /> </stackpanel> <contentpresenter grid.column="1" margin="{templatebinding padding}" x:name="contentpresenter" /> <contentpresenter grid.row="1" grid.column="1" visibility="{binding description,relativesource={relativesource mode=templatedparent},converter={staticresource emptyobjecttovisibilityconverter}}" margin="{templatebinding padding}" content="{templatebinding description}" textblock.foreground="gray" /> </grid> </controltemplate> </setter.value> </setter> </style>
上面是formitem的defaultstyle。formitem继承contentcontrol并提供label、labeltemplate、description和isrequired四个属性,它的代码本身并不提供其它功能:
label
本来打算让formitem继承headeredcontentcontrol,但考虑到语义上label比header更合适结果还是使用了label。
labeltemplate
根据多年来的使用经验,比起提供各种各样的属性,一个labeltemplate能提供的更多更灵活。labeltemplate可以玩的花样还挺多的,例如formitem 使用如下setter让标签右对齐:
<setter property="labeltemplate"> <setter.value> <datatemplate> <textblock text="{binding}" verticalalignment="center" horizontalalignment="right" /> </datatemplate> </setter.value> </setter>
isrequired
是否为必填项,如果为true则显示红色的*
。
description
说明,controltemplate使用了systemcolors.graytextbrush
将文字设置为灰色。
一般来说有这些属性就够应对80%的需求。有些项目要求得更多,通常我会选择为这个项目单独定制一个派生自formitem的控件,而不是让原本的formitem更加臃肿。
sharedsizegroup
formitem中label列是自适应的,同一个form中不同formitem的这个列通过sharedsizegroup属性保持同步。应用了sharedsizegroup属性的元素会找到issharedsizescope设置true的父元素(也就是form),然后同步这个父元素中所有sharedsizegroup值相同的对应列。具体内容可见这篇文章。
很多人喜欢将label列设置为一个固定的值,但国际化后由于英文比中文长长长长很多,或者字体大小会改变,或者因为label是动态生成的一开始就不清楚label列需要的宽度,最终导致label显示不完整。如果将label列设置一个很大的宽度又会在大部分情况下显得左边很空旷,所以最好做成自适应。
3.2 用form和附加属性简化表单构建
3.2.1 如何使用
<kino:form header="normalform"> <textbox kino:form.label="用户名" kino:form.isrequired="true" /> <passwordbox kino:form.label="密码" kino:form.isrequired="true" /> <combobox kino:form.label="国家与地区(请选择居住地)" /> </kino:form>
将formitem封装到form中可以灵活地添加更多功能(不过我也只是多加了个header属性,一般来说已经够用)。可以看到使用附加属性的方式大大简化了布局form的xaml,而更重要的是语义上更加“正常”一些(不过也有人反馈不喜欢这种方式,也可能只是我自己用习惯了)。
3.2.2 form的基本实现
public partial class form : headereditemscontrol { public form() { defaultstylekey = typeof(form); } protected override bool isitemitsowncontaineroverride(object item) { bool isitemitsowncontainer = false; if (item is frameworkelement element) isitemitsowncontainer = getisitemitsowncontainer(element); return item is formitem || isitemitsowncontainer; } protected override dependencyobject getcontainerforitemoverride() { var item = new formitem(); return item; } }
headereditemscontrol
form是一个简单的自定义itemscontro,继承headereditemscontrol是为了多一个header属性及它的headertemplate可用。
getcontainerforitemoverride
protected virtual dependencyobject getcontainerforitemoverride () 用于返回item的container。所谓的container即item的容器,一些itemscontrol不会把items中的项直接呈现到ui,而是封装到一个container,这个container通常是个contentcontrol,如listbox的listboxitem。form返回的是formitem。
isitemitsowncontainer
protected virtual bool isitemitsowncontaineroverride (object item),确定item是否是(或者是否可以作为)其自己的container。在form中,只有formitem和isitemitsowncontainer附加属性的值为true的元素返回true。
3.2.3 使用附加属性简化xaml
比起用formitem包装每个表单元素,如果每个textbox、combobox等都有formitem的label、isrequired属性那就简单太多了。这种情况可以使用附加属性解决,如前面示例代码所示,使用附加属性后上面的示例代码可以答复简化,而且完全隐藏了formitem这一层,语义上更合理。
如果对附加属性不熟悉可以看我的。
为此form提供了几个附加属性,包括label
、labeltemplate
、description
、isrequired
和containerstyle
,分别和formitem中各属性对应,在form中使用protected virtual void preparecontainerforitemoverride (dependencyobject element, object item) 为formitem设置header
、description
、isrequired
:
protected override void preparecontainerforitemoverride(dependencyobject element, object item) { base.preparecontainerforitemoverride(element, item); if (element is formitem formitem && item is formitem == false) { if (item is frameworkelement content) prepareformframeworkelement(formitem, content); } } private void prepareformframeworkelement(formitem formitem, frameworkelement content) { formitem.label = getlabel(content); formitem.description = getdescription(content); formitem.isrequired = getisrequired(content); formitem.clearvalue(datacontextproperty); style style = getcontainerstyle(content); if (style != null) formitem.style = style; else if (itemcontainerstyle != null) formitem.style = itemcontainerstyle; else formitem.clearvalue(frameworkelement.styleproperty); datatemplate labeltemplate = getlabeltemplate(content); if (labeltemplate != null) formitem.labeltemplate = labeltemplate; }
clearvalue(frameworkelement.styleproperty)
注意formitem.clearvalue(frameworkelement.styleproperty)
这句。style是个可以使用继承值的属性(属性值继承使元素树中的子元素可以从父元素获取特定属性的值,并继承该值),也就是说如果写成formitem.style=null
它的style就会成为null,而不能继承父元素中设置的全局样式。(关于依赖属性的优先级,可以看我的另一篇文章:)
clearvalue(datacontextproperty)
另外还需注意formitem.clearvalue(datacontextproperty)
这句,因为formitem的datacontext会影响formitem的header等的绑定,所以需要清除它的datacontext的值,让它使用继承值。
visibility
var binding = new binding(nameof(visibility)); binding.source = content; binding.mode = bindingmode.oneway; formitem.setbinding(visibilityproperty, binding);
除了附加属性,formitem还可以绑定表单元素的依赖属性。上面这段代码添加在prepareformframeworkelement
最后,用于将formitem的visibility绑定到表单元素的visibility。一般来说表单元素的isenabled和visibility都是常常被修改的值,因为它们本身就是uielement的依赖属性,不需要为它们另外创建附加属性。
3.3 为表单布局添加层次
<style targettype="local:formseparator"> <setter property="margin" value="0,8,0,8" /> <setter property="template"> <setter.value> <controltemplate targettype="local:formseparator"> <rectangle verticalalignment="bottom" height="1" /> </controltemplate> </setter.value> </setter> </style> <style targettype="local:formtitle"> <setter property="fontsize" value="16" /> <setter property="margin" value="0,0,0,12" /> <setter property="padding" value="12,0" /> <setter property="foreground" value="#ff333333" /> <setter property="istabstop" value="false" /> <setter property="template"> <setter.value> <controltemplate targettype="local:formtitle"> <stackpanel margin="{templatebinding padding}"> <contentpresenter x:name="contentpresenter" contenttemplate="{templatebinding contenttemplate}" content="{templatebinding content}" /> <contentpresenter content="{templatebinding description}" visibility="{binding description,relativesource={relativesource mode=templatedparent},converter={staticresource nulltovalueconverter},converterparameter=collapsed,fallbackvalue=visible}" margin="0,2,0,0" textblock.fontsize="12" textblock.foreground="gray" /> </stackpanel> </controltemplate> </setter.value> </setter> </style>
这两个控件为form的布局提供层次感,两者都将isitemitsowncontainer附加属性设置为true,所以在form中不会被包装为formitem。这两个控件的使用如下:
<kino:form header="normalform"> <kino:formtitle content="用户信息" /> <textbox kino:form.label="用户名" kino:form.isrequired="true" /> <passwordbox kino:form.label="密码" kino:form.isrequired="true" /> <combobox kino:form.label="国家与地区(请选择居住地)" /> <kino:formseparator /> <kino:formtitle content="家庭信息" description="填写家庭信息可以让我们给您提供更好的服务。" /> <textbox kino:form.label="伴侣" kino:form.description="可以没有" kino:form.isrequired="true" /> <stackpanel kino:form.label="性别" orientation="horizontal"> <radiobutton content="男" groupname="sex" /> <radiobutton content="女" groupname="sex" margin="8,0,0,0" /> </stackpanel> </kino:form>
3.4 shouldapplyitemcontainerstyle
shouldapplyitemcontainerstyle的作用是返回一个值,该值表示是否将属性 itemcontainerstyle 或 itemcontainerstyleselector 的样式应用到指定的项的容器元素。由于在form中设置了:
[styletypedproperty(property = "itemcontainerstyle", styletargettype = typeof(formitem))]
但同时form中很可能有formtitle、formseparator,为避免itemcontainerstyle错误地应用到formtitle和formseparator导致出错,需要添加如下代码:
protected override bool shouldapplyitemcontainerstyle(dependencyobject container, object item) { return container is formitem; }
4. 其它方案
form是一个简单的只满足了基本布局功能的表单方案,业务稍微复杂的程序可以考虑使用下面这些方案,由于这些方案通常包含在成熟的控件库里面(而且稍微超出了“入门"的范围),所以我只简单地介绍一下。
asp.net mvc的方案是通过在实体类的属性上添加各种标签:
[required] [emailaddress] [display(name = "email address")] public string email { get; set; }
ui上就可以这么使用:
<form asp-controller="demo" asp-action="registerlabel" method="post"> <label asp-for="email"></label> <input asp-for="email" /> <br /> </form>
使用同样结构的实体类,wpf还可以这么使用:
<dc:dataform data="{binding selecteditem}"> <dc:dataformfielddescriptor propertyname="id" /> <dc:dataformfielddescriptor propertyname="firstname"/> <dc:dataformfielddescriptor propertyname="lastname"/> <dc:dataformfielddescriptor propertyname="gender"/> <dc:dataformfielddescriptor propertyname="mainaddress"> <dc:dataformfielddescriptor.subfields> <dc:dataformfielddescriptor propertyname="address1"/> <dc:dataformfielddescriptor propertyname="city"/> <dc:dataformfielddescriptor propertyname="state"/> </dc:dataformfielddescriptor.subfields> </dc:dataformfielddescriptor> </dc:dataform>
由dataform选择表单元素并生成的做法也很多人喜欢,但对实体类的要求也较高。dataform通常还可以更进一步--反射实体类的所有属性自动创建表单。如果需要的话可以直接买一个包含dataform的控件库,或者将silverlighttookit的dataform移植过来用。这之后话题越来越不“入门”就割爱了。
5. 还有什么
作为一个表单怎么可以没有错误验证和提交按钮,提交按钮部分在接下来的文章里介绍,但错误验证是一个很大的功能(而且没有错误验证部分这个form也能用),我打算之后再改进。
其它例如点击取消按钮要提示“内容已修改是否放弃保存”之类的功能太倾向业务了,不想包含在控件的功能中。
接下来的文章会继续介绍form的其它小功能。
6. 参考
scottgu's blog - nov 6th links_ asp.net, asp.net ajax, jquery, asp.net mvc, silverlight and wpf
itemscontrol class (system.windows.controls) microsoft docs
7. 源码
推荐阅读
-
[WPF自定义控件库] 模仿UWP的ProgressRing
-
如何在双向绑定的Image控件上绘制自定义标记(wpf)
-
[WPF自定义控件库] 给WPF一个HyperlinkButton
-
[WPF自定义控件库]使用TextBlockHighlightSource强化高亮的功能,以及使用TypeConverter简化调用
-
angular4自定义表单控件[(ngModel)]的实现
-
WPF的ListView控件自定义布局用法实例
-
WPF 控件库——仿制Windows10的进度条
-
WPF 控件库——带有惯性的ScrollViewer
-
[WPF 学习] 3.用户控件库使用资源字典的困惑
-
WPF 控件库——仿制Chrome的ColorPicker