简介:本资源是一套面向WPF桌面开发者的时间选择器实现方案,仿照ElementUI中TimePicker的交互与视觉风格,用C#与XAML在WPF环境下完成同类组件。适合已掌握WPF基础、希望深入理解MVVM模式与数据绑定的中初级开发者,可用于表单录入、日程管理、后台系统等需要时间选择的场景。压缩包共1267个文件,约918KB,以415个cs源码文件为核心,配合3个xaml界面文件、1个sln解决方案与1个csproj工程文件,另有dll、pdb、baml等编译产物及缓存文件,结构完整可直接打开运行。已有424人学习下载。资源围绕ViewModel中的DateTime属性、INotifyPropertyChanged通知、TextBox与Button的XAML布局、自定义对话框及Slider调节小时分钟等环节展开,读者可据此掌握组件封装、数据绑定与事件处理的完整思路,并在此基础上扩展样式与动画,贴近ElementUI的使用体验。
1. 从一次翻车说起:为什么 WPF 需要一个像样的 TimePicker
去年帮某实验室做一套设备排程工具,界面用 WPF 写,需求里有一句“选择每天的开机时间段”。我第一反应是拿两个 ComboBox 拼小时和分钟,结果测试当天就被打回:跨零点怎么选、分钟要不要按 5 分钟步进、键盘能不能直接敲“08:30”、清空后算不算合法值,全都没处理。翻遍 WPF 自带控件,DatePicker 有,TimePicker 却没有,社区里能搜到的实现要么只支持 24 小时制、要么把样式写死在代码里改不动。后来我把 ElementUI 的 TimePicker 交互逻辑拆了一遍,用 WPF 重写了一个,才算把这块补上。这篇就把这套 WPF 版 TimePicker 的完整方案摊开讲:需求边界怎么定、控件结构怎么拆、依赖属性怎么设计、下拉面板怎么画、键盘和校验怎么兜底,最后怎么验收。适合正在做 WPF 桌面端毕设或课程设计、需要一套可复用时间选择控件的同学,也适合手上项目里正缺这个轮子的开发者直接参考。
2. 需求拆解与技术选型:先想清楚要仿哪些行为
2.1 从 ElementUI 抄什么、不抄什么
ElementUI 的 TimePicker 有几个核心行为值得照搬:输入框可直接键入、下拉面板提供时/分/秒三列滚动选择、支持format与value-format分离、支持picker-options限制可选范围、支持clearable一键清空。这些是交互契约,跟 Web 还是桌面无关,照搬过来用户上手成本最低。
但有几处必须改。Web 端靠 CSS 做滚动吸附,WPF 里没有等价物,得用ListBox配合ScrollViewer手动算偏移;Web 端失焦即校验,WPF 里焦点在弹出层和主窗口之间跳,直接照搬会误触发校验,得改成“面板关闭时统一校验”。另外 ElementUI 默认 12 小时制带 AM/PM,桌面工具场景里 24 小时制更常见,所以我把制式做成可配置项,默认 24 小时。
选型上,控件基类用Control而不是UserControl。原因很直接:Control支持Template,样式和逻辑彻底分离,别人拿到这套控件可以只换Style不改一行 C#;UserControl会把视觉树焊死,复用性差一截。弹出层用Popup,不用ToolTip或自绘Adorner,因为Popup自带StaysOpen、Placement和焦点管理,是 WPF 里做下拉面板最省事的选择。
2.2 功能清单与边界
把需求收敛成一张表,后面所有设计都对着它做,避免写着写着加戏。
| 功能项 | 是否支持 | 说明 |
|---|---|---|
| 24/12 小时制 | 支持 | 通过Is24Hour依赖属性切换 |
| 时/分/秒选择 | 支持 | 秒列可通过ShowSeconds隐藏 |
| 手动键入 | 支持 | 失焦或回车时解析 |
| 步进选择 | 支持 | MinuteStep、SecondStep可配 |
| 范围限制 | 支持 | MinTime、MaxTime |
| 清空 | 支持 | IsClearable控制是否显示清空按钮 |
| 只读 | 支持 | IsReadOnly时只允许面板选择 |
| 跨零点 | 不支持 | 单点时间选择,跨天由业务层处理 |
边界划清楚很重要。跨零点这种需求本质是时间段而不是时间点,硬塞进 TimePicker 会让控件职责膨胀,正确做法是业务层用两个 TimePicker 拼。这一点在答辩时也常被问到,提前想好说辞。
2.3 依赖属性设计
控件的对外接口全靠依赖属性暴露,这是 WPF 自定义控件的标准做法,也是这套方案能被别人直接引用的关键。核心属性如下:
public class TimePicker : Control { // 当前选中时间,双向绑定 public static readonly DependencyProperty SelectedTimeProperty = DependencyProperty.Register( nameof(SelectedTime), typeof(TimeSpan?), typeof(TimePicker), new FrameworkPropertyMetadata( null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedTimeChanged)); // 显示格式,如 "HH:mm:ss" public static readonly DependencyProperty FormatProperty = DependencyProperty.Register( nameof(Format), typeof(string), typeof(TimePicker), new PropertyMetadata("HH:mm:ss")); // 是否 24 小时制 public static readonly DependencyProperty Is24HourProperty = DependencyProperty.Register( nameof(Is24Hour), typeof(bool), typeof(TimePicker), new PropertyMetadata(true)); // 分钟步进,默认 1 public static readonly DependencyProperty MinuteStepProperty = DependencyProperty.Register( nameof(MinuteStep), typeof(int), typeof(TimePicker), new PropertyMetadata(1, null, CoerceStep)); // 可选范围下限 public static readonly DependencyProperty MinTimeProperty = DependencyProperty.Register( nameof(MinTime), typeof(TimeSpan?), typeof(TimePicker), new PropertyMetadata(null)); // 可选范围上限 public static readonly DependencyProperty MaxTimeProperty = DependencyProperty.Register( nameof(MaxTime), typeof(TimeSpan?), typeof(TimePicker), new PropertyMetadata(null)); // 省略其余属性注册... }SelectedTime用TimeSpan?而不是DateTime,因为时间选择器只关心一天内的时刻,用DateTime会引入无意义的日期部分,绑定到业务模型时还得手动截断。可空类型是为了表达“未选择”状态,比用DateTime.MinValue当哨兵值干净得多。
MinuteStep和SecondStep用CoerceValueCallback做强制约束,防止有人传 0 或负数导致除零。MinTime/MaxTime用可空TimeSpan,不设就是不限制。这几个属性在OnSelectedTimeChanged里会联动触发校验,超出范围的值会被回退到边界值,而不是静默丢弃——静默丢弃是排查噩梦,用户敲了值没反应却不知道为啥,这种玄学问题最耗时间。
3. 控件结构与模板实现:把视觉树拆成可替换的零件
3.1 模板部件契约
自定义控件用TemplatePart特性声明模板里必须存在的部件,这样换肤时缺了哪个部件,运行时能立刻报出来,而不是等用户点到某个按钮才发现空引用。
[TemplatePart(Name = PartTextBox, Type = typeof(TextBox))] [TemplatePart(Name = PartDropDownButton, Type = typeof(Button))] [TemplatePart(Name = PartClearButton, Type = typeof(Button))] [TemplatePart(Name = PartPopup, Type = typeof(Popup))] [TemplatePart(Name = PartHourList, Type = typeof(ListBox))] [TemplatePart(Name = PartMinuteList, Type = typeof(ListBox))] [TemplatePart(Name = PartSecondList, Type = typeof(ListBox))] public class TimePicker : Control { private const string PartTextBox = "PART_TextBox"; private const string PartDropDownButton = "PART_DropDownButton"; private const string PartClearButton = "PART_ClearButton"; private const string PartPopup = "PART_Popup"; private const string PartHourList = "PART_HourList"; private const string PartMinuteList = "PART_MinuteList"; private const string PartSecondList = "PART_SecondList"; public override void OnApplyTemplate() { base.OnApplyTemplate(); // 先解绑旧部件,防止模板切换时事件重复挂载 DetachParts(); _textBox = GetTemplateChild(PartTextBox) as TextBox; _popup = GetTemplateChild(PartPopup) as Popup; // 其余部件获取... AttachParts(); } }OnApplyTemplate里先解绑再绑定这一步很多人会漏。模板在运行时被替换(比如切换主题)时,旧部件的事件处理器还挂在旧对象上,新部件又挂一遍,结果一次点击触发两次逻辑。这个坑我在一个换肤功能里踩过,表现是时间偶尔跳两格,查了半天才定位到。
3.2 下拉面板的 XAML 结构
面板用三列ListBox并排,每列外面套ScrollViewer。这里不用ItemsControl是因为ListBox自带选中态和键盘导航,省掉一堆手写逻辑。
<Popup x:Name="PART_Popup" PlacementTarget="{Binding RelativeSource={RelativeSource TemplatedParent}}" Placement="Bottom" StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade"> <Border Background="{DynamicResource TimePickerPopupBackground}" BorderBrush="{DynamicResource TimePickerBorderBrush}" BorderThickness="1" CornerRadius="4" Padding="4"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="60"/> <ColumnDefinition Width="60"/> <ColumnDefinition Width="60"/> </Grid.ColumnDefinitions> <ListBox x:Name="PART_HourList" Grid.Column="0" ItemsSource="{Binding Hours, RelativeSource={RelativeSource TemplatedParent}}" SelectedItem="{Binding SelectedHour, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"/> <ListBox x:Name="PART_MinuteList" Grid.Column="1" ItemsSource="{Binding Minutes, RelativeSource={RelativeSource TemplatedParent}}" SelectedItem="{Binding SelectedMinute, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"/> <ListBox x:Name="PART_SecondList" Grid.Column="2" Visibility="{Binding ShowSeconds, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource BoolToVisibility}}" ItemsSource="{Binding Seconds, RelativeSource={RelativeSource TemplatedParent}}" SelectedItem="{Binding SelectedSecond, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"/> </Grid> </Border> </Popup>StaysOpen="False"让点击面板外部自动关闭,这是下拉控件的基本预期。AllowsTransparency="True"配合CornerRadius才能出圆角,否则Popup默认是直角矩形,圆角会被裁掉。PopupAnimation="Fade"给个淡入,比硬切自然。
三列ListBox的ItemsSource绑到控件的Hours、Minutes、Seconds集合上。这三个集合不是固定 0-59,而是根据MinuteStep、SecondStep和范围限制动态生成。比如MinuteStep=5时,分钟列只有 0、5、10……55 这 12 项。集合在属性变更时重建,重建后要把当前选中值重新定位过去,否则会出现“选了 07:03,步进改成 5 之后选中项消失”的尴尬。
3.3 滚动定位与选中项对齐
面板打开时,三列要自动滚到当前选中项的位置,这是体验分水岭。不滚的话用户每次都要手动翻,用两次就烦了。
private void ScrollToSelected(ListBox list, object selected) { if (list == null || selected == null) return; // 等布局完成再滚,否则 ItemContainerGenerator 还没生成容器 list.Dispatcher.BeginInvoke(new Action(() => { var container = list.ItemContainerGenerator .ContainerFromItem(selected) as ListBoxItem; if (container == null) return; // 让选中项大致居中,而不是贴顶或贴底 var offset = container.TranslatePoint(new Point(0, 0), list).Y - (list.ActualHeight - container.ActualHeight) / 2; var scrollViewer = FindVisualChild<ScrollViewer>(list); scrollViewer?.ScrollToVerticalOffset( scrollViewer.VerticalOffset + offset); }), DispatcherPriority.Loaded); }这里必须用DispatcherPriority.Loaded延迟执行。面板刚打开时ListBox还没完成布局,ContainerFromItem返回 null,直接滚会静默失败。这个延迟不是玄学,是 WPF 布局管线的实际顺序决定的。TranslatePoint算出容器相对列表的位置,再减去半个列表高度,就得到让选中项居中的偏移量。
4. 交互逻辑与校验:键盘、步进、范围一个都不能少
4.1 手动键入的解析策略
输入框允许直接敲“8:30”“08:30:00”“8:30 PM”这几种写法,解析要宽容,但存进SelectedTime的值要规范。
private bool TryParseInput(string text, out TimeSpan result) { result = default; if (string.IsNullOrWhiteSpace(text)) return false; // 先按配置的 Format 严格解析 var formats = new[] { Format, "H:mm", "HH:mm", "H:mm:ss", "HH:mm:ss", "h:mm tt", "hh:mm tt", "h:mm:ss tt", "hh:mm:ss tt" }; foreach (var f in formats) { if (TimeSpan.TryParseExact(text.Trim(), f, CultureInfo.InvariantCulture, out result)) { return true; } } // 兜底:交给宽松解析,处理 "8" 这种只敲小时的输入 if (TimeSpan.TryParse(text.Trim(), out result)) return true; return false; }解析顺序是先严格后宽松。严格解析用TryParseExact配一组候选格式,覆盖用户可能敲的写法;宽松解析兜底处理“8”这种只敲小时的输入,按 8 点整算。解析失败时不改SelectedTime,把输入框文本回退到上次合法值,同时把边框标红。回退而不是清空,是因为用户可能只是敲错一个字符,清空会让他重敲一遍。
4.2 步进与范围的双重约束
步进和范围是两个独立约束,但会互相影响。比如MinuteStep=15、MinTime=08:10,那么 08:10 本身不在步进序列上,得决定是向上取到 08:15 还是向下取到 08:00。我的处理是向上取,因为范围下限通常表达“不早于”,向上取更符合语义。
private TimeSpan ClampToRange(TimeSpan value) { // 先对齐步进 var totalMinutes = (int)value.TotalMinutes; var aligned = totalMinutes - (totalMinutes % MinuteStep); var result = TimeSpan.FromMinutes(aligned) .Add(TimeSpan.FromSeconds(value.Seconds - value.Seconds % SecondStep)); // 再夹到范围内 if (MinTime.HasValue && result < MinTime.Value) result = AlignUp(MinTime.Value); if (MaxTime.HasValue && result > MaxTime.Value) result = AlignDown(MaxTime.Value); return result; }AlignUp和AlignDown负责把边界值也对齐到步进序列上,否则会出现“下限是 08:10,但步进 15 分钟,用户永远选不到合法值”的死锁。这个细节在需求评审时容易被忽略,实现时才发现,属于典型的血泪经验。
4.3 键盘导航
键盘支持是桌面控件的底线。上下箭头在面板内切换选中项,左右箭头在时/分/秒三列间跳,回车确认并关闭,Esc 取消并还原。
private void OnPopupKeyDown(object sender, KeyEventArgs e) { switch (e.Key) { case Key.Up: MoveSelection(-1); e.Handled = true; break; case Key.Down: MoveSelection(1); e.Handled = true; break; case Key.Left: FocusColumn(-1); e.Handled = true; break; case Key.Right: FocusColumn(1); e.Handled = true; break; case Key.Enter: CommitAndClose(); e.Handled = true; break; case Key.Escape: CancelAndClose(); e.Handled = true; break; } }MoveSelection在当前列内移动选中项,到边界就停住,不循环。循环滚动在时间选择里反而容易误操作,用户以为到底了结果跳回开头。CancelAndClose要把SelectedTime还原成打开面板前的值,这需要在打开面板时存一份快照。没有快照的话,用户按 Esc 会发现值已经被改了,这是很常见的翻车点。
5. 避坑与常见问题排查
5.1 绑定值不更新
现象:SelectedTime在代码里改了,界面输入框没变。原因通常是依赖属性的PropertyChangedCallback里只更新了内部字段,没触发Text的刷新。解决:在回调里显式调用UpdateTextFromValue(),并且确保Text不是普通属性而是只读依赖属性,这样绑定引擎能感知变化。
5.2 弹出层位置错乱
现象:窗口移动或缩放后,Popup还停在旧位置。原因是Popup默认不跟随PlacementTarget的布局变化。解决:给PlacementTarget挂SizeChanged和LocationChanged,在事件里调_popup.HorizontalOffset += 0.1; _popup.HorizontalOffset -= 0.1;强制重算位置。这个写法看着丑,但比手动算坐标稳。
5.3 输入法冲突
现象:中文输入法下敲数字,候选框和面板抢焦点。原因是TextBox的InputMethod没关。解决:在TextBox上设InputMethod.IsInputMethodEnabled="False",时间输入本来就不需要中文输入法。
5.4 内存泄漏
现象:反复打开关闭面板,内存持续上涨。原因是Popup的子元素事件处理器没解绑,或者ItemsSource集合每次重建都新建对象但旧对象被事件引用。解决:集合重建时先清空再填充,OnApplyTemplate里成对解绑,必要时用WeakEventManager。
5.5 高 DPI 下模糊
现象:125% 缩放下控件边缘发虚。原因是Popup没跟随主窗口的 DPI 感知。解决:在App.xaml.cs里声明PerMonitorV2DPI 感知,并确保Popup的AllowsTransparency和UseLayoutRounding配合使用。
6. 验收与答辩:怎么证明这套控件真的能用
验收分三层,从功能到边界再到集成,逐层过。
第一层功能验收,对着第 2 章那张功能清单逐项点。24/12 小时制切换后显示是否正确、秒列隐藏后布局是否塌陷、清空按钮点击后SelectedTime是否为 null、只读模式下能否用键盘改值。这一层用一张表记录,每项标通过/不通过,不通过的要能复现。
第二层边界验收,专挑极端值。MinuteStep=0传进去会不会崩、MinTime > MaxTime时行为是否可预期、输入“25:00”是否被拒、输入空字符串是否回退。边界用例不用多,但每个都要有明确预期,不能“看着办”。
第三层集成验收,把控件放进真实页面,绑到 ViewModel 的TimeSpan?属性上,跑一遍增删改查。重点看双向绑定在面板选择、手动键入、清空三条路径下是否都同步。集成阶段最容易暴露的是UpdateSourceTrigger设错,默认LostFocus会导致面板选择后 ViewModel 不更新,得改成PropertyChanged。
答辩时如果被问到“为什么不用现成库”,回答思路是:现成库要么不支持范围限制,要么样式改不动,要么依赖额外框架。这套方案零第三方依赖,纯 WPF 原生实现,源码可控,改一行就能适配自己的项目。如果被问“怎么保证可维护”,把TemplatePart契约和依赖属性设计讲一遍,强调逻辑与视觉分离,换肤不改代码。
我自己的习惯是,每写完一个自定义控件,先不写业务,单独建一个测试窗口把所有属性和边界过一遍,截图存档。这个习惯帮我省过好几次返工——有次MinuteStep的强制约束漏了负数判断,测试窗口里一跑就暴露了,要是等集成到业务里才发现,排查成本翻几倍。从那以后我每次交付控件前都强制走一遍这三层验收,不跳步。希望这套方案能帮你把 WPF 里的时间选择这块补上,少走点我踩过的弯路。
本文还有配套的精品资源,点击获取