
简介这是一份面向WPF初中级开发者的日历控件优化与自定义日期时间选择器实现包。针对WPF默认日历无法缩放、不能直接选择时间的痛点作者重写了Calendar样式并设计出独立的DateTimePicker控件可调整尺寸、日期与时间同选注释详细方便应用到桌面项目或作为学习控件模板的范例。包体共59个文件压缩包约152KB核心代码以23个cs逻辑文件、5个xaml界面文件为主辅以baml编译资源、exe可执行程序、pdb调试符号及config配置等移除冗余后结构紧凑便于直接按需移植。目前已有975人浏览学习。开发者下载后可获得整套完整可运行的WPF工程既能直接复制控件样式与依赖属性逻辑也能参照注释理解模板绑定、触发器、路由事件等关键知识点快速实现带时间选择的日历交互。1. 先问一个问题为什么默认的DatePicker不够用做WPF上位机或者业务管理系统的时候我经常遇到这样的需求排产计划要精确到分钟工单要记录「多久开始、多久结束」值班表要写交接时间。默认的DatePicker只能选日期选完还得在旁边再放一个TextBox手动敲时间或者堆两个ComboBox分别选时和分。操作者每天要点几十次页面这种割裂的交互体验非常影响效率而且手动输入时间还容易敲错格式。这篇博文围绕「Calendar样式」和「自定义DatePicker控件」展开解决的核心问题是把日期和时间的选择做进同一个控件里同时把Calendar的默认样式重写成实际项目里能用的样子。覆盖的内容包括WPF模板机制的原理、控件模板重写、依赖属性设计、MVVM对接以及几个真实场景里一定会踩的坑。适合正在用WPF做上位机界面、桌面工具或者内部管理系统的开发人员。2. Calendar样式定制先拆开模板再动手改2.1 先认识Calendar内部这几个部件WPF的Calendar不是一个大而全的封闭控件它是一个组合控件内部层级大致是Calendar本身然后是一个CalendarItem负责整体布局、月份标题、上下翻页再往下是七列六行的网格布局每个格子是一个CalendarDayButton顶部的月份和年份切换按钮是CalendarButton。搞清楚这个层级很重要因为样式定制通常不是改Calendar整体而是分别改这几个部件各自的ControlTemplate。部件类职责样式定制的目标Calendar对外暴露SelectedDate、DisplayDate等属性基本不动CalendarItem整体面板布局包含NavigationButton、网格改边框、阴影、圆角CalendarDayButton单个日期格子改选中态、悬停态、今日标记CalendarButton月份/年份切换按钮改字体、 hover效果绝大多数项目里真正需要动手的是CalendarDayButton。比如一个深色主题的上位机项目默认那个蓝色圆底高亮选中日期会显得非常突兀这时候就需要把选中样式换成和整体UI匹配的配色。2.2 用ControlTemplate重画日期按钮的一个最小例子下面是给CalendarDayButton换皮肤最常见的一种写法。目标是把默认的方块按钮改成圆角矩形鼠标悬停时有个浅色背景选中那天用主题色填充今天用描边标记。Style TargetTypeCalendarDayButton Setter PropertyTemplate Setter.Value ControlTemplate TargetTypeCalendarDayButton Grid Border x:NameDayBorder CornerRadius4 BackgroundTransparent BorderBrushTransparent BorderThickness1 ContentPresenter x:NameDayContent HorizontalAlignmentCenter VerticalAlignmentCenter/ /Border /Grid ControlTemplate.Triggers Trigger PropertyIsMouseOver ValueTrue Setter TargetNameDayBorder PropertyBackground Value#220080D0/ /Trigger Trigger PropertyIsSelected ValueTrue Setter TargetNameDayBorder PropertyBackground Value#FF0080D0/ Setter TargetNameDayContent PropertyTextElement.Foreground ValueWhite/ /Trigger Trigger PropertyIsToday ValueTrue Setter TargetNameDayBorder PropertyBorderBrush Value#FFFF8C00/ Setter TargetNameDayBorder PropertyBorderThickness Value1/ /Trigger /ControlTemplate.Triggers /ControlTemplate /Setter.Value /Setter /Style这段模板把按钮的内容区域全部交给ContentPresenter去展示日期数字触发器的层级是鼠标悬停、选中、今日标记。这里有个细节IsToday和IsSelected同时成立时后面定义的触发器会覆盖前面的所以选中那天的橙色边框会被丢掉。要同时保留的话需要用MultiTrigger来组合处理或者把IsToday触发条件改成IsToday AND NOT IsSelected。提示CalendarDayButton的IsToday、IsSelected、IsInactive都是只读依赖属性。IsInactive表示该按钮属于上个月或下个月的补位日期通常会把它置灰。默认模板里有个IsInactive触发器把字体颜色改成灰色重写模板时如果不补上补位日期看起来会和当月日期一样清晰交互上容易让人困惑。2.3 ItemContainerStyle和Calendar样式的关系CalendarDayButton的样式可以直接放在Calendar的Style里也可以通过ItemContainerStyle间接控制。区别在于直接设置CalendarDayButton的Style是整个应用程序级别的影响所有用到Calendar的地方ItemContainerStyle只影响当前这个Calendar实例。Calendar Calendar.ItemContainerStyle Style TargetTypeCalendarDayButton !-- 同上 -- /Style /Calendar.ItemContainerStyle /Calendar项目里如果只有一两个页面用Calendar用ItemContainerStyle收窄影响范围更稳不会出现「改了一处另一个项目的日历全变样」的事故。如果整个上位机系统统一换肤那就把Style提到App.xaml的资源字典里全局生效。2.4 定制Calendar样式时容易忽略的3个参数Calendar本身有几个影响交互行为的属性平时容易被忽略但在实际项目里作用非常大。DisplayDateStart / DisplayDateEnd限制可选日期的范围。排产系统里一般只允许选未来30天这两个属性比在代码里拦截SelectedDateChanged更干净。DisplayMode控制日历的显示层级默认是Month可以切换到Decade。自定义日期时间控件时不要动它用默认Month就行。SelectionMode默认SingleRange。如果只需要单选日期显式设成SingleDate否则代码里判断SelectedDate是否为空会多一层分支。注意DisplayDateStart限制了可选范围但用户仍然可以通过键盘输入或者代码赋值突破这个限制。要做硬性校验还得在SelectedDateChanged事件里兜底。3. 自定义DatePicker把时间选择嵌进弹层3.1 三种实现路线怎么选看完Calendar的样式定制接下来就是标题里的核心任务做一个能选日期和时间的选择器。我在实际项目里用过三种做法各有适用场景。方案实现方式优点缺点适用场景继承DatePicker重写ControlTemplate在弹层里加时间选择区保留原交互习惯改动小模板细节多需要懂DatePicker内部结构大多数业务界面附加属性给DatePicker挂一个Time属性通过附加行为扩展不改控件类MVVM友好逻辑分散时间选择UI仍需额外实现已有大量DatePicker代码的老项目UserControl组合把TextBoxCalendarComboBox拼成一个新控件完全可控交互割裂没有统一弹层原型验证、内部工具我一般会选择继承DatePicker 重写模板的方案。原因很直接用户已经习惯「点一下输入框、弹出日历、选了日期就完事」的心智模型基于这个模型去加时间选择学习成本最低。而且DatePicker对外暴露的SelectedDate、DisplayDate、BlackoutDates等属性全部可以复用不用重新设计一套API。3.2 DateTimePicker的自定义模板骨架下面是一个最小可用的ControlTemplate示例我在注释里标出了每个关键部件。实际项目里时间部分通常用两个数字框或者ComboBox这里用ComboBox展示清晰的纵向布局方便讲清楚绑定和事件关系。Style TargetTypelocal:DateTimePicker Setter PropertyTemplate Setter.Value ControlTemplate TargetTypelocal:DateTimePicker Grid Grid.ColumnDefinitions ColumnDefinition Width*/ ColumnDefinition WidthAuto/ /Grid.ColumnDefinitions !-- 输入框直接显示 2026-02-03 14:30 这种完整格式 -- TextBox x:NamePART_TextBox Grid.Column0 VerticalContentAlignmentCenter Text{TemplateBinding Text} TextAlignmentLeft/ !-- 右侧下拉按钮保持和原DatePicker一致的视觉 -- ToggleButton x:NamePART_Button Grid.Column1 Width20 FocusableFalse Path DataM0,0L4,4L8,0Z FillGray HorizontalAlignmentCenter VerticalAlignmentCenter/ /ToggleButton !-- 弹层日期区 时间区 -- Popup x:NamePART_Popup PlacementBottom PlacementTarget{Binding ElementNamePART_TextBox} AllowsTransparencyTrue StaysOpenFalse Border Padding8 BackgroundWhite BorderBrush#FFDDDDDD BorderThickness1 CornerRadius4 StackPanel Calendar x:NamePART_Calendar HorizontalAlignmentLeft SelectionModeSingleDate/ Grid Margin0,8,0,0 Grid.ColumnDefinitions ColumnDefinition Width*/ ColumnDefinition WidthAuto/ /Grid.ColumnDefinitions StackPanel Grid.Column0 OrientationHorizontal ComboBox x:NamePART_Hour Width64 ItemsSource{Binding HourItems} SelectedItem{Binding SelectedHour}/ TextBlock Text: Margin6,0/ ComboBox x:NamePART_Minute Width64 ItemsSource{Binding MinuteItems} SelectedItem{Binding SelectedMinute}/ /StackPanel Button Grid.Column1 Content确定 Margin16,0,0,0 Padding16,4 ClickOnConfirmClick/ /Grid /StackPanel /Border /Popup /Grid /ControlTemplate /Setter.Value /Setter /Style注意看几个关键参数Popup的StaysOpenFalse意味着点击弹层外部时自动关闭但这同时带来一个麻烦用户在ComboBox里滚动选择时间时弹层可能因为失焦而关闭。这是我做这个控件时踩的坑之一。PlacementTarget绑定到TextBox而不是ToggleButton这样弹层位置和输入框对齐视觉上是同一个整体。ComboBox的ItemsSource用HourItems和MinuteItems这两个属性在code-behind里初始化40个元素用集合绑定比硬写40个ComboBoxItem要干净得多。3.3 依赖属性SelectedDateTime和联动逻辑控件类直接继承DatePicker新增一个SelectedDateTime依赖属性用来承载「日期 时间」的完整值。DatePicker本身的SelectedDate属性只存日期部分时间部分永远为00:00:00所以不能直接复用必须在两者之间做同步。public class DateTimePicker : DatePicker { public static readonly DependencyProperty SelectedDateTimeProperty DependencyProperty.Register( nameof(SelectedDateTime), typeof(DateTime?), typeof(DateTimePicker), new FrameworkPropertyMetadata( null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedDateTimeChanged)); public DateTime? SelectedDateTime { get (DateTime?)GetValue(SelectedDateTimeProperty); set SetValue(SelectedDateTimeProperty, value); } protected override void OnSelectedDateChanged(SelectionChangedEventArgs e) { base.OnSelectedDateChanged(e); if (SelectedDateTime?.Date ! SelectedDate) { var time SelectedDateTime?.TimeOfDay ?? TimeSpan.Zero; SelectedDateTime SelectedDate?.Add(time); } } private static void OnSelectedDateTimeChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var picker (DateTimePicker)d; var newValue (DateTime?)e.NewValue; if (picker.SelectedDate ! newValue?.Date) { picker.SelectedDate newValue?.Date; } picker.Text newValue?.ToString(yyyy-MM-dd HH:mm) ?? string.Empty; } }这段代码的逻辑是外部给SelectedDateTime赋值会联动更新SelectedDate和显示文本用户在模板Calendar里改了日期OnSelectedDateChanged触发保留之前的小时和分钟信息重新拼进SelectedDateTime。这样保证两个属性之间不会互相覆盖。注意这里有个容易被忘掉的点TextBox允许用户直接输入字符。日期时间格式的解析不能再依赖DatePicker内置的Parse逻辑因为默认只接受yyyy/M/d这种纯日期格式。常见的做法是在TextBox的LostFocus事件或者TextChanged事件里用DateTime.TryParse尝试解析解析失败则恢复原Text。private void OnTextBoxLostFocus(object sender, RoutedEventArgs e) { var textBox (TextBox)sender; if (DateTime.TryParse(textBox.Text, out var parsed)) { SelectedDateTime parsed; textBox.Text parsed.ToString(yyyy-MM-dd HH:mm); } else { textBox.Text SelectedDateTime?.ToString(yyyy-MM-dd HH:mm) ?? string.Empty; } }LostFocus的好处是不会在用户输入到一半时就打断比如输入2026-02-03 14:还没敲完TextChanged触发解析必定失败反而造成光标跳动。用LostFocus的话用户在输入过程中不会被打扰只有离开输入框时做一次校验交互上顺很多。3.4 时间显示和快捷选择的取舍ComboBox选时间的缺点是操作路径长展开小时列表、移动鼠标、点击、再展开分钟列表。对于频繁调整时间的场景我见过两种更顺手的变体。用两个TextBox手动输入时间回车确认。代码逻辑和上面LostFocus解析基本一致胜在键盘操作最快适合排产工单编号那种「今天下午3点24分」的输入场景。加一组快捷按钮此刻、半小时后、1小时后。这在交接班、定时任务这类场景里非常实用。快捷按钮的实现非常简单给按钮Click事件里直接给SelectedDateTime赋DateTime.Now这样的值就行不需要额外依赖属性。Button Content此刻 Margin8,0,0,0 ClickOnNowClick/private void OnNowClick(object sender, RoutedEventArgs e) { SelectedDateTime DateTime.Now; }4. 日期时间联动规则与MVVM接入4.1 这个控件在MVVM里怎么用WPF上位机项目普遍在用MVVM控件开发时最忌讳的就是把ViewModel的逻辑写进Code-behind。DateTimePicker对外暴露的SelectedDateTime是依赖属性天然支持数据绑定所以ViewModel里的使用方式和原来的DatePicker几乎没有区别。public class ShiftPlanViewModel : ObservableObject { private DateTime? _planTime DateTime.Now; public DateTime? PlanTime { get _planTime; set { if (_planTime ! value) { _planTime value; OnPropertyChanged(nameof(PlanTime)); // 联动当时间变化时重新计算该工位最近一小时的空闲状态 RefreshPlanSuggestions(); } } } }XAML里直接绑定local:DateTimePicker SelectedDateTime{Binding PlanTime, ModeTwoWay}/这句绑定里有两个要点。第一因为SelectedDateTime注册时已经指定了BindsTwoWayByDefaultModeTwoWay可写可不写但写上能让后来维护的人一眼看懂。第二当用户在弹层里改小时或分钟时如果时间值变了需要有一条路径更新到ViewModel——如果时间部分也是绑定的数据源这个更新是自动的如果时间部分用的独立属性就需要在时间改变的事件回调里拼接日期并赋给SelectedDateTime。4.2 联动边界哪些值得做成可配置参数日期时间控件不会凭空工作两个核心联动规则必须处理好我建议直接做成可绑定的属性方便不同项目复用。参数默认值作用典型场景设置AutoCloseOnDateSelectfalse在日历里选完日期后是否立刻关闭弹层只选日期时设true需要同时选时间时设falseTimeStepMinutes1分钟列表的步进间隔交接班场景设15减少选择项DefaultTime当前时间选中日期后如果没有时间可用默认填什么排产系统可能固定填08:00ShowNowButtontrue显示此刻快捷按钮时间不是关键数据的界面可以隐藏AutoCloseOnDateSelect这个参数值得多说两句。如果把它设为true用户点完日期弹层就关那时间就没机会选了。反过来设为false用户点完日期还要再点一次确定才行。真正的联动逻辑是当时间区没被修改过时选完日期立即关闭当用户已经手动调整过时间就不关闭给二次确认的机会。这个规则用挂接Calendar的SelectedDatesChanged事件实现注意要避免和OnSelectedDateChanged的联动冲突。4.3 键盘导航和错误输入的兜底自定义模板后键盘交互很容易残废。原DatePicker支持F4打开弹层、Esc关闭、Enter确认这些习惯在改模板后通常会丢掉需要在代码里手动补回来。挂接在TextBox上的KeyDown事件处理三个键。private void OnTextBoxKeyDown(object sender, KeyEventArgs e) { if (e.Key Key.F4 || ((e.Key Key.Down) (Keyboard.Modifiers ModifierKeys.Alt) ModifierKeys.Alt)) { PopupOpen(); e.Handled true; } else if (e.Key Key.Escape) { PopupClose(); e.Handled true; } else if (e.Key Key.Enter) { CommitInput(); PopupClose(); e.Handled true; } }这里的关键是代码和模板的分工模板只负责呈现键盘事件全部在控件类里统一处理。PopUp的打开关闭不能直接操作模板里的命名元素需要用GetTemplateChild(PART_Popup)拿到引用再控制。5. 边界条件、常见坑与性能注意点5.1 日期时间控件的边界测试用例把控件交给测试人员之前下面这几类边界条件值得先用代码过一遍。用例输入期望结果空值SelectedDateTime设为nullText清空不报错当天时间当前日期23:59显示yyyy-MM-dd 23:59不再自动跳到次日跨月日期是1月31日手动把月份改成2月自动归为2月28日或29日非法文本TextBox输入abc后失焦恢复为原值时间溢出小时输入25解析失败恢复原值闰年2024-02-29日期正常选中时间联动保留第二个用例是最容易被忽略的。原DatePicker中直接给SelectedDate赋值一个带时间的DateTime时间部分会被截断成00:00:00。自定义控件里如果不在OnSelectedDateChanged中保留TimeOfDay就会出现用户明明选了14:30打开弹层日期对但时间变成00:00的诡异现象。5.2 Popup性能和模板缓存弹层里的Calendar每次打开都重新布局在高端机器上感觉不到但在配置一般的工控机上会有明显卡顿。两个优化手段比较有效。第一把模板中静态的视觉资源全部放进ResourceDictionary并调用Freeze()冻结。SolidColorBrush和DrawingBrush这类Freezable对象冻结后WPF可以跨线程共享并跳过变更检查。var brush new SolidColorBrush(Color.FromRgb(0x00, 0x80, 0xD0)); brush.Freeze();第二避免在CalendarDayButton模板里使用过于复杂的Effect比如DropShadowEffect。Effect会触发软件渲染路径每个日期按钮都带阴影的话整个Calendar的渲染开销会翻好几倍。VisualBrush、OpacityMask这些也应该避免出现在高频重绘的模板中。5.3 用UI自动化做一次冒烟验证手工测试弹层交互比较繁琐可以写一个UI Automation的小脚本覆盖「打开弹层 - 选日期 - 改时间 - 点确定 - 断言SelectedDateTime」这个主流程。var picker new DateTimePicker { SelectedDateTime new DateTime(2026, 3, 1, 8, 0, 0) }; picker.ApplyTemplate(); var popup (Popup)picker.Template.FindName(PART_Popup, picker); Assert.False(popup.IsOpen); // 模拟操作者点击日期 picker.SelectedDate new DateTime(2026, 3, 5); Assert.Equal(new DateTime(2026, 3, 5, 8, 0, 0), picker.SelectedDateTime); // 模拟调整时间 picker.SelectedDateTime new DateTime(2026, 3, 5, 14, 30, 0); Assert.Equal(2026-03-05 14:30, picker.Text); // 非法输入回滚 picker.Text not-a-date; picker.RaiseEvent(new RoutedEventArgs(TextBox.LostFocusEvent)); Assert.Equal(2026-03-05 14:30, picker.Text);这个测试脚本验证了核心联动逻辑和文本回滚机制。由于没有UI线程的交互直接在单元测试里跑也完全可行。要覆盖弹层的打开关闭、ComboBox选择联动则需要配合STA线程启动一个真实的Dispatcher运行环境。你先拿这个最小验证撑住主流程再往里面加矩阵式的边界测试排期紧张时也能保证迭代不回退。本文还有配套的精品资源点击获取