當前位置: 妍妍網 > 碼農

實作現代化 WPF 日期選擇控制項 SmartDate

2024-05-18碼農

實作現代化 WPF 日期選擇控制項 SmartDate

控制項名稱:SmartDate

作者:WPFDevelopersOrg - Vicky&James

源碼連結 [1] :https://github.com/vickyqu115/smartdate

教學視訊 [2] (【小李趣味多】https://bit.ly/3xI9DNh)

這篇文章是對 WPF SmartDate 教程視訊的技術回顧。

WPF DatePicker 的問題認知

WPF DatePicker WPF 中歷史悠久的基本控制項之一,已經有近 20 年的歷史。相比簡單的 Button、TextBox、CheckBox 等控制項, DatePicker 內部結構和操作步驟更加復雜,由多個控制項組成。因此,進行自訂需要高水平的技能和技術,直接使用或自訂這一老舊控制項相當困難。

WPF DatePicker 的理解

分析和理解 DatePicker 的結構及樣版中各內部元素的互動,是提升 WPF 設計和分析能力的有益案例。這不僅適用於 DatePicker ,還適用於所有 WPF 控制項。然而, DatePicker 的設計是在很多年前,與現在更加推薦的編程方式有所不同,因此在這樣的環境下,根據計畫的具體需求,透過 CustomControl 重新構建一個 DatePicker 控制項可能是更加有效的方式。

下載和準備源碼

本文介紹了如何辨識基礎 DatePicker 的使用問題,並透過 CustomControl 方法重新設計。你可以透過 GitHub 下載源碼並檢視結果,同時結合本文閱讀將會更有幫助。

首先,透過以下命令從 GitHub 下載源碼:

git clone https://github.com/vickyqu115/smartdate

接下來,要執行源碼的解決方案檔,需要在 Windows 10 以上的環境中使用 Visual Studio 2022 Rider 以及 .NET 8.0 版本。

SmartDate.sln

SmartDate.sln

計畫結構

SmartDate 由兩個計畫組成:

  • SmartDateControl : CustomControl 庫,包含 SmartDate 類及所有子 CustomControl 類。

  • SmartDateApp : 一個簡單的應用程式計畫,展示如何使用這個控制項。

  • SmartDate 的聲明與使用方法

    使用方法非常簡單。透過 xmlns 聲明名稱空間,並像使用傳統 DatePicker 一樣使用 SmartDate

    <Windowx: class="SmartDateApp.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:smart="clr-namespace:SmartDateControl.UI.Units;assembly=SmartDateControl"
    xmlns:theme="https://jamesnet.dev/xaml/presentation/themeswitch"
    mc:Ignorable="d"
    x:Name="Window"
    Title="SmartDate"Height="450"Width="800"Background="#FFFFFF">

    <ViewboxWidth="500">
    <UniformGridMargin="20"Columns="1"VerticalAlignment="Top">
    <smart:SmartDateSelectedDate="{Binding Created}"/>
    <DatePickerSelectedDate="{Binding Created}"/>
    </UniformGrid>
    </Viewbox>
    </Window>

    SelectedDate 是一個 DependencyProperty ,與 DatePicker SelectedDate 相同,型別為 DateTime?

    執行結果

    執行結果

    CustomControl 的定義與套用

    開始定義 CustomControl 。通常, CustomControl 是從 Control 衍生的類,但實際上,所有從 DependencyObject 衍生的類都可以包括在內。然而,只有那些可以利用 Template 或至少可以利用 DataContext 的階層才有意義。因此,從 FrameworkElement 衍生的類更適合用於 CustomControl 的實作。

    設計新的 DatePicker: SmartDate

    本文詳細說明了如何實作一個從基本類 Control 衍生的新的 CustomControl SmartDate ,而不是使用現有的 DatePicker

    選擇 Control 而非 ContentControl 的原因

    首先,了解 ContentControl Control 的區別。 ContentControl 除了提供基本樣版外,還提供 Content ContentTemplate 內容。 ContentPresenter 透過 DataTemplate 自動連線 Content 和 ContentTemplate,因此無需手動設定它們之間的關系。總結來說,根據 DataTemplate 的基本利用情況選擇衍生控制項是明智的。

    DatePicker 是一個使用 DataTemplate 的控制項嗎?盡管觀點可能不同,但 DatePicker 這樣的復雜控制項通常需要多個 DataTemplate ,不適合被視為一般的 ContentControl 。實際上, DatePicker 衍生自 Control ,而類似型別的控制項通常也繼承自 Control 。盡管 ComboBox 看起來與 DatePicker 相似,但它是一個擁有 ItemsSource 內容的 ItemsControl

    因此,實作 SmartDate 時選擇 Control 是合適的,因為 SmartDate 並不提供獨立的 DataTemplate

    DataTemplate 的套用方法

    雖然 SmartDate 預設不提供 DataTemplate ,但在多個領域可以考慮擴充套件 DataTemplate

    例如,可以擴充套件 DayOfWeek 控制項的 ContentPresenter ,以添加對特定日期的處理。客戶經常要求特殊日期的觸發器或轉換器,這樣的擴充套件非常實用。

    SelectedDate 繫結區域擴充套件為 ContentPresenter ,可以靈活地用於簡單的 TextBlock 、可編輯的 TextBox 或包含時間選擇的日期選擇。

    DataTemplate 的不足

    盡管 DataTemplate 在復雜情況下保持通用性並提供必要的客製樣版區域,但在特定控制項如日期選擇器中套用時需要謹慎考慮。 DataTemplate 會將相關邏輯分離成獨立的互動實作,看似實用,但需要慎重判斷。

    SmartDate 的主要繫結內容(DependencyProperty)

    這個控制項包括一個名為 SelectedDate 的繫結內容,型別為 DateTime ?。由於預設值可以為空,因此聲明為 Nullable 型別,用於指定透過行事曆選擇的日期值。

    SmartDate 樣版設計

    ControlTemplate 設計中必需的基本組成部份如下:

  • Popup

  • ListBox

  • ToggleButton

  • Popup 用於包含 ListBox ,即行事曆; ListBox 透過 ItemsPanelTemplate 使用 UniformGrid 實作行事曆; ToggleButton 以行事曆圖示表示,當按鈕切換時, Popup IsOpen 內容也會改變,從而控制行事曆視窗。這種結構不僅在 SmartDate 控制項中適用,在基本的 DatePicker 控制項中也類似,因此對比 DatePicker 的開原始碼非常有益。

    下面是 SmartDate 控制項的樣版結構。

    SmartDate: ControlTemplate

    <ControlTemplateTargetType="{x:Type units:SmartDate}">
    <BorderBackground="{TemplateBinding Background}"
    BorderBrush="{TemplateBinding BorderBrush}"
    BorderThickness="{TemplateBinding BorderThickness}"CornerRadius="4">

    <Grid>
    <units:CalendarSwitchx:Name="PART_Switch"/>
    <Popupx:Name="PART_Popup"StaysOpen="False">
    <BorderBackground="{TemplateBinding Background}">
    <james:JamesGridRows="Auto,Auto,Auto"Columns="*">
    <james:JamesGridRows="*"Columns="Auto,*,Auto">
    <units:ChevronButtonx:Name="PART_Left"Tag="Left"/>
    <TextBlock style="{StaticResource Month style}"/>
    <units:ChevronButtonx:Name="PART_Right"Tag="Right"/>
    </james:JamesGrid>
    <UniformGridColumns="7">
    <units:DayOfWeekGrid.Column="0"Content="Su"/>
    <units:DayOfWeekGrid.Column="1"Content="Mo"/>
    <units:DayOfWeekGrid.Column="2"Content="Tu"/>
    <units:DayOfWeekGrid.Column="3"Content="We"/>
    <units:DayOfWeekGrid.Column="4"Content="Th"/>
    <units:DayOfWeekGrid.Column="5"Content="Fr"/>
    <units:DayOfWeekGrid.Column="6"Content="Sa"/>
    </UniformGrid>
    <units:CalendarBoxx:Name="PART_ListBox"/>
    </james:JamesGrid>
    </Border>
    </Popup>
    </Grid>
    </Border>
    </ControlTemplate>

    ControlTemplate 可以看到,包含了之前提到的所有元素。 Popup 作為基礎控制項使用, CalendarSwitch 是從 ToggleButton 繼承的行事曆切換按鈕。 CalendarBox 繼承自 ListBox ,用於選擇行事曆日期。

    其他組成部份包括用於切換到上一個月或下一個月的按鈕、顯示當前月份的 TextBlock 以及用於顯示星期幾的設計元素。

    非重用性內部專用 CustomControl

    SmartDate 控制項不僅可以獨立使用,也可以在樣版內部實作為 CustomControl 。並非所有 CustomControl 都以通用控制項為目的。 SmartDate 具有特定的用途,這在 WPF 架構中是很常見的。

    這種性質的控制項通常歸類為 ' Primitives ' 命名

    空間。 ToggleButton Thumb ScrollBar 等控制項通常在其他控制項的內部使用。

    基於這種 WPF 架構事實,可以看出 SmartDate 控制項的樣版設計與 WPF 基本模式沒有太大區別。

    理解 PART_ 控制項項及其作用

    CustomControl 結構中,程式碼與 XAML 之間沒有自動連線功能。兩者的互動完全依賴於 _PART 控制項。

    常用的 _PART 控制項包括:

  • PART_Switch

  • PART_ListBox

  • PART_Left

  • PART_Right

  • 這些控制項在 SmartDate 類的 OnApplyTemplate 方法中傳遞,處理按鈕事件、日期生成等所有必要操作。透過 OnApplyTemplate 接收的控制項名稱最好使用 PART_ 字首命名,以便在 XAML 中預見類內部的處理邏輯。

    SmartDate.cs 原始碼

    以下是包含 CustomControl 核心實作的 SmartDate.cs 類檔,特別重要的部份包括:

  • 聲明的 DependencyProperty

  • 透過 OnApplyTemplate 定義 PART_ 元素

  • 透過 SelectedDate 內容控制行事曆選擇邏輯

  • 使用 CalendarBox 的 SelectedItem/SelectedValue

  • CustomControl: SmartDate.cs

  • using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Controls.Primitives;
    using System.Windows.Data;
    using System.Windows.Documents;
    using System.Windows.Input;
    using System.Windows.Media;
    using System.Windows.Media.Imaging;
    using System.Windows.Navigation;
    using System.Windows.Shapes;
    namespaceSmartDateControl.UI.Units
    {
    public classSmartDate : Control
    {
    private Popup _popup;
    private CalendarSwitch _switch;
    private CalendarBox _listbox;
    publicbool KeepPopupOpen
    {
    get { return (bool)GetValue(KeepPopupOpenProperty); }
    set { SetValue(KeepPopupOpenProperty, value); }
    }
    publicstaticreadonly DependencyProperty KeepPopupOpenProperty =
    DependencyProperty.Register("KeepPopupOpen"typeof(bool), typeof(SmartDate), new PropertyMetadata(true));
    public DateTime CurrentMonth
    {
    get { return (DateTime)GetValue(CurrentMonthProperty); }
    set { SetValue(CurrentMonthProperty, value); }
    }
    publicstaticreadonly DependencyProperty CurrentMonthProperty =
    DependencyProperty.Register("CurrentMonth"typeof(DateTime), typeof(SmartDate), new PropertyMetadata(null));
    public DateTime? SelectedDate
    {
    get { return (DateTime?)GetValue(SelectedDateProperty); }
    set { SetValue(SelectedDateProperty, value); }
    }
    publicstaticreadonly DependencyProperty SelectedDateProperty =
    DependencyProperty.Register("SelectedDate"typeof(DateTime?), typeof(SmartDate), new PropertyMetadata(null));
    staticSmartDate()
    {
    Default styleKeyProperty.OverrideMetadata(typeof(SmartDate), new FrameworkPropertyMetadata(typeof(SmartDate)));
    }
    publicoverridevoidOnApplyTemplate()
    {
    base.OnApplyTemplate();
    _popup = (Popup)GetTemplateChild("PART_Popup");
    _switch = (CalendarSwitch)GetTemplateChild("PART_Switch");
    _listbox = (CalendarBox)GetTemplateChild("PART_ListBox");
    ChevronButton leftButton = (ChevronButton)GetTemplateChild("PART_Left");
    ChevronButton rightButton = (ChevronButton)GetTemplateChild("PART_Right");
    _popup.Closed += _popup_Closed;
    _switch.Click += _switch_Click;
    _listbox.MouseLeftButtonUp += _listbox_MouseLeftButtonUp;
    leftButton.Click += (s, e) => MoveMonthClick(-1);
    rightButton.Click += (s, e) => MoveMonthClick(1);
    }
    privatevoidMoveMonthClick(int month)
    {
    GenerateCalendar(CurrentMonth.AddMonths(month));
    }
    privatevoid _popup_Closed(object sender, EventArgs e)
    {
    _switch.IsChecked = IsMouseOver;
    }
    privatevoid _switch_Click(object sender, RoutedEventArgs e)
    {
    if (_switch.IsChecked == true)
    {
    _popup.IsOpen = true;
    GenerateCalendar(SelectedDate ?? DateTime.Now);
    }
    }
    privatevoid _listbox_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
    if (_listbox.SelectedItem is CalendarBoxItem selected)
    {
    SelectedDate = selected.Date;
    GenerateCalendar(selected.Date);
    _popup.IsOpen = KeepPopupOpen;
    }
    }
    privatevoidGenerateCalendar(DateTime current)
    {
    if (current.ToString("yyyyMM") == CurrentMonth.ToString("yyyyMM")) return;
    CurrentMonth = current;
    _listbox.Items.Clear();
    DateTime fDayOfMonth = new(current.Year, current.Month, 1);
    DateTime lDayOfMonth = fDayOfMonth.AddMonths(1).AddDays(-1);
    int fOffset = (int)fDayOfMonth.DayOfWeek;
    int lOffset = 6 - (int)lDayOfMonth.DayOfWeek;
    DateTime fDay = fDayOfMonth.AddDays(-fOffset);
    DateTime lDay = lDayOfMonth.AddDays(lOffset);
    for (DateTime day = fDay; day <= lDay; day = day.AddDays(1))
    {
    CalendarBoxItem boxItem = new();
    boxItem.Date = day;
    boxItem.DateFormat = day.ToString("yyyyMMdd");
    boxItem.Content = day.Day;
    boxItem.IsCurrentMonth = day.Month == current.Month;
    _listbox.Items.Add(boxItem);
    }
    if (SelectedDate != null)
    {
    _listbox.SelectedValue = SelectedDate.Value.ToString("yyyyMMdd");
    }
    }
    }
    }


















    首先,檢視 DependencyProperty ,包括最重要的 SelectedDate ,以及保持彈出視窗開啟的 KeepPopupOpen 內容和記錄當前月份的 CurrentMonth 內容。這些內容在基礎 DatePicker 中是不存在的。

    GenerateCalendar 方法包含了根據選擇日期生成新行事曆的邏輯。值得註意的是 Offset 計算部份。根據當前日期生成行事曆時包含前後月份的日期,這部份邏輯是行事曆生成的關鍵。

    DateTime fDayOfMonth = new(current.Year, current.Month, 1);
    DateTime lDayOfMonth = fDayOfMonth.AddMonths(1).AddDays(-1);
    int fOffset = (int)fDayOfMonth.DayOfWeek;
    int lOffset = 6 - (int)lDayOfMonth.DayOfWeek;
    DateTime fDay = fDayOfMonth.AddDays(-fOffset);
    DateTime lDay = lDayOfMonth.AddDays(lOffset);

    在事件處理方式上,使用 MouseLeftButtonUp 處理行事曆選擇事件,匹配一般按鈕點選操作。相比 SelectionChanged 事件,選擇相同值時不會觸發事件,這樣的處理方式更為適合。

    ToggleButton IsChecked Popup IsOpen 及其關閉相關的互動透過事件實作。這些復雜的互動最好透過實際實作進行學習。

    關於擴充套件實作

    這個應用程式是為教程制作的程式碼,可以透過添加功能進行擴充套件。比如添延長間選擇功能或手動更改值。也可以根據客戶需求實作自訂行事曆顯示。

    SmartDate 實作的 WPF 教程視訊及源碼介紹

    SmartDate 控制項的全部實作過程可以透過 BiliBili 視訊檢視,也可以在 GitHub 上找到。這些視訊時長約 50 分鐘,制作耗時 近 1 個月。作為高品質的免費教學資源,建議大家花足夠的時間慢慢反復練習和學習。

    溝通與支持

    我們隨時保持溝通渠道開放。大家可以透過以下方式與我們互動:

  • GitHub [3] : 關註、Fork、Stars

  • BiliBili [4] : 一鍵三連

  • 信箱: [email protected]

  • 參考資料

    [1]

    源碼連結: https://github.com/vickyqu115/smartdate

    [2]

    教學視訊: https://bit.ly/3xI9DNh

    [3]

    GitHub: https://github.com/vickyqu115/smartdate

    [4]

    BiliBili: https://bit.ly/3xI9DNh