這一次咱們來探究一下怎麼用純程式碼寫 WPF 模板。模板有個共同基礎類別 FrameworkTemplate,資料模板、控制元件模板等是從此類派生的,因此,該類已定義了一些通用成員。
用程式碼構建模板,重要的成員是 VisualTree 屬性,它的型別是 FrameworkElementFactory。可見,模板不是直接建立視覺化物件,而是通過一個工廠類來範例化。畢竟用於模板的視覺化樹是在用到時才建立的。
這麼看來,對於控制元件、常見元素,用 XAML 和用純程式碼寫差不多,而模板用程式碼寫就複雜一些。所以,比較好的方法是把控制元件樣式、模板都放到外部的 XAML 檔案中,再在程式中載入(就像老週上一篇水文那樣)。要改 UI 你直接改 XAML 檔案就行了,程式不用重新編譯。
說一下用法。
1、呼叫 FrameworkElementFactory 類別建構函式,可以直接用 XAML 文字初始化,也可以指定一個 Type,讓工廠類自動範例化。
2、a:要設定某個屬性的值,用 SetValue 方法;
b:要為某個屬性設定資料繫結,請用 SetBinding 方法;
c:要參照資源中的東東,請用 SetResourceReference 方法。
3、呼叫 AppendChild 方法可以把另一個 FrameworkElementFactory 物件新增當前物件的子級。這種方法可以構建 N 個層次的邏輯樹。
4、AddHandler、RemoveHandler 為物件新增或刪除事件處理方法。
老周下面要用的這個例子,是一個控制元件庫。在新建專案時,可以直接用 WPF 控制元件庫模板。
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net7.0-windows</TargetFramework> <Nullable>enable</Nullable> <UseWPF>true</UseWPF> </PropertyGroup> </Project>
我們這裡不用 XAML 檔案,所以,Themes 目錄可以刪除。然後就像寫普通類庫一樣,定義控制元件類,從 Control 類派生。
public class VVControl : Control { …… }
這個控制元件沒什麼實用價值,純屬娛樂。控制元件模板裡面放一個 StackPanel,水平排列,然後排三個圓。滑鼠點第一個圓時,只有第一個圓的背景色會變;點選第二個圓時,第一、二個圓的背景色都變;點選第三個圓時,三個圓的背景色都會變。
// 根元素 FrameworkElementFactory rootFac = new(typeof(StackPanel)); // 設定方向 rootFac.SetValue(StackPanel.OrientationProperty, Orientation.Horizontal);
這個是模板中的根元素,StackPanel 面板,方向水平。然後我們弄三個圓,圓可以用 Ellipse 類來做,寬度和高度相等就是正圓了。
// 圓的寬度和高度 const double ELL_SIZE = 35.0d; const double ELL_MARGIN = 6.5d; // 輪廓大小 const double ELL_STROKEW = 2.0d; // 子元素是三個圈 FrameworkElementFactory ellip1 = new(typeof(Ellipse), "ellip1"); // 設定寬度和高度 ellip1.SetValue(Shape.WidthProperty, ELL_SIZE); ellip1.SetValue(Shape.HeightProperty, ELL_SIZE); // 邊距 ellip1.SetValue(Shape.MarginProperty, new Thickness(ELL_MARGIN)); ellip1.SetValue(Shape.StrokeThicknessProperty, ELL_STROKEW); // 這兩個屬性要繫結 ellip1.SetBinding(Shape.StrokeProperty, fgbind); // 把子元素追加到樹中 rootFac.AppendChild(ellip1); FrameworkElementFactory ellip2 = new(typeof(Ellipse), "ellip2"); ellip2.SetValue(Shape.WidthProperty, ELL_SIZE); ellip2.SetValue(Shape.HeightProperty, ELL_SIZE); ellip2.SetValue(Shape.MarginProperty, new Thickness(ELL_MARGIN)); ellip2.SetBinding(Shape.StrokeProperty, fgbind); ellip2.SetValue(Shape.StrokeThicknessProperty, ELL_STROKEW); rootFac.AppendChild(ellip2); FrameworkElementFactory ellip3 = new(typeof(Ellipse), "ellip3"); ellip3.SetValue(Shape.WidthProperty, ELL_SIZE); ellip3.SetValue(Shape.HeightProperty, ELL_SIZE); ellip3.SetValue(Shape.MarginProperty, new Thickness(ELL_MARGIN)); ellip3.SetBinding(Shape.StrokeProperty, fgbind); ellip3.SetValue(Shape.StrokeThicknessProperty, ELL_STROKEW); rootFac.AppendChild(ellip3);
這樣,控制元件模板就構建好了,下面建立 ControlTemplate,並賦值給當前控制元件的 Template 屬性。
ControlTemplate temp = new(this.GetType()); temp.VisualTree = rootFac; this.Template = temp;
模板中的三個圓都有命名的,比如
FrameworkElementFactory ellip2 = new(typeof(Ellipse), "ellip2");
FrameworkElementFactory 建構函式的第二個引數可以為元素分配一個 Name。後面咱們在控制元件的邏輯處理中要存取這三個圓,所以給它們命名。
定義一個 LoadExtXaml 方法,傳入檔名,這樣方便動態載入 XAML 檔案。
public void LoadExtXaml(string file) { using FileStream input = File.OpenRead(file); this.Resources = (ResourceDictionary)XamlReader.Load(input); // 從資源加獲取畫刷 this.Background = (SolidColorBrush)Resources["background"]; this.BorderBrush = (SolidColorBrush)Resources["bordercolor"]; }
XAML 檔案單獨放到類庫外,方便直接修改,不重新編譯。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <SolidColorBrush x:Key="background" Color="Green"/> <SolidColorBrush x:Key="bordercolor" Color="Red"/> </ResourceDictionary>
這主要是背景、前景色的畫刷,常用的可能有字型啊、背景圖片啊什麼的,這些內空修改的概率大,全扔到外部 XAML 檔案中。為了可以給控制元件」換面板「,咱們也可以再弄一個 XAML 檔案,也是放到程式外。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <SolidColorBrush x:Key="background" Color="Blue"/> <SolidColorBrush x:Key="bordercolor" Color="DeepPink"/> </ResourceDictionary>
如果你有 100 套面板,那就弄 100 個 XAML 檔案就行了。最好建個資料夾,把 XAML 全放進去。XAML 檔案可以用專門的命名方式。比如 myStyle-<主題名稱>.xaml 這樣,方便在程式碼中識別。你甚至可以寫程式碼直接遍歷這個目錄下的 XAML 檔案,然後在程式視窗上動態生成選單,讓使用者選擇面板,然後載入對應的 XAML 檔案。豈不美哉!
好了,現在控制元件有了用純程式碼搞的模板,又可載入外部資源了。接下來要重寫 OnApplyTemplate 方法,當控制元件套用完模板後就會呼叫這個方法,我們在這個地方就可以讀出模板裡面命名的三個 Ellipse 物件了。
Ellipse _ep1, _ep2, _ep3;
// 透明畫刷
SolidColorBrush _defaultBrush = new(Colors.Transparent);
……
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
_ep1 = (Ellipse)GetTemplateChild("ellip1");
_ep2 = (Ellipse)GetTemplateChild("ellip2");
_ep3 = (Ellipse)GetTemplateChild("ellip3");
_ep1.Fill = _ep2.Fill = _ep3.Fill = _defaultBrush;
_ep1.MouseDown += OnEllipseMouseDown;
_ep2.MouseDown += OnEllipseMouseDown;
_ep3.MouseDown += OnEllipseMouseDown;
// 雙擊恢復預設填充顏色
this.MouseDoubleClick += (_, _) =>
{
_ep1.Fill = _ep2.Fill = _ep3.Fill = _defaultBrush;
};
}
要將模板中的物件擼出來不要呼叫 FindName 方法,這個方法只查詢當前物件的子級,不是包括模板裡面的。而要用 GetTemplateChild 方法,這個才是搜尋模板的。
下面就是處理 MouseDown 的方法。
private void OnEllipseMouseDown(object sender, MouseButtonEventArgs e) { if(e.OriginalSource == _ep1) { _ep1.Fill = Background; _ep2.Fill = _ep3.Fill = _defaultBrush; } else if(e.OriginalSource == _ep2) { _ep1.Fill = _ep2.Fill = Background; _ep3.Fill = _defaultBrush; } else if(e.OriginalSource == _ep3) { _ep1.Fill = _ep2.Fill = _ep3.Fill = Background; } else { _ep1.Fill = _ep2.Fill = _ep3.Fill = _defaultBrush; } }
控制元件庫搞好了,然後咱們得用用,看正不正常。新增一個 WPF 應用程式專案。
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net7.0-windows</TargetFramework> <Nullable>enable</Nullable> <UseWPF>true</UseWPF> </PropertyGroup> </Project>
你會發現,其實 WPF應用程式 和 WPF 控制元件庫的專案檔案差不多,區別是多了 OutputType 為 Winexe 的屬性罷了。
參照咱們剛剛做好的控制元件庫專案。
<ItemGroup> <ProjectReference Include="..\CustControl\CustControl.csproj" /> </ItemGroup>
寫視窗類。
public class MyWindow : Window { public MyWindow() { InitUI(); } private void InitUI() { NameScope.SetNameScope(this, new NameScope()); DockPanel root = new(); root.LastChildFill = true; this.Content = root; // 有兩個按鈕,用來選擇主題 StackPanel btnPanel = new() { Orientation = Orientation.Horizontal }; Button btnStyle1 = new Button { Content = "主題1" }; Button btnStyle2 = new Button { Content = "主題2" }; btnPanel.Children.Add(btnStyle1); btnPanel.Children.Add(btnStyle2); root.Children.Add(btnPanel); DockPanel.SetDock(btnPanel, Dock.Bottom); btnStyle1.Click += OnStyle1Click; btnStyle2.Click += OnStyle2Click; VVControl cust = new("mycc\\style.a.xaml"); RegisterName("myCust", cust); root.Children.Add(cust); } private void OnStyle2Click(object sender, RoutedEventArgs e) { VVControl? cc = FindName("myCust") as VVControl; if(cc != null) { cc.LoadExtXaml("mycc\\style.b.xaml"); } } private void OnStyle1Click(object sender, RoutedEventArgs e) { VVControl? c = FindName("myCust") as VVControl; if(c != null) { c.LoadExtXaml("mycc\\style.a.xaml"); } } }
Main 入口點。
[STAThread] static void Main(string[] args) { Application app = new Application(); MyWindow win = new MyWindow(); win.Title = "範例程式"; win.Width = 350; win.Height = 300; app.Run(win); }
在生成的主程式的.exe 所在目錄下建立 mycc 目錄,把前面那兩個 XAML 檔案放進去,就完功了。
但你會發現,換主題時,圓的背景色不會自動換,要等單擊事件後才變,而圓的輪廓是能及時換色的。這是因為 Fill 屬性沒有進行繫結,是在處理滑鼠按下事件時用程式碼賦值的,所以不會自動更新。
至於 DataTemplate,和 ControlTemplate 一樣的,也是通過 FrameworkElementFactory 類構建物件樹。老周就不重複說了。資料模板和控制元件模板本來就是同一玩意兒,只是它們的角色不一樣而已。
如果你的程式要通過程式碼來計算,動態得到 UI 相關屬性的話,那用純程式碼寫較方便;如果不是的話,可以把一些資源放到程式外,這樣你想改 的時候隨便改,程式碼不用多次編譯。
下面咱們弄個內外結合的方案。即控制元件庫使用內建的XAML,但像邊框、背景、字型等,放到外部的檔案中。
新建 WPF 控制元件庫專案,我們做個簡單控制元件。
Themes/Generic.xaml:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:import="clr-namespace:GuaGuaControlLib"> <Style TargetType="{x:Type import:MyControl}"> <Setter Property="BorderBrush" Value="{DynamicResource bdColor}"/> <Setter Property="Foreground" Value="{DynamicResource fgColor}"/> <Setter Property="Background" Value="{DynamicResource bgColor}"/> <Setter Property="Margin" Value="4.5"/> <Setter Property="BorderThickness" Value="1,1.5"/> <Setter Property="FontSize" Value="{DynamicResource fontSize}"/> <Setter Property="FontFamily" Value="{DynamicResource fontFamily}"/> <Setter Property="HorizontalContentAlignment" Value="Center"/> <Setter Property="VerticalContentAlignment" Value="Center"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type import:MyControl}"> <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"> <TextBlock FontFamily="{TemplateBinding FontFamily}" FontSize="{TemplateBinding FontSize}" Margin="{TemplateBinding Padding}" Foreground="{TemplateBinding Foreground}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}" Text="{TemplateBinding Text}"/> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary>
MyControl.cs
[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)] namespace GuaGuaControlLib { public class MyControl : Control { public static readonly DependencyProperty TextProperty; static MyControl() { // 重寫樣式鍵的依賴屬性後設資料 DefaultStyleKeyProperty.OverrideMetadata( typeof(MyControl), new FrameworkPropertyMetadata(typeof(MyControl)) ); // 註冊依賴屬性 TextProperty = DependencyProperty.Register("Text", typeof(string), typeof(MyControl), new FrameworkPropertyMetadata(string.Empty)); } // 封裝屬性 public string Text { get => (string)GetValue(TextProperty); set => SetValue(TextProperty, value); } public MyControl() { } } }
有五個資源咱們放到專案外面,這裡得用動態資源才能正確參照,用靜態資源會報錯,目前老周未找到解決方法。
下面這個 XAML 檔案不包含在專案內,不會參與生成。
res/cust.xaml:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:sys="clr-namespace:System;assembly=mscorlib"> <SolidColorBrush x:Key="bdColor" Color="blue"/> <SolidColorBrush x:Key="bgColor" Color="red"/> <SolidColorBrush x:Key="fgColor" Color="LightBlue"/> <sys:Double x:Key="fontSize">25.0</sys:Double> <FontFamily x:Key="fontFamily">華文彩雲</FontFamily> </ResourceDictionary>
這五個 Key 對應被參照的五個資源項。
新增 WPF 應用程式專案,並參照 MyControl 所在專案。
<ItemGroup> <ProjectReference Include="..\GuaGuaControlLib\GuaGuaControlLib.csproj" /> </ItemGroup>
從 Window 類派生自定義視窗類。
class MyWindow : Window { public MyWindow() { // 載入外部資源 using var fs = File.OpenRead("res\\cust.xaml"); // 合併資源字典 Resources.MergedDictionaries.Add((ResourceDictionary)XamlReader.Load(fs)); MyControl cc = new(); cc.Text = "小約翰可汗"; Grid root = new(); root.Children.Add(cc); Content = root; } }
class Program { [STAThread] static void Main(string[] args) { Application myapp = new Application(); MyWindow mainWin = new MyWindow(); mainWin.Title = "外部資源"; mainWin.Width = 242; mainWin.Height = 199; myapp.Run(mainWin); } }
這裡咱們採用合併資源字典的方式載入 XAML 檔案。如果主資源中有定義的內部物件,用合併字典的方式可以保證主資源中的物件不會被覆蓋。
執行看一下。
開啟外部的 cust.xaml 檔案,咱們改一下顏色和字型,並儲存。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:sys="clr-namespace:System;assembly=mscorlib"> <SolidColorBrush x:Key="bdColor" Color="blue"/> <SolidColorBrush x:Key="bgColor" Color="darkblue"/> <SolidColorBrush x:Key="fgColor" Color="LightBlue"/> <sys:Double x:Key="fontSize">25.0</sys:Double> <FontFamily x:Key="fontFamily">華文行楷</FontFamily> </ResourceDictionary>
不要重新生成專案,直接執行程式。
嗯,這樣就方便很多了。
===================================================================================
關於純程式碼寫 WPF 以及載入外部 XAML 以方便改程式,老週一口氣寫完了這三篇水文。下面老周就做一個膚淺的總結吧。
先說說為什麼會產生這一系列」奇葩「想法。主要有這兩個因素:
1、對介面做一些引數的修改(如字型、顏色、背景圖什麼的)又要重新生成專案確實麻煩;
2、Qt 的 QSS 和 QML 既可以編譯進資源中,也可以放在外部參照,也容易修改。所以我在想,WPF 專案也應該這樣搞。
老周正在虐待的這個破專案比較雜,介面主視窗是 Qt 做的,一些左邊欄,右邊欄子視窗是 Win32 寫的。操作員設定視窗是別人用 WPF 做的。exe 檔案都好幾個(以前寫程式碼那貨肯定東抄一塊,西抄一塊來的)。所以,用 Win32 API 寫的和 WPF 寫的程式,在入口函數時直接建立子程序,讓它們執行,然後獲取視窗的控制程式碼,套在 Qt 的 Widget 中,再懟到主視窗上。目前沒什麼問題,執行之後,外行人看不出來是幾個東東拼接出來的。忽悠過去就完事了,誰還管它 100 年呢。