WPF仿ElementUI TimePicker:自定义时间选择器实现
简介本资源是一套面向WPF桌面开发者的时间选择器实现方案仿照ElementUI中TimePicker的交互与视觉风格用C#与XAML在WPF环境下完成同类组件。适合已掌握WPF基础、希望深入理解MVVM模式与数据绑定的中初级开发者可用于表单录入、日程安排、后台管理系统等需要精确选择时间的场景。压缩包共1267个文件约918KB以415个cs源码文件为核心配合3个xaml界面文件、1个sln解决方案与1个csproj工程文件另有dll、pdb、baml等编译产物及缓存、配置文件完整保留了Visual Studio项目的目录结构。已有424人学习下载。资源围绕ViewModel中的DateTime属性、INotifyPropertyChanged通知、TextBox与SelectedTime的双向绑定以及自定义对话框内Slider控制小时与分钟等关键环节展开读者可据此掌握组件拆分、事件处理与样式优化的完整思路并直接运行调试、按需扩展。1. 从一次控件选型翻车说起为什么要在 WPF 里重造一个 TimePicker去年帮某实验室做一套桌面端排班工具需求里有一句“时间选择要像网页那样能滚轮选、能直接输入、能清空”。我第一反应是翻 WPF 自带控件库DatePicker 有TimePicker 没有第三方控件包要么体积大要么样式锁死改一个下拉面板的圆角要翻三层模板。更麻烦的是团队里做前端的同学习惯了 ElementUI 那套交互——点击输入框弹出面板、上下滚动选时和分、输入非法值自动回填、右侧一个清空小图标。用 WPF 原生控件拼交互对不上验收时被一句“和设计稿不一样”打回。这就是这套方案的起点用 wpf C# 从零实现一个仿 ElementUI TimePicker 的时间选择器。它要解决的不是“有没有时间选”的问题而是“交互一致、样式可控、能塞进任意 WPF 项目”的问题。适合正在做 WPF 课设、毕设或者要在桌面端复刻 Web 组件库交互的开发者。整套方案围绕一个自定义控件展开包含弹出面板、滚动选择列、输入解析、清空与禁用态代码量可控结构清晰拿来就能改。2. 需求拆解与技术选型一个 TimePicker 到底要拆成几块2.1 从 ElementUI 交互反推功能清单先把参照物的行为拆干净不然写到一半会发现漏了状态。ElementUI 的 TimePicker 核心交互有这么几条输入框可编辑支持HH:mm:ss格式点击输入框或右侧图标弹出面板面板里时、分、秒各一列滚动选择选中后回填输入框并关闭面板有清空按钮鼠标悬停才显示禁用状态下整体变灰不可点。再往下还有边界情况输入25:00要判非法、输入9:5要补零成09:05、面板打开时点外部要关闭。把这些整理成功能清单后面每个模块都能对上号功能点交互表现涉及模块文本输入与解析手动输入时间失焦校验输入解析器弹出面板点击展开点外部收起Popup 容器滚动选择时/分/秒三列滚动滚动选择列值回填选中后写入输入框数据绑定清空悬停显示清空图标状态管理禁用整体置灰禁止交互依赖属性这张表是我做任何自定义控件前的习惯动作先列行为再动手能省掉大量返工。2.2 为什么用 UserControl Popup 而不是重写 ControlWPF 里做自定义控件有两条路一是继承Control写ControlTemplate二是用UserControl组合现有控件。前者更“正统”支持无外观模板替换但代价是要处理TemplatePart、OnApplyTemplate、依赖属性注册一大堆样板代码。这个 TimePicker 的定位是“项目内复用、样式可控”不是要做成通用控件库发布所以选UserControl组合Popup更划算。具体结构是外层UserControl里放一个Grid左边是TextBox负责输入显示右边是图标按钮负责清空和展开Popup作为弹出层里面横向排三列滚动选择器。Popup的StaysOpen设为False配合StaysOpenFalse时点击外部自动关闭省掉自己写全局鼠标钩子。数据流用依赖属性串起来SelectedTime是TimeSpan?类型IsDropDownOpen控制面板开合IsEnabled走原生依赖属性禁用态直接继承。提示Popup默认不在视觉树里绑定DataContext时要用PlacementTarget或显式指定源否则面板里的绑定会失效这是新手最容易卡住的地方。2.3 滚动选择列的两种实现路线面板里最核心的是滚动选择列。有两种做法一是用ListBox加ScrollViewer靠ScrollIntoView定位选中项二是用ItemsControl加TranslateTransform手动算偏移做“滚轮感”。第一种实现快但滚动是原生的没有 ElementUI 那种“选中项居中、上下渐隐”的效果第二种可控性强但要自己处理惯性滚动和吸附。我选的是折中方案ListBox打底隐藏原生滚动条用ScrollViewer的ScrollChanged事件监听滚动位置滚动停止后计算最近项并吸附居中。这样既有原生滚动的流畅度又能控制视觉。每一列是一个独立的TimeColumn用户控件对外暴露ItemsSource和SelectedItem时、分、秒三列复用同一个控件只是数据源不同。// TimeColumn.xaml.cs 核心逻辑滚动停止后吸附到最近项 private void OnScrollChanged(object sender, ScrollChangedEventArgs e) { // 只处理垂直滚动且滚动已停止惯性结束 if (e.VerticalChange 0) return; // 用 Dispatcher 延迟到滚动结束后再吸附避免滚动中频繁计算 _scrollTimer.Stop(); _scrollTimer.Start(); } private void OnScrollTimerTick(object sender, EventArgs e) { _scrollTimer.Stop(); var scrollViewer GetScrollViewer(); if (scrollViewer null) return; // 每项高度固定为 ItemHeight算出当前偏移对应第几项 double offset scrollViewer.VerticalOffset; int index (int)Math.Round(offset / ItemHeight); index Math.Max(0, Math.Min(index, Items.Count - 1)); // 吸附把选中项滚到容器垂直居中位置 double targetOffset index * ItemHeight - (scrollViewer.ViewportHeight - ItemHeight) / 2; scrollViewer.ScrollToVerticalOffset(targetOffset); SelectedItem Items[index]; }这段逻辑的关键参数是ItemHeight它必须和 XAML 里每一项的实际高度严格一致否则吸附会偏。_scrollTimer是一个DispatcherTimer间隔设 100 毫秒左右用来判断“滚动是否停止”。ViewportHeight是可视区高度减去ItemHeight再除以 2 就是居中偏移量。实际项目里我会把ItemHeight做成依赖属性方便不同字号下调整。2.4 输入解析与格式化别小看那几行正则输入框要支持手动输入就得解析。用户可能输入9:5、09:05、9:05:30、09:05:30甚至9点5分。我的做法是先做宽松匹配再补零格式化。用正则^(\d{1,2})[:点](\d{1,2})(?:[:分](\d{1,2}))?抓出时、分、秒然后校验范围时 0-23分 0-59秒 0-59。非法就回退到上一次有效值并给输入框加一个红色边框提示。// TimeParser.cs 解析并格式化时间字符串 public static bool TryParse(string input, out TimeSpan result) { result TimeSpan.Zero; if (string.IsNullOrWhiteSpace(input)) return false; // 宽松匹配支持 9:5、09:05、9点5分、9:05:30 等写法 var match Regex.Match(input.Trim(), ^(\d{1,2})[:点](\d{1,2})(?:[:分](\d{1,2}))?); if (!match.Success) return false; int hour int.Parse(match.Groups[1].Value); int minute int.Parse(match.Groups[2].Value); int second match.Groups[3].Success ? int.Parse(match.Groups[3].Value) : 0; // 范围校验越界直接判非法 if (hour 23 || minute 59 || second 59) return false; result new TimeSpan(hour, minute, second); return true; } // 格式化输出统一补零成 HH:mm:ss public static string Format(TimeSpan time) ${time.Hours:D2}:{time.Minutes:D2}:{time.Seconds:D2};TryParse返回布尔值而不是抛异常是因为输入校验是高频操作异常开销大。Format用D2保证两位数补零。这里有个细节TimeSpan的Hours是 0-23TotalHours才是总小时数格式化时必须用Hours用错会得到超过 24 的值。这个坑我在第一个版本里踩过输入25:00没拦住显示成了01:00排查了半天。3. 总体架构设计三层结构与四个核心模块的职责划分3.1 分层结构表现层、逻辑层、数据层整套控件按三层划分。表现层是 XAML负责布局和视觉状态包括TimePicker.xaml、TimeColumn.xaml和样式资源字典。逻辑层是 C# 代码负责交互响应、解析校验、滚动吸附包括TimePicker.xaml.cs、TimeColumn.xaml.cs、TimeParser.cs。数据层最简单就是TimeSpan?类型的值传递没有独立的数据访问但依赖属性承担了数据绑定的职责。这样分的好处是样式改动只动 XAML交互逻辑改动只动代码解析规则独立成静态类可以单独写单元测试。课设答辩时老师问“你的模块怎么划分的”这张分层图能直接讲清楚。3.2 四个核心模块与依赖关系四个模块分别是输入区TextBox 图标按钮、弹出面板Popup 三列 TimeColumn、滚动选择列TimeColumn、解析器TimeParser。依赖关系是单向的输入区依赖解析器做校验弹出面板依赖滚动选择列提供选项滚动选择列不依赖其他模块解析器是纯静态类无依赖。这种单向依赖意味着可以单独测试解析器也可以单独替换滚动选择列的实现而不影响其他部分。我在项目里会把TimeParser单独拎出来写几个测试用例覆盖9:5、25:00、空字符串、9点5分这几种输入确保解析逻辑稳定。3.3 依赖属性设计SelectedTime 与 IsDropDownOpen依赖属性是 WPF 数据绑定的基础。这个控件对外暴露两个关键属性SelectedTimeTimeSpan?和IsDropDownOpenbool。SelectedTime用FrameworkPropertyMetadata注册设置BindsTwoWayByDefault这样外部用{Binding SelectedTime}就能双向同步。IsDropDownOpen绑定到Popup的IsOpen控制面板开合。// 注册 SelectedTime 依赖属性支持双向绑定和属性变更回调 public static readonly DependencyProperty SelectedTimeProperty DependencyProperty.Register( nameof(SelectedTime), typeof(TimeSpan?), typeof(TimePicker), new FrameworkPropertyMetadata( null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedTimeChanged)); public TimeSpan? SelectedTime { get (TimeSpan?)GetValue(SelectedTimeProperty); set SetValue(SelectedTimeProperty, value); } private static void OnSelectedTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var picker (TimePicker)d; // 值变化时同步更新输入框显示文本 picker.UpdateTextFromValue(); }BindsTwoWayByDefault省去了外部写ModeTwoWay的麻烦。OnSelectedTimeChanged回调里做文本同步保证代码设值和界面显示一致。注意TimeSpan?是可空类型null表示未选择清空按钮就是把SelectedTime设为null。3.4 样式与模板用资源字典隔离视觉视觉部分全部放在Themes/TimePicker.xaml资源字典里包括输入框边框、图标按钮、弹出面板背景、滚动列选中项高亮。主控件通过ResourceDictionary.MergedDictionaries引入。这样做的目的是让样式可替换如果项目里已有自己的配色体系改资源字典里的画刷就行不用动控件代码。注意资源字典的Source路径要用pack://application:,,,格式或者放在项目根目录用相对路径否则设计时和运行时可能加载不到表现为控件“裸奔”没样式。4. 关键模块实现从弹出面板到滚动吸附的完整代码4.1 弹出面板的定位与关闭策略Popup的定位用PlacementBottom让面板出现在输入框下方PlacementTarget指向输入框所在的Grid。关闭策略有两个触发点一是选中时间后自动关闭二是点击面板外部关闭。后者靠StaysOpenFalse实现WPF 内部会捕获外部点击。但有个细节如果面板里也有按钮点击按钮时Popup可能先关闭导致按钮点不到解决办法是在按钮的PreviewMouseDown里把事件标记为已处理。!-- TimePicker.xaml 弹出面板部分 -- Popup x:NamePART_Popup PlacementTarget{Binding ElementNameInputGrid} PlacementBottom StaysOpenFalse IsOpen{Binding IsDropDownOpen, RelativeSource{RelativeSource AncestorTypeUserControl}} AllowsTransparencyTrue PopupAnimationFade Border BackgroundWhite CornerRadius4 BorderBrush#DCDFE6 BorderThickness1 Padding8 StackPanel OrientationHorizontal local:TimeColumn x:NameHourColumn ItemHeight32/ local:TimeColumn x:NameMinuteColumn ItemHeight32/ local:TimeColumn x:NameSecondColumn ItemHeight32/ /StackPanel /Border /PopupAllowsTransparencyTrue配合CornerRadius才能出圆角否则圆角会被方形背景盖住。PopupAnimationFade给一个淡入效果比生硬弹出舒服。三列TimeColumn的ItemHeight统一设 32和前面吸附逻辑里的ItemHeight必须一致。4.2 滚动选择列的数据绑定与选中同步TimeColumn内部是一个ListBoxItemsSource绑定到外部传入的集合。时列是 0-23分列和秒列是 0-59。选中项通过SelectedItem双向绑定回TimePicker三列的值拼成最终的TimeSpan。// TimePicker.xaml.cs 初始化三列数据源 private void InitializeColumns() { // 时列 0-23分秒列 0-59 HourColumn.ItemsSource Enumerable.Range(0, 24).Select(i i.ToString(D2)).ToList(); MinuteColumn.ItemsSource Enumerable.Range(0, 60).Select(i i.ToString(D2)).ToList(); SecondColumn.ItemsSource Enumerable.Range(0, 60).Select(i i.ToString(D2)).ToList(); // 选中变化时重新组装时间值 HourColumn.SelectionChanged OnColumnSelectionChanged; MinuteColumn.SelectionChanged OnColumnSelectionChanged; SecondColumn.SelectionChanged OnColumnSelectionChanged; } private void OnColumnSelectionChanged(object sender, EventArgs e) { // 三列都有选中值才组装否则保持 null if (HourColumn.SelectedItem null || MinuteColumn.SelectedItem null || SecondColumn.SelectedItem null) return; int hour int.Parse(HourColumn.SelectedItem.ToString()); int minute int.Parse(MinuteColumn.SelectedItem.ToString()); int second int.Parse(SecondColumn.SelectedItem.ToString()); SelectedTime new TimeSpan(hour, minute, second); }数据源用Select(i i.ToString(D2))直接生成补零字符串省去在 XAML 里写转换器。SelectionChanged事件里做组装注意判空因为初始化时三列可能还没选中值。这里有个性能细节每次选中变化都 new 一个TimeSpan开销可忽略但如果做实时预览滚动过程中就更新就要考虑节流。4.3 输入框与面板的双向同步输入框和面板是同一个值的两种表现必须双向同步。面板选中后更新SelectedTimeOnSelectedTimeChanged回调更新输入框文本输入框手动输入并校验通过后更新SelectedTime再反向驱动面板滚动到对应项。// 输入框失焦时校验并同步 private void OnTextBoxLostFocus(object sender, RoutedEventArgs e) { var text InputBox.Text; if (TimeParser.TryParse(text, out var parsed)) { // 解析成功更新值并格式化显示 SelectedTime parsed; InputBox.Text TimeParser.Format(parsed); InputBox.ClearValue(BorderBrushProperty); // 清除错误边框 } else { // 解析失败回退到上一次有效值并标红提示 InputBox.Text SelectedTime.HasValue ? TimeParser.Format(SelectedTime.Value) : string.Empty; InputBox.BorderBrush Brushes.Red; } }ClearValue(BorderBrushProperty)用来恢复默认边框颜色比手动设回原色更稳妥因为原色可能来自样式。标红用Brushes.Red是简化写法实际项目里我会从资源字典取一个ErrorBrush方便统一改色。4.4 清空与禁用状态的视觉处理清空按钮只在鼠标悬停且SelectedTime有值时显示用Trigger控制Visibility。禁用状态走IsEnabled依赖属性在样式里用Trigger把整体Opacity降到 0.5 并禁用点击。!-- 清空按钮悬停且有值时显示 -- Button x:NameClearButton Content✕ VisibilityCollapsed ClickOnClearClick Button.Style Style TargetTypeButton Style.Triggers MultiTrigger MultiTrigger.Conditions Condition PropertyIsMouseOver ValueTrue/ Condition Binding{Binding SelectedTime, Converter{StaticResource NotNullConverter}} ValueTrue/ /MultiTrigger.Conditions Setter PropertyVisibility ValueVisible/ /MultiTrigger /Style.Triggers /Style /Button.Style /ButtonNotNullConverter是一个简单的值转换器SelectedTime不为null时返回true。MultiTrigger同时满足悬停和有值两个条件才显示避免空值时悬停出现一个没用的清空按钮。禁用态则在UserControl根样式里加TriggerIsEnabledFalse时设Opacity0.5和IsHitTestVisibleFalse。5. 避坑与常见问题排查那些让我返工的细节5.1 现象面板打开后绑定全部失效数据不显示原因Popup不在主视觉树里DataContext不会自动继承。面板里的TimeColumn绑定ItemsSource时找不到源。解决在Popup的Child根元素上显式设置DataContext用{Binding PlacementTarget.DataContext, RelativeSource{RelativeSource AncestorTypePopup}}或者直接在Popup上设DataContext{Binding DataContext, ElementNameRoot}。我一般用后者直观。5.2 现象滚动吸附总是偏半格选中项对不齐原因ItemHeight和实际项高度不一致。XAML 里项模板可能有Margin或Padding导致实际渲染高度大于设定的ItemHeight。解决把项模板的Margin和Padding归零高度用Height固定不要用MinHeight。然后在TimeColumn加载完成后用ActualHeight反推一次ItemHeight做一次校准。5.3 现象输入25:00没被拦截显示成了01:00原因TimeSpan构造函数对超出范围的值会回绕new TimeSpan(25, 0, 0)得到的是 1 天 1 小时Hours属性返回 1。解决解析阶段就做范围校验hour 23直接判非法。不要依赖TimeSpan自己校验它不抛异常是静默回绕。这个坑我在 2.4 节提过这里再强调一次因为它太隐蔽。5.4 现象点击面板里的项面板先关闭导致点不中原因StaysOpenFalse时Popup捕获外部点击面板内按钮的MouseDown被Popup抢先处理。解决在面板内需要点击的元素上监听PreviewMouseDown把e.Handled设为true阻止事件冒泡到Popup的关闭逻辑。或者改用StaysOpenTrue自己写全局鼠标钩子判断点击位置但那样代码量更大不推荐。5.5 现象设计器里控件显示正常运行时样式丢失原因资源字典的Source路径写成了绝对路径或错误的pack格式设计时能解析运行时找不到。解决统一用pack://application:,,,/程序集名;component/Themes/TimePicker.xaml格式程序集名要和项目输出名一致。如果资源字典在同一个程序集可以简写成/Themes/TimePicker.xaml但跨程序集必须用完整pack格式。6. 验收与答辩怎么证明这个 TimePicker 真的能用验收这套控件我一般分三步走。第一步是功能清单逐项过输入9:5能否补零成09:05、输入25:00是否标红回退、点击输入框面板是否弹出、滚动时/分/秒三列是否吸附居中、选中后面板是否关闭并回填、清空按钮悬停是否出现且点击后值变null、禁用状态下是否整体置灰不可点。这七条覆盖了核心交互答辩时演示一遍老师基本能看懂。第二步是边界测试。空输入失焦后应该保持空或回退输入00:00:00应该合法输入23:59:59应该合法输入24:00应该非法面板打开时点外部应该关闭快速连续滚动后应该稳定吸附不抖动。这些边界是答辩时最容易被追问的点提前跑一遍心里有底。第三步是集成测试。把控件放进一个真实的表单场景绑定一个TimeSpan?属性验证双向绑定是否生效代码设值界面是否更新、界面选值代码是否能读到、清空后代码读到的是否是null。这一步能暴露依赖属性注册的问题比如忘了设BindsTwoWayByDefault导致外部绑定只能单向。答辩时如果被问到“为什么不用原生控件”可以这样答WPF 没有原生 TimePicker第三方控件样式不可控自研控件能精确复刻 ElementUI 交互且代码量在可控范围内便于课设展示技术深度。如果被问到“滚动吸附怎么实现的”就讲ScrollChanged加DispatcherTimer判断滚动停止再算偏移量吸附居中ItemHeight是关键参数。提示答辩前把ItemHeight、SelectedTime、IsDropDownOpen这三个参数的含义和默认值背熟被问到细节时能直接答上来比翻代码强。从那以后我每次做自定义控件都强制先列功能清单表再动手写 XAML最后才写逻辑。这个顺序能避免“写到一半发现漏了状态”的返工。希望这套 TimePicker 方案能帮到你无论是交课设还是塞进项目里用照着这个结构走基本不会翻车。本文还有配套的精品资源点击获取