1. 从零造一个时间选择器为什么我不直接用现成的WPF 里做时间输入很多人第一反应是去 NuGet 上找一个现成的控件库或者干脆用两个 ComboBox 拼一个。我早期也这么干过结果就是样式跟项目主题对不上、弹出层被父容器裁剪、键盘输入体验稀碎改到最后发现还不如自己写一个。所以这次我决定从零开始把时间选择器 TimePick 作为自己控件库的第一个成员完整走一遍自定义控件的设计、样式、模板、交互和打包流程。这篇文章适合谁看如果你已经写过一些 WPF 界面能看懂 XAML但对ControlTemplate、DependencyProperty、PART_命名约定这些概念还停留在见过但没自己写过的阶段那这篇就是给你准备的。我会把 TimePick 从需求拆解、控件选型、模板设计、依赖属性定义、交互逻辑到样式复用一步步讲清楚并且把我在这个过程中踩过的坑和想明白的道理都摊开来说。先说清楚 TimePick 到底要做什么。它本质上是一个时:分输入控件用户既可以手动敲键盘输入也可以点开一个下拉面板在面板里通过列表滚动选择小时和分钟。听起来简单但真正做起来涉及的问题一点不少输入框的文本校验怎么做、下拉面板怎么定位、滚动列表怎么跟输入框双向同步、只读和禁用状态怎么区分、主题色怎么让使用者能覆盖。这些问题每一个都对应着 WPF 自定义控件的一个核心知识点所以拿它当控件库的第一个练手项目性价比非常高。我个人的习惯是做任何自定义控件之前先问自己三个问题这个控件的最小可用形态是什么哪些东西必须暴露成依赖属性哪些东西应该留给样式去控制把这三个问题想清楚后面写代码基本就是填空题。TimePick 的最小可用形态就是一个能输入、能选择、能回传时间值的控件暴露的属性包括时间值、时间格式、下拉是否打开、是否只读而颜色、圆角、字体这些全部交给样式。这个划分后面会反复用到。2. 控件基类怎么选Control 还是 UserControl2.1 为什么我最终选了 Control 而不是 UserControl这是自定义控件绕不开的第一个岔路口。UserControl 上手快拖几个现成控件拼一拼就能用但它有个致命问题它的外观和逻辑是绑死的使用者想改个颜色、换个布局只能去改你的内部 XAML或者暴露一堆属性做转发。而 Control 走的是逻辑与外观分离的路线逻辑写在 C# 里外观写在ControlTemplate里使用者可以通过重写模板彻底换掉长相这才是控件库该有的样子。具体到 TimePick我需要它既能当输入框用又能弹出下拉面板还要支持不同的主题皮肤。如果用 UserControl我得把 TextBox、Popup、ListBox 全部塞进内部 XAML然后暴露一堆属性去转发它们的设置代码会变得又臭又长。用 Control 的话我在 C# 里只定义行为和依赖属性模板里用PART_命名约定去关联关键元素剩下的交给样式。所以结论很明确控件库里的控件一律继承 Control或者更具体的基类不要用 UserControl。2.2 继承 Control 之后必须处理的三件事继承 Control 不是改个基类就完事有三件事必须处理。第一件是默认样式。Control 本身没有外观如果你不给它指定DefaultStyleKey运行起来就是一个空白方块。标准做法是在静态构造函数里写DefaultStyleKeyProperty.OverrideMetadata(typeof(TimePick), new FrameworkPropertyMetadata(typeof(TimePick)))然后在 Themes/Generic.xaml 里放默认样式。第二件是模板应用后的钩子。模板里的元素不是一开始就存在的得等OnApplyTemplate被调用之后才能通过GetTemplateChild拿到。我一般会在这个方法里把关键元素取出来存成字段并在这里挂事件、做初始化。第三件是依赖属性的定义这个后面单独讲。这三件事是 Control 派生控件的标准骨架缺一个都会出问题。public class TimePick : Control { static TimePick() { DefaultStyleKeyProperty.OverrideMetadata( typeof(TimePick), new FrameworkPropertyMetadata(typeof(TimePick))); } public override void OnApplyTemplate() { base.OnApplyTemplate(); _textBox GetTemplateChild(PART_TextBox) as TextBox; _popup GetTemplateChild(PART_Popup) as Popup; // 挂事件、做初始化 } }提示GetTemplateChild拿不到元素时返回 null一定要做空判断。很多人第一次写自定义控件模板里名字写错了运行起来没报错但功能全失效排查半天才发现是PART_名字对不上。2.3 PART_ 命名约定不是可选项WPF 的模板机制里PART_前缀是一个约定带这个前缀的元素控件逻辑会去主动查找并使用它。比如 ComboBox 会找PART_EditableTextBoxSlider 会找PART_Track。这个约定的意义在于它把模板必须提供哪些元素这件事变成了契约。使用者重写模板时只要保留这些PART_元素控件逻辑就能正常工作其他部分随便改。TimePick 里我定义了三个关键 PARTPART_TextBox负责文本输入和显示PART_Popup负责下拉面板PART_HourList和PART_MinuteList负责两个滚动列表。这里有个经验PART 元素不要定义太多够用就行。定义得越多使用者重写模板的负担越重而且你自己维护起来也累。我见过有的控件定义了十几个 PART结果模板稍微改一下就崩体验极差。3. 依赖属性设计哪些该暴露哪些该藏起来3.1 时间值属性为什么用 TimeSpan 而不是 DateTimeTimePick 的核心属性是当前选中的时间。这里有个选型问题用 DateTime 还是 TimeSpanDateTime 带日期信息但我们只关心时分用它会引入一堆无意义的日期处理TimeSpan 表示时间间隔正好可以表达从零点开始的偏移也就是时分。所以我选了 TimeSpan并且约定它的范围是 0 到 23:59:59。定义依赖属性有一套固定写法关键是PropertyMetadata里的默认值和变更回调。变更回调里要做两件事一是把新值同步到文本框显示二是同步到下拉列表的选中项。这里要注意防止循环触发——用户输入导致属性变化属性变化又去更新文本框文本框再触发输入事件就死循环了。我的做法是加一个_isUpdating标志位更新期间直接返回。public static readonly DependencyProperty SelectedTimeProperty DependencyProperty.Register( nameof(SelectedTime), typeof(TimeSpan), typeof(TimePick), new FrameworkPropertyMetadata( TimeSpan.Zero, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedTimeChanged)); public TimeSpan SelectedTime { get (TimeSpan)GetValue(SelectedTimeProperty); set SetValue(SelectedTimeProperty, value); }注意BindsTwoWayByDefault这个选项。时间值天然需要双向绑定用户选了时间要回传给 ViewModelViewModel 改了时间也要反映到界面。默认双向能省掉使用者写ModeTwoWay的麻烦这是控件设计里很贴心的一个细节。3.2 时间格式属性让使用者决定显示成什么样不同场景对时间显示的要求不一样有的要HH:mm有的要H:mm有的还要带秒。所以我加了一个TimeFormat字符串属性默认值是HH:mm。这个属性变化时要重新格式化文本框内容。实现上就是调用SelectedTime.ToString(TimeFormat)但要注意格式字符串非法时会抛异常得用 try-catch 兜底非法就退回默认格式。这里有个坑我踩过格式字符串里如果包含:在某些区域设置下会被当成时间分隔符处理导致格式化结果不符合预期。稳妥的做法是提醒使用者用标准格式说明符或者在文档里写清楚。我自己在控件内部做了个简单的校验只允许H、h、m、s和分隔符:出现其他字符一律过滤掉。3.3 只读、禁用、下拉打开状态的处理差异IsReadOnly和IsEnabled是两个容易混淆的属性。IsEnabledfalse时控件整体变灰、不响应任何输入这是 WPF 框架层面自动处理的IsReadOnlytrue时控件仍然正常显示、能获得焦点但用户不能修改文本不过下拉选择应该还是允许的——这是只读和禁用的关键区别。我在模板里用触发器分别处理这两种状态的视觉表现。IsDropDownOpen是控制下拉面板的属性它需要双向绑定因为用户点击外部关闭面板时属性要同步变回 false。这个属性的变更回调里要处理焦点转移面板打开时把焦点移到列表上方便键盘操作面板关闭时把焦点还给文本框。属性名类型默认值双向绑定作用SelectedTimeTimeSpan0是当前选中时间TimeFormatstringHH:mm否显示格式IsDropDownOpenboolfalse是下拉是否展开IsReadOnlyboolfalse否是否只读MinuteIntervalint1否分钟步进4. ControlTemplate 拆解把外观和逻辑彻底分开4.1 模板的整体结构长什么样TimePick 的模板由三部分组成一个带边框的容器里面放文本框和一个下拉箭头按钮一个 Popup里面放小时和分钟两个列表以及各种状态触发器。整体用 Grid 布局文本框占满箭头按钮靠右固定宽度。Popup 的PlacementTarget指向整个控件Placement设为 Bottom这样面板会出现在控件正下方。写模板时我有个习惯先把结构搭出来不加任何样式跑起来看看布局对不对再逐步加视觉细节。很多人一上来就写一大堆颜色、圆角、阴影结果布局出问题了在一堆样式里找问题特别费劲。结构优先样式其次这个顺序能省很多时间。ControlTemplate TargetType{x:Type local:TimePick} Grid Border x:NameBd ... Grid Grid.ColumnDefinitions ColumnDefinition Width*/ ColumnDefinition WidthAuto/ /Grid.ColumnDefinitions TextBox x:NamePART_TextBox Grid.Column0 .../ ToggleButton x:NamePART_Toggle Grid.Column1 .../ /Grid /Border Popup x:NamePART_Popup ... Border ... Grid ListBox x:NamePART_HourList .../ ListBox x:NamePART_MinuteList .../ /Grid /Border /Popup /Grid /ControlTemplate4.2 下拉面板的定位与裁剪问题Popup 最大的坑是定位和裁剪。Popup 默认不在视觉树里它的位置是相对于屏幕计算的所以父容器的ClipToBounds管不到它这既是优点也是缺点。优点是面板不会被父容器裁剪缺点是如果父容器滚动面板不会跟着动会出现面板飘在原地的尴尬情况。解决办法是监听父容器的滚动事件滚动时重新计算 Popup 的偏移或者直接关闭它。我在 TimePick 里做了个简化处理面板打开时记录控件的屏幕位置监听ScrollViewer.ScrollChanged一旦滚动就关闭面板。这样虽然体验上略有妥协但胜在稳定不会出现面板错位。如果你追求极致体验可以用CustomPopupPlacementCallback做动态定位但复杂度会上升不少。注意Popup 的StaysOpen属性要设为 false这样点击面板外部时面板会自动关闭。但如果你在面板内部有需要保持打开的操作比如连续选择就要手动管理关闭时机别让StaysOpenfalse把面板意外关掉。4.3 用 VisualStateManager 还是 TriggerWPF 里控制视觉状态有两种方式老式的Trigger和新的VisualStateManagerVSM。Trigger 写起来直观适合简单的状态切换VSM 更规范适合状态多、需要动画过渡的场景。TimePick 的状态不算多——正常、鼠标悬停、获得焦点、只读、禁用、下拉打开——用 Trigger 完全够用而且可读性更好。不过我要提醒一点Trigger 的优先级和覆盖关系容易出问题。比如同时满足鼠标悬停和下拉打开时哪个样式生效取决于 Trigger 的声明顺序后声明的覆盖先声明的。我一般把通用状态放前面特殊状态放后面并且在每个 Trigger 里把相关属性写全避免出现改了一个属性另一个属性还是旧值的情况。5. 交互逻辑键盘、鼠标、滚动列表的协同5.1 文本输入的实时校验与格式化用户在文本框里敲字的时候不能等到失焦才校验那样体验太差。我的做法是在TextChanged里做实时解析用TimeSpan.TryParseExact尝试按当前格式解析成功就更新SelectedTime失败就把文本框标红提示。但这里有个细节用户正在输入1的时候它还不是合法时间这时候不应该立刻标红否则用户每敲一个字符都看到红色很烦躁。我的处理是加一个延迟输入后 500 毫秒内没有新输入才做校验和标红。用DispatcherTimer实现每次输入重置计时器。这样用户连续输入时不会被打断停下来之后才给反馈。这个细节看起来小但对输入体验的影响很大属于用了就回不去的优化。5.2 下拉列表的滚动与选中同步小时列表有 24 项分钟列表有 60 项都需要滚动。ListBox 自带滚动但有两个问题要处理一是打开面板时要自动滚动到当前选中项二是选中项变化时要同步回文本框。第一个问题用ScrollIntoView解决在面板打开时调用第二个问题在SelectionChanged里处理更新SelectedTime。这里有个性能细节分钟列表 60 项如果每项都用一个复杂的 ItemTemplate滚动会卡。我的做法是 ItemTemplate 尽量简单就一个 TextBlock需要复杂显示的话用VirtualizingStackPanel做虚拟化。实测下来60 项用简单模板完全不卡用复杂模板就要开虚拟化。private void OnHourListSelectionChanged(object sender, SelectionChangedEventArgs e) { if (_isUpdating) return; _isUpdating true; var hour (int)_hourList.SelectedItem; SelectedTime new TimeSpan(hour, SelectedTime.Minutes, 0); _isUpdating false; }5.3 键盘操作上下键、回车、Esc 的完整支持一个合格的输入控件必须支持纯键盘操作。我的设计是文本框里按上下键调整小时配合 Shift 调整分钟按回车打开面板面板打开后上下键在列表里移动回车确认并关闭Esc 取消并关闭。这些逻辑写在PreviewKeyDown里注意要处理e.Handled避免事件继续冒泡导致意外行为。键盘操作里最容易忽略的是焦点管理。面板打开时焦点要移到列表上关闭时要还给文本框否则用户按了回车之后焦点不知道跑哪去了下次输入还得重新点一下。我用Keyboard.Focus显式管理焦点并且在面板关闭的动画结束后再移焦点避免动画过程中焦点丢失。6. 样式复用让控件库真正能被库起来6.1 把颜色、圆角、字体抽成资源控件库的价值在于复用而复用的前提是样式可配置。我把 TimePick 的所有视觉参数都抽成了资源背景色、边框色、圆角半径、字体大小、下拉面板的阴影等。使用者只要在自己的资源字典里覆盖这些 key就能换一套皮肤完全不用碰控件代码。资源命名我遵循一个约定TimePick.前缀加属性名比如TimePick.Background、TimePick.BorderBrush。这样既避免了和其他控件的资源冲突又让使用者一眼能看出这个资源是给谁用的。这个约定看起来不起眼但在大型项目里能省掉大量这个资源到底是谁定义的的排查时间。6.2 用 BasedOn 做样式继承如果使用者想基于默认样式改一点点用BasedOn是最省事的。我在 Generic.xaml 里给 TimePick 定义了默认样式使用者可以这样继承Style TargetType{x:Type local:TimePick} BasedOn{StaticResource {x:Type local:TimePick}} Setter PropertyBackground Value#FF2D2D30/ Setter PropertyForeground ValueWhite/ /Style注意BasedOn里用的是{x:Type local:TimePick}这会自动找到该类型的默认样式。这个写法比硬编码资源 key 更健壮因为即使默认样式的 key 变了BasedOn也不会失效。这是我推荐的标准做法。6.3 主题切换的两种实现路径主题切换有两种做法一种是运行时替换整个资源字典一种是给每个控件定义多套样式用触发器切换。前者适合全局换肤后者适合局部微调。TimePick 作为控件库成员我建议用前者因为控件本身不应该关心当前是什么主题它只负责消费资源主题由应用层决定。具体做法是把颜色资源放在单独的Theme.Light.xaml和Theme.Dark.xaml里应用启动时根据设置加载对应的字典。切换时把旧的字典移除、新的字典加入WPF 会自动重新解析所有DynamicResource引用。注意这里必须用DynamicResource而不是StaticResource否则切换主题时样式不会更新。对比项StaticResourceDynamicResource解析时机加载时一次性解析每次使用时解析主题切换不生效生效性能略高略低适用场景固定不变的资源需要动态切换的资源7. 打包与分发把 TimePick 变成真正的库7.1 项目结构怎么组织一个规范的控件库项目结构应该是这样的根目录下放控件的 C# 文件Themes 文件夹放 Generic.xaml 和主题资源Properties 文件夹放 AssemblyInfo。Generic.xaml 的路径必须是Themes/Generic.xaml这是 WPF 的硬性约定放错位置默认样式就找不到。我还会加一个 Demo 项目专门用来测试控件。Demo 和库分开库项目不引用任何多余的东西保持干净。这样打包出来的 DLL 体积小依赖少使用者集成起来也省心。很多人把 Demo 和库混在一个项目里结果打包时带了一堆测试代码很不专业。7.2 版本管理和向后兼容控件库一旦发布就要考虑向后兼容。加新属性没问题改已有属性的类型或默认值就要慎重因为使用者的代码可能依赖旧行为。我的原则是新增属性给合理默认值废弃属性先标记 Obsolete 保留几个版本重大变更升主版本号。这套规则听起来老生常谈但真正做起来能避免很多升级之后项目跑不起来的投诉。7.3 文档和示例的重要性控件库好不好用一半看代码一半看文档。我习惯给每个公开属性写 XML 注释这样使用者在 IDE 里能直接看到说明。另外在 Demo 项目里给每个功能配一个示例页面使用者复制粘贴就能跑。文档不用写得多华丽把这个属性是干嘛的、默认值是什么、什么时候该改说清楚就够了。8. 我在这个控件上踩过的几个坑第一个坑是OnApplyTemplate的调用时机。我一开始在构造函数里就去拿模板元素结果全是 null。后来才明白模板是在控件第一次测量布局时才应用的构造函数执行时模板还不存在。正确做法是在OnApplyTemplate里拿元素并且这个方法可能被调用多次模板被替换时所以每次都要先解绑旧元素的事件再绑新的。第二个坑是依赖属性的默认值共享问题。如果依赖属性的类型是引用类型比如集合默认值会在所有实例间共享一个实例改了会影响其他实例。解决办法是在PropertyMetadata里用CreateDefaultValueCallback或者干脆在构造函数里初始化。TimePick 的属性都是值类型没遇到这个问题但这是自定义控件里非常经典的坑值得记一笔。第三个坑是 Popup 里的 ListBox 拿不到键盘焦点。原因是 Popup 的内容不在主视觉树里键盘导航默认到不了。解决办法是给 Popup 内容设置Focusabletrue并且在打开时显式调用Keyboard.Focus。这个坑我排查了大半天最后是在一篇老外的博客里找到答案的。第四个坑是文本框的UpdateSourceTrigger。默认情况下 TextBox 的绑定是失焦才更新但 TimePick 需要实时更新所以要设成PropertyChanged。设了之后又带来新问题用户每敲一个字符都触发属性变更性能有影响。我的折中方案是内部用TextChanged事件手动处理绑定层面还是用默认的失焦更新这样既实时又不至于太频繁。9. 后续可以怎么扩展这个控件TimePick 目前只支持时分后续可以加秒的支持把TimeFormat里带s的情况处理掉。还可以加一个现在按钮一键填入当前时间。再进一步可以做成日期时间选择器把日期部分也纳入进来那就是另一个控件了。从控件库的角度看TimePick 是一个很好的起点因为它涵盖了自定义控件的大部分核心知识点依赖属性、ControlTemplate、PART 约定、Popup、键盘交互、样式复用。把这一个控件吃透再做其他控件就是举一反三的事。我个人的体会是控件库不要贪多先把一两个控件做到生产可用把基础设施主题、资源、打包、文档搭好后面加控件就是流水线作业了。最后分享一个小技巧调试自定义控件时如果模板里的元素找不到可以在OnApplyTemplate里把GetTemplateChild的返回值打印出来或者用 Snoop 这类工具直接看运行时的视觉树。Snoop 能实时看到模板展开后的结构对排查模板问题特别有用强烈建议装一个。