[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. 我的實現

從十年前開始我就一直用這個方案布局表單,不過我對原本的方案進行了改進:

  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
附加屬性1:概述
附加屬性概述
自定義附加屬性

7. 源碼

Kino.Toolkit.Wpf_Form


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM