【.NET深呼吸】用程式碼寫WPF控制元件模板

2023-06-18 15:00:47

這一次咱們來探究一下怎麼用純程式碼寫 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 年呢。