【.NET深呼吸】用代碼寫WPF控件模板

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

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章