內容概述
我去年寫過幾篇關於在WPF和Silverlight中實現可復用的設計的文章,分別如下,如果有興趣地可以先參考一下
-
可復用的WPF或者Silverlight應用程序和組件設計(1)——應用程序級別
-
可復用的WPF或者Silverlight應用程序和組件設計(2)——組件級別
-
可復用的WPF或者Silverlight應用程序和組件設計(3)——控件級別
-
可復用的WPF或者Silverlight應用程序和組件設計(4)——外觀級別
今天要繼續寫第五篇的原因在於最近的一些思考,也是我被問到的一個問題:我們知道WPF中的布局控件有很多,例如Grid,StackPanel,Canvas等等,利用他們,編寫一定的XAML定義,就可以設計出來足夠靈活多樣的界面。但這里會有一個問題,如果我們很多界面都很類似,例如有某種固定格式的布局要求,那么是否在每個界面上都應該去定義一次呢?
[備注]和前幾篇文章不同的是,這一篇沒有錄制視頻,因為我還是覺得文字的部分很重要。
本文源代碼可以通過 http://files.cnblogs.com/chenxizhang/WpfApplicationSample.zip 下載
問題詳細描述
想象一下下面這樣一個示意圖
如果只有一個界面的話,那么我們可以很簡單地實現,如下面所示
<Window x:Class="WpfApplicationSample.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:extension="clr-namespace:System.Windows.Extensions;assembly=WPFExtension" Title="MainWindow" Height="350" Width="525"> <Window.Resources> <Style TargetType="TextBlock"> <Setter Property="FontSize" Value="30"></Setter> </Style> </Window.Resources> <Grid extension:GridHelper.ShowBorder="True"> <Grid.RowDefinitions> <RowDefinition Height="80"></RowDefinition> <RowDefinition Height="*"></RowDefinition> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"></ColumnDefinition> <ColumnDefinition Width="100"></ColumnDefinition> </Grid.ColumnDefinitions> <!--頂部控件--> <ContentControl> <ContentControl.Content> <TextBlock Text="Top"></TextBlock> </ContentControl.Content> </ContentControl> <!--主體控件--> <ContentControl Grid.Row="1"> <ContentControl.Content> <TextBlock Text="Main"></TextBlock> </ContentControl.Content> </ContentControl> <!--右側控件--> <ContentControl Grid.Column="1" Grid.RowSpan="2"> <ContentControl.Content> <TextBlock Text="Right"></TextBlock> </ContentControl.Content> </ContentControl> </Grid> </Window>
這個界面看起來是這樣的:
所以,我們完全可以做得出來。但是問題的關鍵在於,如果有很多窗口都是這樣的布局,那么我們是否應該每個窗口都去這樣定義?還是說能否從一定意義上實現布局重用:能不能讓這個Grid默認就有兩行和兩列呢?
[備注] 上面的代碼中,為了給Grid添加邊框線,使用到了我以前寫的一個擴展組件,可以通過這里了解如何使用:http://nuget.org/packages/WPFSilverlightExtension/
Windows Forms里面的做法
很久以前,那時候還沒有WPF,我們都使用Windows Forms這個技術來做界面。那時候,對於上面提到的這種布局重用的問題,有一個很簡單的解決方案:窗體繼承。
對於這一種技術,本文並不打算對此進行展開討論,有興趣的朋友可以參考MSDN:http://msdn.microsoft.com/en-us/library/aa983613(v=VS.71).aspx
是否可以通過Style來實現對Grid行和列的定制
雖然確實有很多人懷念Windows Forms那種經典的界面開發,但時光之輪總是催我們向前。回到現在我們在用的WPF這個技術,對於控件和界面重用,WPF提供了很強大的Style功能。所以,基於上面這樣的需求,我們會很自然地想到是否可以通過Style來實現。
<Style TargetType="Grid"> <Setter Property="ColumnDefinitions"> <Setter.Value> <ColumnDefinition Width="*"></ColumnDefinition> <ColumnDefinition Width="100"></ColumnDefinition> </Setter.Value> </Setter> <Setter Property="RowDefinitions"> <Setter.Value> <RowDefinition Height="80"></RowDefinition> <RowDefinition Height="*"></RowDefinition> </Setter.Value> </Setter> </Style>
如果能這樣寫,當然是很好的。但問題是,你不能這樣寫。錯誤如下
如果去查看Grid的類型定義,會發現這個屬性確實只有get方法器
是否可以通過對Grid進行擴展來實現行和列的定制
一計不成,我們可以繼續想辦法。有着良好的面向對象素養的同學一定可以想到,那么要不就對Grid做一個擴展,默認提供行和列的定義,這樣行不行呢?例如
using System.Windows.Controls; namespace WpfApplicationSample { class LayoutGrid:Grid { protected override void OnInitialized(System.EventArgs e) { base.OnInitialized(e); //默認提供兩行兩列的實現 base.ColumnDefinitions.Add(new ColumnDefinition()); base.ColumnDefinitions.Add(new ColumnDefinition() { Width = new System.Windows.GridLength(100) }); base.RowDefinitions.Add(new RowDefinition() { Height = new System.Windows.GridLength(80) }); base.RowDefinitions.Add(new RowDefinition()); } } }
然后,我們在界面上可以像下面這樣使用。很明顯,這樣可以簡化很多了。
<Window x:Class="WpfApplicationSample.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:extension="clr-namespace:System.Windows.Extensions;assembly=WPFExtension" xmlns:local="clr-namespace:WpfApplicationSample" Title="MainWindow" Height="350" Width="525"> <Window.Resources> <Style TargetType="TextBlock"> <Setter Property="FontSize" Value="30"></Setter> </Style> </Window.Resources> <local:LayoutGrid extension:GridHelper.ShowBorder="true"> <!--頂部控件--> <ContentControl> <ContentControl.Content> <TextBlock Text="Top"></TextBlock> </ContentControl.Content> </ContentControl> <!--主體控件--> <ContentControl Grid.Row="1"> <ContentControl.Content> <TextBlock Text="Main"></TextBlock> </ContentControl.Content> </ContentControl> <!--右側控件--> <ContentControl Grid.Column="1" Grid.RowSpan="2"> <ContentControl.Content> <TextBlock Text="Right"></TextBlock> </ContentControl.Content> </ContentControl> </local:LayoutGrid> </Window>
這個方案的美中不足,在於使用者還是需要記住要在具體的內容控件上面設置Grid.Row,Grid.Column等相關信息,而且,如果他忘記寫,或者沒有按照規范寫,實際上就沒有實現界面的統一,因為你沒有辦法強制他必須寫,或者怎么寫。
要想實現界面的統一,就必須把這種可能改變界面的屬性,從使用者的身邊移開。
使用用戶控件實現PlaceHolder式的控件模板
既然直接擴展Grid並不能完整地實現我們的需求,我接下來想到是否可以使用用戶控件來封裝,而且為了讓用戶使用簡單,同時也避免用戶不按照規定使用,所以我借鑒了ASP.NET中的母版頁(master page)這樣的技術,使用類似PlaceHolder這樣的方式來實現了一個控件模板。
<UserControl x:Class="WpfApplicationSample.TemplateLayout" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:extension="clr-namespace:System.Windows.Extensions;assembly=WPFExtension" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300" x:Name="root"> <Grid extension:GridHelper.ShowBorder="True"> <Grid.RowDefinitions> <RowDefinition Height="80"></RowDefinition> <RowDefinition Height="*"></RowDefinition> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"></ColumnDefinition> <ColumnDefinition Width="100"></ColumnDefinition> </Grid.ColumnDefinitions> <!--頂部控件--> <ContentControl Content="{Binding TopControl,ElementName=root}"> </ContentControl> <!--主體控件--> <ContentControl Grid.Row="1" Content="{Binding MainControl,ElementName=root}"> </ContentControl> <!--右側控件--> <ContentControl Grid.Column="1" Grid.RowSpan="2" Content="{Binding RightControl,ElementName=root}"> </ContentControl> </Grid> </UserControl>
需要注意的是,因為這里是定義一個控件模板,所以具體的內容控件是沒有內容的,我們希望使用者可以后期再插入具體的內容。這里使用到了綁定的技術。為了支持綁定,我們在這個控件中添加了三個依賴屬性,如下
using System.Windows; using System.Windows.Controls; namespace WpfApplicationSample { /// <summary> /// Interaction logic for TemplateLayout.xaml /// </summary> public partial class TemplateLayout : UserControl { public TemplateLayout() { InitializeComponent(); } public object TopControl { get { return (object)GetValue(TopControlProperty); } set { SetValue(TopControlProperty, value); } } // Using a DependencyProperty as the backing store for TopControl. This enables animation, styling, binding, etc... public static readonly DependencyProperty TopControlProperty = DependencyProperty.Register("TopControl", typeof(object), typeof(TemplateLayout), new PropertyMetadata(null)); public object MainControl { get { return (object)GetValue(MainControlProperty); } set { SetValue(MainControlProperty, value); } } // Using a DependencyProperty as the backing store for MainControl. This enables animation, styling, binding, etc... public static readonly DependencyProperty MainControlProperty = DependencyProperty.Register("MainControl", typeof(object), typeof(TemplateLayout), new PropertyMetadata(null)); public object RightControl { get { return (object)GetValue(RightControlProperty); } set { SetValue(RightControlProperty, value); } } // Using a DependencyProperty as the backing store for RightControl. This enables animation, styling, binding, etc... public static readonly DependencyProperty RightControlProperty = DependencyProperty.Register("RightControl", typeof(object), typeof(TemplateLayout), new PropertyMetadata(null)); } }
那么,如何來使用這個模板控件呢?大致是下面這樣的:
<local:TemplateLayout> <local:TemplateLayout.TopControl> <TextBlock Text="Top"></TextBlock> </local:TemplateLayout.TopControl> <local:TemplateLayout.MainControl> <TextBlock Text="Main"></TextBlock> </local:TemplateLayout.MainControl> <local:TemplateLayout.RightControl> <TextBlock Text="Right"></TextBlock> </local:TemplateLayout.RightControl> </local:TemplateLayout>
很顯然,這樣使用起來更加簡潔方便,而且使用者只能往我們預先定義好的三個內容區域中去填充內容,而不可能因為錯誤的設置屬性(或者忘記設置屬性)而導致界面布局不一致。
看起來相當不錯,幾乎已經完全實現了我們的要求。我以前也就一直這樣用,直到最近發現一個問題。
如果我們出於一些目的,希望給這些內容控件添加名稱,以便在后台代碼中訪問到它。例如下面這樣:
<local:TemplateLayout> <local:TemplateLayout.TopControl> <TextBlock Text="Top" x:Name="topTextBlock"></TextBlock> </local:TemplateLayout.TopControl> <local:TemplateLayout.MainControl> <TextBlock Text="Main"></TextBlock> </local:TemplateLayout.MainControl> <local:TemplateLayout.RightControl> <TextBlock Text="Right"></TextBlock> </local:TemplateLayout.RightControl> </local:TemplateLayout>
看起來是一個很平常的修改,但你立即會發現,這會導致無法編譯通過。
Cannot set Name attribute value 'topTextBlock' on element 'TextBlock'. 'TextBlock' is under the scope of element 'TemplateLayout', which already had a name registered when it was defined in another scope.
這個錯誤實在是讓人捉摸不透,目前也沒有找到合理的解釋。可能的解釋是這樣:因為我們的TextBlock其實是嵌入到TemplateLayout中,而TemplateLayout默認會為內部的控件生成一個名稱,所以就不允許再定義名稱。
為此,我也找了很多資料,一個最接近的討論在這里
這個問題描述到了和本文比較接近的情況,而且也提到了如果假如名稱,會出現錯誤。但是該文也沒有可用的解決方案。
針對這個問題,我做了不少研究,同時也找了一些朋友進行討論。其中和韋恩卑鄙 的討論中,他給了我一個啟發,打開了我另外一個思路:如果說定義名稱是必須的,那么既然WPF內部的命名規范無法通過,那么是否可以通過自己的一種什么機制來定義名稱呢?最終我確定使用附加屬性來實現了該功能。榮譽屬於韋恩卑鄙。
通過附加屬性來為控件添加名稱
我創建了如下這樣一個類型,添加了一個附加屬性,並且為控件查找提供了一個方法。
using System.Collections.Generic; using System.Linq; using System.Windows; namespace WpfApplicationSample { public class LayoutExtension { /// <summary> /// 這個方法用來獲取控件 /// </summary> /// <typeparam name="T">指定控件類型,例如TextBlock</typeparam> /// <param name="name">指定控件名稱</param> /// <returns></returns> public static T GetControl<T>(string name) where T : class { return controls.FirstOrDefault(t => t.Key == name).Value as T; } private static Dictionary<string, DependencyObject> controls; static LayoutExtension() { controls = new Dictionary<string, DependencyObject>(); } public static string GetName(DependencyObject obj) { return (string)obj.GetValue(NameProperty); } public static void SetName(DependencyObject obj, string value) { obj.SetValue(NameProperty, value); } // Using a DependencyProperty as the backing store for Name. This enables animation, styling, binding, etc... public static readonly DependencyProperty NameProperty = DependencyProperty.RegisterAttached("Name", typeof(string), typeof(LayoutExtension), new PropertyMetadata(string.Empty, (d, e) => { if (e.NewValue != null) { var name = e.NewValue.ToString(); if (!controls.ContainsKey(name)) controls.Add(name, d); } })); } }
如何使用這個擴展呢?
<local:TemplateLayout>
<local:TemplateLayout.TopControl>
<TextBlock Text="Top"
local:LayoutExtension.Name="topTextBlock"></TextBlock>
</local:TemplateLayout.TopControl>
<local:TemplateLayout.MainControl>
<TextBlock Text="Main"></TextBlock>
</local:TemplateLayout.MainControl>
<local:TemplateLayout.RightControl>
<TextBlock Text="Right"></TextBlock>
</local:TemplateLayout.RightControl>
</local:TemplateLayout>
然后,如果需要在后台代碼中訪問這個控件,就可以大致像下面這樣操作
using System.Windows; using System.Windows.Controls; using System.Windows.Media; namespace WpfApplicationSample { /// <summary> /// Interaction logic for MainWindow.xaml /// </summary> public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); Loaded += MainWindow_Loaded; } void MainWindow_Loaded(object sender, RoutedEventArgs e) { var txt = LayoutExtension.GetControl<TextBlock>("topTextBlock"); txt.Foreground = new SolidColorBrush(Colors.Red); } } }
如何更加方便地使用這個擴展?
為了使得大家更加方便地使用這個擴展,我將其合並到了我之前寫過的針對WPF和Silverlight的擴展包中,大家可以通過nuget package manager搜索wpfsilverlightextension下載安裝這個擴展包
目前這個擴展,既支持WPF,也支持Silverlight。可以免費使用。
如果是使用這個包的話,那么在導入名稱空間的時候,我習慣用extension這個名稱,所以在具體頁面中用的時候,大致上是下面這樣
<local:TemplateLayout> <local:TemplateLayout.TopControl> <TextBlock Text="Top" extension:LayoutExtension.Name="topTextBlock"></TextBlock> </local:TemplateLayout.TopControl> <local:TemplateLayout.MainControl> <TextBlock Text="Main"></TextBlock> </local:TemplateLayout.MainControl> <local:TemplateLayout.RightControl> <TextBlock Text="Right"></TextBlock> </local:TemplateLayout.RightControl> </local:TemplateLayout>
是否一定要使用這個擴展?
看起來還不錯,但是是否一定要使用這個擴展呢?並不見得。因為我之前就說過,我以前沒有意識到這個Name會出問題,是因為我幾乎大部分時候都沒有命名這種需要。為什么呢?因為我們大部分時候都會使用mvvm這種模式進行WPF應用程序的開發,在這種情況下,我們是不會在代碼中去訪問到控件的。例如下面是一個較為真實的例子:
<Page x:Class="EmployeePage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:SMSApplicationSample" xmlns:extension="clr-namespace:System.Windows.Extensions;assembly=WPFExtension" xmlns:tk="clr-namespace:Xceed.Wpf.Toolkit;assembly=WPFToolkit.Extended" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300" Title="EmployeePage"> <Page.DataContext> <local:EmployeePageViewModel></local:EmployeePageViewModel> </Page.DataContext> <tk:BusyIndicator IsBusy="{Binding IsBusy}" BusyContent="Data loading,pls wait..."> <local:TemplateLayout> <local:TemplateLayout.Condition> <!--加載所有員工--> <DataGrid ItemsSource="{Binding Employees}" SelectedItem="{Binding CurrentEmployee,Mode=TwoWay}"> <!--通過前台的選擇,TwoWay的方式可以將CurrentEmployee的更新告訴后台,並且去更新綁定了該屬性的元素,這個機制就是所謂的依賴屬性的通知功能--> </DataGrid> </local:TemplateLayout.Condition> <local:TemplateLayout.Result> <!--顯示一個員工--> <ContentControl Content="{Binding CurrentEmployee}"> <ContentControl.ContentTemplate> <DataTemplate> <Grid extension:GridHelper.ShowBorder="True"> <Grid.RowDefinitions> <RowDefinition Height="auto"></RowDefinition> <RowDefinition Height="auto"></RowDefinition> <RowDefinition Height="auto"></RowDefinition> <RowDefinition Height="auto"></RowDefinition> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="100"></ColumnDefinition> <ColumnDefinition Width="*"></ColumnDefinition> <ColumnDefinition Width="100"></ColumnDefinition> </Grid.ColumnDefinitions> <Image Source="{Binding Photo}" Grid.Column="2" Grid.RowSpan="4"></Image> <TextBlock Text="FirstName:"></TextBlock> <TextBlock Text="{Binding FirstName}" Grid.Column="1"></TextBlock> <TextBlock Text="LastName:" Grid.Row="1"></TextBlock> <TextBlock Text="{Binding LastName}" Grid.Row="1" Grid.Column="1"></TextBlock> <TextBlock Text="Title:" Grid.Row="2"></TextBlock> <TextBlock Text="{Binding Title}" Grid.Row="2" Grid.Column="1"></TextBlock> <TextBlock Text="Address:" Grid.Row="3"></TextBlock> <TextBlock Text="{Binding Address}" Grid.Row="3" Grid.Column="1"></TextBlock> </Grid> </DataTemplate> </ContentControl.ContentTemplate> </ContentControl> </local:TemplateLayout.Result> <local:TemplateLayout.Action> <!--顯示一些按鈕,可以添加,刪除,修改員工--> <StackPanel> <Button Content="New..." Command="{Binding NewCommand}"></Button> <Button Content="Update..." Command="{Binding UpdateCommand}" CommandParameter="{Binding CurrentEmployee}"></Button> <Button Content="Delete..." Command="{Binding DeleteCommand}"></Button> <Button Content="Show Report..." Command="{Binding ReportCommand}"></Button> </StackPanel> </local:TemplateLayout.Action> </local:TemplateLayout> </tk:BusyIndicator> </Page>
本文源代碼可以通過 http://files.cnblogs.com/chenxizhang/WpfApplicationSample.zip 下載








