欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

[WPF自定义控件库]简单的表单布局控件

程序员文章站 2022-04-14 18:47:55
1. WPF布局一个表单 在WPF中布局表单一直都很传统,例如使用上面的XAML,它通过Grid布局一个表单。这样出来的结果整整齐齐,看上去没什么问题,但当系统里有几十个表单页以后需要统一将标签改为上对齐,或者标签和控件中加一个:号等需求都会难倒开发人员。一个好的做法是使用某些控件库提供的表单控件; ......

1. wpf布局一个表单

[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.

[WPF自定义控件库]简单的表单布局控件

[WPF自定义控件库]简单的表单布局控件

<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. 我的实现

从十年前开始我就一直用这个方案布局表单,不过我对原本的方案进行了改进:

  1. 由于原本的代码是vb.net,我把它改为了c#。
  2. 原本的方案提供了十分多的属性,我只保留了最基本的几个,其它都靠style处理。因为我希望form是一个80/20原则下的产物,很少的代码,很短的编程时间,可以处理大部分的需求。

3.1 用formitem封装表单元素

在文章开头的表单中,textbox、password等是它的逻辑结构,其它都只是它外观和装饰,可以使用自定义的itemscntrol控件分离表单的逻辑结构和外观。之前自定义itemscontrol这篇文章介绍过,自定义itemscontrol可以首先定义itemcontainer,所以在实现form的功能前首先实现formitem的功能。

3.1.1 如何使用

[WPF自定义控件库]简单的表单布局控件

<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设定不同的外观。在一个程序内,通常只有以下两种情况:

  1. 通用表单的布局,一般最多只有几种,只需要给出对应数量的全局样式就足够应付。

  2. 复杂而独特的布局,应该不会很多,所以不在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 如何使用

[WPF自定义控件库]简单的表单布局控件

<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提供了几个附加属性,包括labellabeltemplatedescriptionisrequiredcontainerstyle,分别和formitem中各属性对应,在form中使用protected virtual void preparecontainerforitemoverride (dependencyobject element, object item) 为formitem设置headerdescriptionisrequired

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 为表单布局添加层次

[WPF自定义控件库]简单的表单布局控件

<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。这两个控件的使用如下:

[WPF自定义控件库]简单的表单布局控件

<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. 源码

kino.toolkit.wpf_form