【WPF】根據選項值顯示不同的編輯控制元件(使用DataTemplateSelector)

东邪独孤發表於2024-06-29

接了一個小雜毛專案,大概情形是這樣的:ZWT先生開的店是賣拆片機的,Z先生不僅賣機器,還貼心地提供一項服務:可以根據顧客需要修改兩個電機的轉向和轉速(機器廠家有給SDK的,但Z自己不會寫程式)。廠家有配套一個調節器,調整引數時連線到拆片機的串列埠上,然後旋轉按鈕可以調速,撥碼開關可以設定電機正轉還是反轉。但Z覺得調速器長得像個馬桶水箱似的,既不好看也不便攜帶。最慘的是,有的客戶的車間裡有很多臺機子,一臺一臺的連上串列埠調很費勁。於是,學過電工的Z不知道哪搞了一個串列埠 Hub,電腦向 Hub 發資料,然後會將資料群發給所有連線的拆片機(這種Hub忘了叫什麼,十幾年前老周曾在某工廠裡見過)。

Z先生經過 N 箇中間人介紹,找到老周。去Z的店參觀了一下,瞭解情況後。當時老周可能被蚊子叮昏了頭腦,覺得這不簡單嗎,便答應Z老闆說三天可以完成。不過,這做起來確實不復雜,基礎程式碼當天晚上用了兩個多小時就搞定了。串列埠發資料這事也不難,就一個 SerialPort 類就搞定的。協議方面有文件,一個位元組陣列,代入引數就行。每臺拆片機的編組號和引數就用一人 Sqlite 資料庫存放就完事,配上 EF Core 讀寫起來那簡直是入門級的難度。

不過,在第一次實測時遇到一個問題,每臺機器的引數是不同的,需要提供一個介面,讓使用者可以增/刪/改/查。也就是說,機器引數不能用類來封裝,靜態語言又不能動態生成屬性。當然,這也不是靜態語言的錯,而是思路的問題。我幹嗎非要用類封裝呢?不封裝不就更可以動態變化了嗎?然後,老周的生理 CPU 飛速運轉,眼前浮現著一張張熟悉的面孔—— 用字典來儲存,用JSON來做,用XML來做……避免產生過多不必要的資料檔案,老周決定在資料庫加個表,一個選項(引數)就是一條資料記錄。欄位如選項名稱,選項值,備註,新增時間,最後修改時間,新增人(誰操作的)等。

隨後,又有問題了,雖然在資料庫中我把選項(引數)值統一用文字來表示,但在使用者介面上,總不能全放個 TextBox 在那裡。最重要的是全用 TextBox 的話,驗證的程式碼就要寫很多了。比如:你輸入的是日期嗎?你輸入的是整數嗎?Bool 值的東西為什麼還要讓人填呢,直接CheckBox不好嗎?現在的情況是,機器引數是使用者動態新增的,在佈局介面時我不能寫死了,畢竟事先不能確定用下拉選單還是文字框。那就在執行時動態呈現控制元件吧。

思路有了,但方法有很多種。老周當初吹下了三天完工的牛皮,所以,建立新控制元件的思路可能更花時間,而且感覺也沒必要。經過短暫的計劃,老周的想法:

1、在編輯介面上用一個 ListBox 或 ItemsControl,這樣可以免去許多排版的功夫。就把介面當成一個列表框控制元件,每個被編輯的選項就是一個列表項。這樣新增和刪除也方便,直接 Binding 就好了。

2、為 ItemsControl 定義列表項模板,是 DataTemplate,不是控制元件模板。重做控制元件模板跟創作新控制元件一樣,費時間。

3、DataTemplate 中,一個 TextBlock 用來顯示專案標籤(不可修改的文字),然後值呢?

4、選項值用的控制元件是不固定的,事先不可知,老周就放了個 ContentControl 在那裡,就當作佔位符用吧。為什麼選它呢?因為它有 Content 屬性,可以放任何東西,而且配套一個 ContentTemplate 屬性,型別正是 DataTemplate。當然,也可以用類似功能的控制元件。

老周的想法就是,用 ContentTemplate 去引用其他的資料模板,當然資料模板也不能寫死的。即要定義多個模板,裡面放不同的控制元件,需要根據選項的需求選擇不同的模板。於是,有一個類可以用上——DataTemplateSelector。

這個類有一個虛方法叫 SelectTemplate,能夠根據相同資料運態返回資料模板。派生類只要重寫這個方法就 OK。

哦,補充一下,什麼是拆片機。大夥伴看看你身上穿的戰袍,是不是由一塊塊布組成?服裝廠先要依據圖紙,用選定的線織成一塊塊的“散片”,針織機有橫機和縱機,橫向的多見。以前是手動操作,現在是電腦控制。這些“散片”可交給縫盤工人(做縫盤的一般是女孩,以前那些手動織機的話,男女都有)“組裝”起來。下圖是常見的縫盤機(不是縫紉機,二者不同的),老周家裡還有三臺,23年前的型號,有兩臺還是全新未拆封的。

縫盤機的套口編號要與針織機的匹配使用,如果混用會出問題的,以前內銷毛衣常見的12針(這個簡單說就是代表線眼的疏密)一般會配用14號的縫盤機(當然也不是絕對,看工人經驗,有時候接近編號的也行)。

從上世紀 90 年代起,老周家裡做過十幾年的毛衣加工,現在是租給別人做,近20臺電腦橫機,全天24小時工作。所以,服裝方面的東西,老周還是懂一點點的。

拆片就是把編織好的衣片拆解,變回一筒一筒的毛線。這個原因就很複雜,可能針織時效果不好,可能拿錯圖紙了,可能多出來不用的……把衣片重新變回毛線,還可以重複用,不浪費。有些片可能被機器裡的潤滑油汙染,以前有的師傅懶得處理,是直接不要的(在農村,以前有些人家用來生火煮水煮飯),不過,這些毛料燃燒起來是環境汙染,而且味道讓人很難受。現在很少有人這麼幹。畢竟,別說燃氣灶,連電磁爐在農村都很普及了。直接生火做飯的很少,就算要生火,也會選一些天然的柴木。

拆片機結構很簡單,用夾子固定衣片,先人工抽出部分絲線,在橡皮輪上纏繞二三十圈,然後開動電機就行,工作起來有點像打毛機。拆片機現在並沒有標準的產品,都是個別有“本事”的工廠或者像Z先生這樣的,自己做的。

------------------------------------------------------------------------------------------------------------------------------------------------------------------

不得了,這次扯的廢話太長了。不能再說了,不然就真變成水文了。下面說重點。

步驟一:定義選項包裝類

哦,老週上面只是說,不用類來封裝選項資料,但每條選項記錄還是要封裝的。這個類與資料庫中的選項表對映。為了簡單,老周刪除了像備註、操作員、時間、父級選項等無關的屬性。

public class OptionItem : INotifyPropertyChanged
{
    private string _labelName;
    private string _objValue;
    private bool _required;
    private UIElementType _uiType;
    private string _listItems;

    public int Id { get; set; }

    /// <summary>
    /// 選項的標籤文字
    /// </summary>
    public string LabelName
    {
        get => _labelName;
        set
        {
            _labelName = value;
            OnMyPropertyChanged();
        }
    }

    /// <summary>
    /// 選項的值
    /// </summary>
    public string ObjectValue
    {
        get { return _objValue; }
        set
        {
            _objValue = value;
            OnMyPropertyChanged();
        }
    }

    /// <summary>
    /// 是否為必填
    /// </summary>
    public bool Required
    {
        get => _required;
        set
        {
            _required = value;
            OnMyPropertyChanged();
        }
    }

    /// <summary>
    /// 介面控制元件型別
    /// </summary>
    public UIElementType UIType
    {
        get => _uiType;
        set
        {
            _uiType = value;
            OnMyPropertyChanged();
        }
    }

    /// <summary>
    /// 顯示在下拉選單中的內容,只有需要下拉選單控制元件時才用到
    /// </summary>
    public string ListItems
    {
        get => _listItems;
        set
        {
            _listItems = value;
            OnMyPropertyChanged();
        }
    }

    // 屬性更改後呼叫的方法
    private void OnMyPropertyChanged([CallerMemberName] string propertyName = null)
    {
        if (string.IsNullOrEmpty(propertyName))
        {
            return;
        }
        PropertyChanged?.Invoke(this, new(propertyName));
    }

    // 屬性更改通知事件
    public event PropertyChangedEventHandler PropertyChanged;
}

實現 INotifyPropertyChanged 介面,這個不用多介紹,可以讓繫結物件能實時獲得更改通知。UIElementType 是一個列舉型別,表示要呈現什麼樣的控制元件,對映到資料庫表時用整型儲存。

public enum UIElementType
{
    TextBox,                // 普通文字框
    NumberBoxInt,           // 文字框,但輸入整數用
    NumberBoxFloat,         // 文字框,但輸入浮點數用
    CheckBox,               // 核取方塊
    DropdownList,           // 下拉選單框
    DatePicker              // 日期選擇器
}

這老周專案所需要用的控制元件,你可以根據實際新增成員。WPF的編輯控制元件其實很少,比 WinForms 還少。大概開發團隊覺得 WPF 的控制元件可以自己設計。雖然控制元件模板方便換,但做起來也要一定工作量的,反正嘛,非必要不創作控制元件。有大夥伴會問:怎麼不用開源的第三方控制元件?如果老周自己用的話,倒是可以的。可是專案一旦是商用的就……有些開源協議是很讓人無語的,而且不少專案在商用上要授權的。比如那個 Qt,到處告人,“一不小心就會一團糟”。所以咱們這邊做上位機、客戶端什麼的基本用 .NET,Qt 幾乎沒幾個人用,很多公司都不敢用,怕被找上門。

購買授權這種事不要問老周,因為老周也搞不懂。但老周大膽懷疑,這些王X蛋是不是故意的,想透過打官司來發家致富?其實大夥常用的手機 App 表面免費,實際上你如果有心情去閱讀一下使用者協議,你會冒出一身冷汗。處處給你設坑,你想不跳都不行。比如微信輸入法什麼的,你如果用它來寫小說,那小心,可能你的版權會變成別人的。還有一些不要臉的套殼 AI 服務,在你我不知情的前提下,把咱們的東西變成它們的資料、模型的一部分,產權歸誰?科學技術很強大,可有沒有質疑過,現在搞科技創新的初心是什麼?為了割矮腳白菜?還是為戰爭服務?還是……

不過,老周從 2010 年至今,接的很多專案 90% 是用 .NET 做的,至少目前沒出什麼授權上的問題,也沒收到過“綠屍寒”。微軟還是靠譜的。

為了在資料庫中能夠相容,表示選項值的 ObjectValue 屬性定義為字串型別,在資料庫也是用文字存放。因為選項值可能是浮點數和日期,但文字很強大,啥值它都能表示。LabelName 屬性是顯示在使用者介面上的,不能被編輯。Required 屬性表示此欄位值是不是必需的,如果是,會在標籤旁邊顯示一個星號(*)。在繫結時,用 BoolValueConverter 轉換器把 bool 值轉為控制元件的 Visibility 屬性值,這個轉換器是 .NET 類庫自有的,不用我們自己寫。

ListItems 屬性只有當控制元件是 ComboBox 時才用到,字串內容,每個項用逗號分隔,顯示在下拉選單中。由於它的值是一個字串,而 ComboBox 的 item source 是列表型別。需要寫一個轉換器,把字串分割成一個陣列。

public class StrToListConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        string list = (string)value;
        var splis = list.Split(',').Select(s => s.Trim());
        return splis.ToArray();
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return null;
    }
}

因為繫結是單向的,所以 ConvertBack 不需要,返回 null 了事。

同樣的道理,如果用的是 CheckBox 控制元件,需要 bool 和 string 之間的轉換。

public class BoolValueConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        // 由於資料的值都用字串表示,所以這裡可以直接轉成字串
        string strValue = (string)value;
        if (bool.TryParse(strValue, out bool val))
        {
            return val;
        }
        return false;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        // 轉回去的時候,都是字串
        return value.ToString();
    }
}

由於 CheckBox 控制元件用於編輯,需要雙向繫結,所以 ConvertBack 方法要實現。

步驟二:從TextBox派生一個類

儘管原則上不創作新控制元件,但由於 TextBox 除輸入任意文字外,有些選項只能讓使用者輸入數字。有大夥伴會說,用 InputScope 不就行了嗎。還真的不行。input scope 只對觸控螢幕鍵盤有效,對於實體鍵盤無法限制。因此,為了方便,只好派生出一個類,然後重寫 OnPreviewTextInput 方法,把非數字字元排除。其實,處理 TextBox 的 PreviewTextInput 事件也可以的,不過派生一個類來處理,完整性更好,也方便直接把控制元件程式碼複製到別的專案使用。Preview開頭的事件是【隧道】事件,即從 XAML 樹的外層向內引發,而 TextInput 事件是冒泡式的,從內向外引發。鍵盤事件由 WM_KEY*** 相關訊息產生,作業系統會將其傳遞給視窗。視窗處理了再到裡面的控制元件處理。我們應該處理 Preview 事件,當輸入的字元不是數字時,不讓它向下傳播,這樣 TextBox 就不會接收這個字元了。

public class NumberTextBox : TextBox
{
    public NumberTextBox()
    {
        TextNumberType = NumberType.Integer;
        // 禁用輸入法
        InputMethod.SetIsInputMethodEnabled(this, false);
        // 設定輸入範圍是數字,這隻對觸控鍵盤有效
        InputScope ipscope = new();
        ipscope.Names.Add(new InputScopeName(InputScopeNameValue.Number));
        this.InputScope = ipscope;
    }

    /// <summary>
    /// 獲取/設定數字型別
    /// </summary>
    public NumberType TextNumberType { get; set; }

    protected override void OnPreviewTextInput(TextCompositionEventArgs e)
    {
        Debug.WriteLine($"Text={e.Text}, ControlText={e.ControlText}");
        string pattern = TextNumberType == NumberType.Integer ? "[0-9]+" : "[0-9.]+";
        var rgxres = Regex.Match(e.Text, pattern);
        if (!rgxres.Success)
        {
            e.Handled = true;
            return;
        }
        base.OnPreviewTextInput(e);
    }
}

public enum NumberType
{
    /// <summary>
    /// 整數
    /// </summary>
    Integer,
    /// <summary>
    /// 浮點數
    /// </summary>
    Floating
}

老周這裡就用正規表示式來匹配,實際上它只匹配一個字元。只要有字元輸入,TextInput 相關的事件就會引發,而關聯的文字只是剛剛輸入的那個字元,而不是 TextBox 中的所有字元。也就是說,這段程式碼只是限制,並不能驗證輸入的對不對。其實這程式碼還不嚴謹,如果使用者已經輸入過小數點了,也可能出現輸入 N 個小數點的問題。我們也可以最佳化一下。

protected override void OnPreviewTextInput(TextCompositionEventArgs e)
{
    ……
    if (TextNumberType == NumberType.Floating && e.Text == ".")
    {
        // 看看他輸入了幾個小數點
        TextBox tb = (TextBox)e.OriginalSource;
        int index = tb.Text.LastIndexOf('.');
        // 索引如果有效,說明之前輸入過小數點
        if(index > -1)
        {
            e.Handled= true;
            return;
        }
    }

    base.OnPreviewTextInput(e);
}

e.Handled = true 會阻止事件向下傳播,就能達到阻止輸入某些字元的目的。注意,TextInput 相關事件引發時,一般我們只能獲取剛輸入的字元,要結合 TextBox 現有的文字來分析,就必須從 TextBox.Text 屬性獲取。上面程式碼是先判斷當前輸入的是浮點數,且當前輸入的字元是小數點,如果是,就從 TextBox 現有文字中查詢小數點。如果找到,說明之前輸入過,就不能再輸入了。

當然了,限制並不能代替數驗證功能。如果需要,還要進一步用程式碼檢查輸入的值是否有效。

步驟三:準備一堆資料模板

要用模板選擇器篩選要用的資料模板,首先得準備一系列待選模板,放進資源字典中。

<Window.Resources>
    <!--轉換器-->
    <BooleanToVisibilityConverter x:Key="btvCvt" />
    <local:BoolValueConverter x:Key="boolValCvt"/>
    <local:StrToListConverter x:Key="strToListCvt"/>
    <!--下面幾個模板是用於選擇的-->
    <DataTemplate x:Key="selTempAnyTxt">
        <TextBox Text="{Binding Path=ObjectValue, Mode=TwoWay}" InputMethod.IsInputMethodEnabled="False"/>
    </DataTemplate>
    
    <DataTemplate x:Key="selTempNumberInt">
        <local:NumberTextBox Text="{Binding Mode=TwoWay, Path=ObjectValue}" TextNumberType="Integer"/>
    </DataTemplate>
    
    <DataTemplate x:Key="selTempNumberFloat">
        <local:NumberTextBox Text="{Binding Mode=TwoWay, Path=ObjectValue}" TextNumberType="Floating"/>
    </DataTemplate>

    <DataTemplate x:Key="selTempList">
        <ComboBox ItemsSource="{Binding Path=ListItems, Converter={StaticResource strToListCvt}}"
                  Text="{Binding Path=ObjectValue, Mode=TwoWay}"
                  IsReadOnly="True"
                  IsEditable="True"/>
    </DataTemplate>
    
    <DataTemplate x:Key="selTempCheckBox">
        <CheckBox IsChecked="{Binding Path=ObjectValue,Mode=TwoWay,Converter={StaticResource boolValCvt}}" Content=""/>
    </DataTemplate>
    
    <DataTemplate x:Key="selTempDatePicker">
        <DatePicker Text="{Binding Path=ObjectValue, Mode=TwoWay}" SelectedDateFormat="Short"/>
    </DataTemplate>
    <!--END-->
……
</Window.Resources>

要定義一堆模板,看著複雜,其實很簡單。

步驟四:編寫模板選擇器

    public class MyTemplateSelector : DataTemplateSelector
    {
        public override DataTemplate SelectTemplate(object item, DependencyObject container)
        {
            OptionItem opt = (OptionItem)item;

            switch (opt.UIType)
            {
                case UIElementType.TextBox:
                    return TextTemplate;
                case UIElementType.NumberBoxInt:
                    return IntNumTemplate;
                case UIElementType.NumberBoxFloat:
                    return FloatNumTemplate;
                case UIElementType.DropdownList:
                    return DropListTemplate;
                case UIElementType.CheckBox:
                    return CheckBoxTemplate;
                case UIElementType.DatePicker:
                    return DatePickerTemplate;
            }
            return TextTemplate;
        }

        #region 公共屬性,用於引用模板
        public DataTemplate TextTemplate { get; set; }
        public DataTemplate IntNumTemplate { get; set; }
        public DataTemplate FloatNumTemplate { get; set; }
        public DataTemplate CheckBoxTemplate { get; set#endregion
    }

注意那六大屬性,待會把選擇器放到XAML資源中,可以引用我們前面定義的那些模板。重寫 SelectTemplate 方法是核心。根據 UIType 屬性來判斷要用的模板。這是咱們前面定義選項類時用的列舉。

步驟五:套用

        <!--模板選擇器-->
        <local:MyTemplateSelector x:Key="tempSelt"
                                  TextTemplate="{StaticResource selTempAnyTxt}"
                                  IntNumTemplate="{StaticResource selTempNumberInt}"
                                  FloatNumTemplate="{StaticResource selTempNumberFloat}"
                                  DropListTemplate="{StaticResource selTempList}"
                                  CheckBoxTemplate="{StaticResource selTempCheckBox}"
                                  DatePickerTemplate="{StaticResource selTempDatePicker}"
                               />
        <DataTemplate x:Key="itemTemp" DataType="local:OptionItem">
            <Grid MinWidth="300" HorizontalAlignment="Center" Margin="0,5">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="100"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <StackPanel Orientation="Horizontal" Grid.Column="0" HorizontalAlignment="Right" Margin="0,0,5,0">
                    <TextBlock>
                        <Run Text="{Binding Mode=TwoWay, Path=LabelName}"/>
                        <Run Text=":"/>
                    </TextBlock>
                    <TextBlock Visibility="{Binding Required, Converter={StaticResource btvCvt}}" Foreground="Red" FontSize="16">*</TextBlock>
                </StackPanel>
                <ContentControl Grid.Column="1" Content="{Binding}" ContentTemplateSelector="{StaticResource tempSelt}"/>
            </Grid>
        </DataTemplate>

Key 為 itemTemp 的模板是給 ItemsControl 控制元件用的。而前面的那些模板只是給 ContentControl 用的。也就是說,ItemsControl 控制元件的列表項套用 itemTemp 模板,而 itemTemp 模板裡面的 ContentControl 控制元件又套用前面定義的模板(由模板選擇器決定)。

注意,繫結 Required 屬性的 TextBlock 只有一個“*”字元,當 Required 屬性為true時,該控制元件顯示星號,表示此欄位必填。

步驟六:介面佈局

在介面上放一個 ItemsControl 控制元件就能顯示各個選項欄位資訊了。

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="*"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <ScrollViewer Grid.Row="0">
        <ItemsControl x:Name="itOpt"
             ItemTemplate="{StaticResource itemTemp}"
             />
    </ScrollViewer>
    <TextBox Grid.Row="1" x:Name="txtDisp"/>
</Grid>

txtDisp 用來每 3 秒顯示一次選項類列表,這個只是用來觀察繫結有沒有雙向更新,並無實際用途。

下面是程式碼部分。

    public partial class MainWindow : Window
    {
        private ObservableCollection<OptionItem> _optionItems = new();
        private DispatcherTimer timer = new DispatcherTimer();
        public MainWindow()
        {
            InitializeComponent();
            timer.Interval = TimeSpan.FromSeconds(3);
            timer.Tick += OnTimer;
            _optionItems.Add(new()
            {
                Id = 1,
                Required = true,
                LabelName = "banch",
                UIType = UIElementType.NumberBoxInt,
                ObjectValue = "3"
            });
            _optionItems.Add(new()
            {
                Id = 2,
                LabelName = "factor",
                UIType = UIElementType.NumberBoxFloat,
                ObjectValue = "0.1",
                Required = false
            });
            _optionItems.Add(new()
            {
                Id = 3,
                LabelName = "tcc1",
                ObjectValue = "s-i-2",
                Required = true,
                UIType = UIElementType.TextBox
            });
            _optionItems.Add(new()
            {
                Id = 4,
                LabelName = "tcc2",
                ObjectValue = "k9",
                Required = true,
                UIType = UIElementType.TextBox
            });
            _optionItems.Add(new()
            {
                Id = 5,
                LabelName = "wt_mode",
                ObjectValue = "nor",
                Required = false,
                UIType = UIElementType.DropdownList,// 下拉選單
                ListItems = "cls,nor,slw,wdw"
            });
            _optionItems.Add(new()
            {
                Id = 6,
                LabelName = "flash",
                UIType = UIElementType.CheckBox,
                ObjectValue = "True",
                Required = false
            });
            _optionItems.Add(new()
            {
                Id = 7,
                LabelName = "size",
                ObjectValue = "8",
                UIType = UIElementType.NumberBoxInt,
                Required = false
            });
            _optionItems.Add(new()
            {
                Id = 8,
                LabelName = "reg_0xD3",
                Required = false,
                ObjectValue = "29",
                UIType = UIElementType.NumberBoxInt
            });
            // 繫結
            itOpt.ItemsSource = _optionItems;
            timer.Start();
        }

        private void OnTimer(object sender, EventArgs e)
        {
            StringBuilder bd = new StringBuilder();
            foreach (OptionItem oi in _optionItems)
            {
                bd.AppendLine($"{oi.LabelName} = {oi.ObjectValue}");
            }
            txtDisp.Text = bd.ToString();
        }
    }

DispatchTimer 只是用來週期性更新顯示,沒其他用途。

執行程式後,你會發現。程式會根據各個選項的設定,生成編輯欄位。

是不是很好用呢?現在,把 factor 欄位改為 1.5,等3秒鐘後,看看有沒有更改。

已經更新了。

再試試,把 flash 的 CheckBox 中的勾去掉,看會不會變成 false。

好了,已經有效果了,這樣一來,使用者愛新增啥引數就隨他的便。

相關文章