实现现代化 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
。
<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] : 一键三连
参考资料
[1]
源码链接:
https://github.com/vickyqu115/smartdate
教学视频:
https://bit.ly/3xI9DNh
GitHub:
https://github.com/vickyqu115/smartdate
BiliBili:
https://bit.ly/3xI9DNh