当前位置: 移动技术网 > IT编程>开发语言>.net > [WPF自定义控件库]简单的表单布局控件

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

2019年05月30日  | 移动技术网IT编程  | 我要评论

咸宁新闻网,会呼吸的痛 杨宗纬,名门剩女

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分享过一个,很有参考价值:

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

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

  1. 由于原本的代码是vb.net,我把它改为了c#。
  2. 原本的方案提供了十分多的属性,我只保留了最基本的几个,其它都靠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设定不同的外观。在一个程序内,通常只有以下两种情况:

  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 如何使用

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

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

kino.toolkit.wpf_form

如对本文有疑问,请在下面进行留言讨论,广大热心网友会与你互动!! 点击进行留言回复

相关文章:

验证码:
移动技术网