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