认识和解析 WPF 基本控件 DatePicker 的问题,并通过 CustomControl 实现新的控件:SmartDate

控件名称:SmartDate
作者:Vicky&James
源码链接:https://github.com/vickyqu115/smartdate
教学视频(【小李趣味多】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。

<Window x: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">
    <Viewbox Width="500">
        <UniformGrid Margin="20" Columns="1" VerticalAlignment="Top">
            <smart:SmartDate SelectedDate="{Binding Created}"/>
            <DatePicker SelectedDate="{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

<ControlTemplate TargetType="{x:Type units:SmartDate}">
    <Border Background="{TemplateBinding Background}"
            BorderBrush="{TemplateBinding BorderBrush}"
            BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4">
        <Grid>
            <units:CalendarSwitch x:Name="PART_Switch"/>
            <Popup x:Name="PART_Popup" StaysOpen="False">
                <Border Background="{TemplateBinding Background}">
                    <james:JamesGrid Rows="Auto,Auto,Auto" Columns="*">
                        <james:JamesGrid Rows="*" Columns="Auto,*,Auto">
                            <units:ChevronButton x:Name="PART_Left" Tag="Left"/>
                            <TextBlock Style="{StaticResource MonthStyle}"/>
                            <units:ChevronButton x:Name="PART_Right" Tag="Right"/>
                        </james:JamesGrid>
                        <UniformGrid Columns="7">
                            <units:DayOfWeek Grid.Column="0" Content="Su"/>
                            <units:DayOfWeek Grid.Column="1" Content="Mo"/>
                            <units:DayOfWeek Grid.Column="2" Content="Tu"/>
                            <units:DayOfWeek Grid.Column="3" Content="We"/>
                            <units:DayOfWeek Grid.Column="4" Content="Th"/>
                            <units:DayOfWeek Grid.Column="5" Content="Fr"/>
                            <units:DayOfWeek Grid.Column="6" Content="Sa"/>
                        </UniformGrid>
                        <units:CalendarBox x: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;

namespace SmartDateControl.UI.Units
{
    public class SmartDate : Control
    {
        private Popup _popup;
        private CalendarSwitch _switch;
        private CalendarBox _listbox;

        public bool KeepPopupOpen
        {
            get { return (bool)GetValue(KeepPopupOpenProperty); }
            set { SetValue(KeepPopupOpenProperty, value); }
        }

        public static readonly DependencyProperty KeepPopupOpenProperty =
            DependencyProperty.Register("KeepPopupOpen", typeof(bool), typeof(SmartDate), new PropertyMetadata(true));

        public DateTime CurrentMonth
        {
            get { return (DateTime)GetValue(CurrentMonthProperty); }
            set { SetValue(CurrentMonthProperty, value); }
        }

        public static readonly DependencyProperty CurrentMonthProperty =
            DependencyProperty.Register("CurrentMonth", typeof(DateTime), typeof(SmartDate), new PropertyMetadata(null));

        public DateTime? SelectedDate
        {
            get { return (DateTime?)GetValue(SelectedDateProperty); }
            set { SetValue(SelectedDateProperty, value); }
        }

        public static readonly DependencyProperty SelectedDateProperty =
            DependencyProperty.Register("SelectedDate", typeof(DateTime?), typeof(SmartDate), new PropertyMetadata(null));

        static SmartDate()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(SmartDate), new FrameworkPropertyMetadata(typeof(SmartDate)));
        }
        public override void OnApplyTemplate()
        {
            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);
        }

        private void MoveMonthClick(int month)
        {
            GenerateCalendar(CurrentMonth.AddMonths(month));
        }

        private void _popup_Closed(object sender, EventArgs e)
        {
            _switch.IsChecked = IsMouseOver;
        }

        private void _switch_Click(object sender, RoutedEventArgs e)
        {
            if (_switch.IsChecked == true)
            {
                _popup.IsOpen = true;
                GenerateCalendar(SelectedDate ?? DateTime.Now);
            }
        }

        private void _listbox_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
        {
            if (_listbox.SelectedItem is CalendarBoxItem selected)
            {
                SelectedDate = selected.Date;
                GenerateCalendar(selected.Date);
                _popup.IsOpen = KeepPopupOpen;
            }
        }
        private void GenerateCalendar(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: 关注、Fork、Stars
  • BiliBili: 一键三连
  • 邮箱: james@jamesnet.dev
  • 25
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值