实现现代化 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
由两个项目组成:
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
?。由于默认值可以为空,因此声明为 able
类型,用于指定通过日历选择的日期值。
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());
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());
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 != )
{
_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]: 一键三连 邮箱: james@jamesnet.dev
参考资料
源码链接: https://github.com/vickyqu115/smartdate
[2]教学视频: https://bit.ly/3xI9DNh
[3]GitHub: https://github.com/vickyqu115/smartdate
[4]BiliBili: https://bit.ly/3xI9DNh