实现现代化 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派生的新的CustomControlSmartDate,而不是使用现有的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/SelectedValueCustomControl: 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
参考资料
[1]
源码链接:
https://github.com/vickyqu115/smartdate
[2]
教学视频: https://bit.ly/3xI9DNh
[3]
GitHub: https://github.com/vickyqu115/smartdate
[4]
BiliBili: https://bit.ly/3xI9DNh