ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

WPF NumberUpDown自定义数字微调框设计与输入过滤实践

WPF NumberUpDown自定义数字微调框设计与输入过滤实践

做 WPF 桌面项目的人里,十个有八个在界面上要放“数字输入框”的时候卡过壳:翻遍工具箱找不到一个像样的微调输入控件,只能退回去想起 WinForms 里那个用了几十年的 NumericUpDown。于是有人装了个第三方控件库,有人用 WindowsFormsHost 把老控件嵌进 WPF,还有人干脆用 TextBox 加两条校验规则糊过去——结果上线之后被用户用一堆奇怪的输入姿势教做人。这篇东西聊的就是 NumberUpDown 这个控件:它在 WPF 里为什么缺失、一个真正能用的数字微调框需要满足哪些行为约定、从零手写的时候模板和依赖属性该怎么摆、输入过滤的漏洞藏在哪些角落,以及我自己在项目里踩过的几个坑。适合已经会用 WPF 做界面、但被数字输入细节折磨过的开发者,也适合刚接手一个老项目、发现里面那个“数字框”用起来哪儿哪儿都不对的人。

1. WPF 里那个“本该存在”的控件,以及它为什么缺席

1.1 从 WinForms 迁移过来最先撞上的空白

WPF 的控件栈是从零设计的,它把“控件”拆成了“逻辑 + 模板”两层,TextBox 负责文本编辑,Button 负责点击,RepeatButton 负责长按重复,但没有一个现成控件把这几样拼成“数字微调框”。WinForms 那边 NumericUpDown 是自带的老牌控件,直接在工具箱里躺着,迁移项目的时候最自然的想法就是“把它搬过来接着用”。

搬的方式看起来也简单:WPF 提供了 WindowsFormsHost,把 WinForms 控件塞进一个宿主容器就行。我在一个维护型项目里就见过这种写法,代码不到二十行,跑起来也确实能点上下箭头。问题从第三天开始冒出来:宿主区域内的 WinForms 控件永远浮在 WPF 元素之上,任何想叠在上面的提示气泡、下拉面板、右键菜单都会被它挡得严严实实,这就是典型的airspace(空域)问题。这个不是 bug,是两套渲染体系共存的物理限制,绕不过去。

接着是 DPI。高分辨率屏幕上,WPF 部分按 150% 缩放得很清晰,嵌进去的 WinForms 控件却是位图拉伸的糊状,字体边缘发虚,行高和旁边 WPF 控件对不齐。再往后是主题,项目里给控件统一做了暗色主题,宿主里的 NumericUpDown 还是系统灰,改起来得单独写一套 WinForms 绘制逻辑,等于维护两套皮肤。

1.2 宿主方案的隐性成本,比想象中高

真正让人下决心自己写的,往往不是上面这些看得见的问题,而是几个隐性的:

  • 数据绑定不通畅。WindowsFormsHost 里的控件属性不是依赖属性,绑不上去,得手动写ValueChanged事件把值搬进 ViewModel,再写一个反向的监听把 ViewModel 的变化搬回控件。来回复制粘贴,某个方向漏了就是“界面改了值没存上”或者“加载数据后界面不刷新”。
  • 焦点与 Tab 顺序。宿主是一个整体,Tab 进去之后焦点在 WinForms 内部流转,KeyboardNavigation那一套在跨界的地方会断掉,验收时测试同学专门提了“按 Tab 要按两下才能出得去”。
  • 样式与触发器全部失效。WPF 里想根据IsEnabled变个背景色,一个 Trigger 就完事,宿主方案做不到,只能靠代码里手动改属性。
  • 打包与体积。引入了 WinForms 的程序集引用,对一个纯 WPF 的小工具来说,这个依赖纯属多余。

算下来,为了省一个两三百行的控件,付出的维护成本远高于自己写。这也是为什么社区里绝大多数讨论帖最后都指向同一个结论:在 WPF 里,数字微调框的合理做法是自己实现。

1.3 三条自研路线的取舍对比

自研也不是只有一条路。按我见过的实现,大致能分出三种骨架,各有各的代价:

方案实现成本模板可控性数据绑定典型问题
继承 TextBox,重写默认样式加两个 RepeatButton中等高,可直接复用 TextBox 的编辑能力直接,Text 和 Value 都在自己手里需要自己维护一份完整的控件模板
UserControl 组合 TextBox 和两个 Button低中,受限于 UserControl 的根元素需要把 Text、Value 等属性逐个转发Tab 顺序易乱,样式继承链断掉
继承 Control,从零实现文本编辑高最高需要自己造 Text 属性等于重写一个 TextBox,投入产出比极低

第三种路线我在一个需要“数字键盘式输入”的特殊场景里见过,作者最后写了一千多行,光标定位、选区、输入法全得自己补,收益并不明显。第二种最省事,但 UserControl 有个天然缺陷:它的根元素是固定的,模板替换困难,想在控件模板里加触发器改整体外观时会被卡住,而且它默认不是“控件”语义,做无障碍支持时还得额外包一层。

我最终选的是第一种:继承 TextBox,覆盖默认样式,在模板里塞进两个 RepeatButton。理由很实际——文本编辑这种活儿已经被 TextBox 打磨了十几年,光标、选区、输入法、撤销栈全是现成的,我只需要在它的行为上加约束,而不是重新发明一遍。

1.4 为什么必须覆盖默认样式

继承 TextBox 之后有个容易忽略的点:如果不调用DefaultStyleKeyProperty.OverrideMetadata,控件用的还是 TextBox 的样式,模板里根本没有那两个按钮的位置,GetTemplateChild永远返回 null,点了没反应还查不出原因。

正确的做法是在静态构造函数里把默认样式键改成自己的类型,然后在Themes/Generic.xaml里提供一份完整的模板。这份模板不能只画按钮,PART_ContentHost这个 ScrollViewer 必须留着,否则 TextBox 内部的文本呈现逻辑找不到宿主,整个控件会变成一片空白。我在第一次写的时候就漏了这个,界面上一片死寂,调了半天以为是样式加载顺序问题。

2. 动手之前,先把行为约定写清楚

2.1 取值域、空值与“不允许为空”的抉择

写控件最容易犯的错是直接开始敲键盘,写到一半发现需求没想清楚,回头重构。我在动手前会先列一张行为清单,最核心的三条是:能不能为空、边界怎么处理、超出边界时是拒绝还是夹紧。

空值语义是第一个要定的事。绑定一个decimal是值类型,天然不能为空,但业务上“这个字段还没填”是常态,所以 Value 的类型我选了decimal?。空值意味着什么?是“用户清空了输入框”,还是“还没初始化”?这两种状态在表现上一样,但在校验逻辑里不一样。我的处理是:文本框为空时 Value 置为 null,只要AllowNull为 true 就合法;如果业务要求必填,在 ViewModel 层用[Required]或自定义校验处理,控件本身不承担业务校验职责。

边界处理我选的是“夹紧”而不是“拒绝”。用户手动输入一个超过最大值的数,失焦时自动变成最大值,而不是弹个红框报错。这个取舍基于实际体验:微调框的交互节奏很快,用户点着上箭头一路加,加到头了就应该停住,而不是跳出来打断。至于“拒绝”,那更适合表单提交时的整体校验,不适合逐字输入的场景。

2.2 三条输入通道必须给出同样结果

一个数字微调框实际上有三个输入入口:键盘直接敲、点上下按钮、滚轮。这三条路走完,用户得到的感受必须一致,否则就会出现“我点按钮加 0.1,加十次变成 1.0000000001”这种让人抓狂的事。

具体来说有几条硬性要求:

  • 点一次上按钮,值增加一个Increment;点下按钮减少一个,两条路必须对称。
  • 键盘上箭头等价于点上按钮,下箭头等价于点下按钮,PageUp/PageDown 走十步是我加的一个顺手设计。
  • 滚轮向上等价于上按钮,向下等价于下按钮,且滚轮触发时不应该同时滚动外层 ScrollViewer。
  • 走到边界时,三条通道要么同时停住,要么同时允许环绕,不能一个停一个绕。

我见过一个实现,按钮走的是“夹紧”,键盘走的是“环绕”,结果用户按着上箭头到了最大值,再按一下变回最小值,当场懵了。这种不一致在验收阶段极难被发现,因为测试用例通常只覆盖单条路径。

2.3 允许“半成品文本”存在,是体验的关键

这条是我踩坑之后才明白的。用户在输入框里敲一个负数,第一个字符是-,此刻文本是-,解析失败,值是多少?如果这时候把 Value 强制置 0,用户会发现刚敲的负号被吃掉了,光标跳走,体验直接崩掉。

所以必须区分两种状态:文本是值的一种表示,但文本不必时时刻刻都是合法的值。中间态的-、1.、0.0都应该被接受并保留在文本框里,只是在这一瞬间不更新 Value。等用户继续输入或者失焦,再尝试解析并同步。

这个设计带来的直接结果是:不能在每次TextChanged里无脑解析并回写。要引入一个同步标志位,区分“文本变化引发的值更新”和“值变化引发的文本刷新”,两者不能互相触发,否则就是一个死循环或者光标乱跳。

3. 控件骨架:模板里必须出现哪几个部分

3.1 模板结构拆解

继承 TextBox 之后,模板需要包含三样东西:一个名为PART_ContentHost的 ScrollViewer(TextBox 内部靠它承载文本呈现,名字必须一字不差),两个 RepeatButton 分别叫PART_IncreaseButton和PART_DecreaseButton。命名用 PART_ 前缀是一种约定,方便外部通过GetTemplateChild找到它们。

我用的布局是一个两列的 Grid,左边放内容宿主,右边是一列上下两个按钮的小 Grid。按钮用 RepeatButton 而不是 Button,原因是长按加速——RepeatButton 内部自带一个定时器,可以设置首次延迟和重复间隔,而普通 Button 只能响应一次点击。这个差别在“把值从 0 调到 1000”的场景里非常明显,点一百下和按住等待完全是两种体验。

<Style TargetType="{x:Type local:NumberUpDown}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:NumberUpDown}"> <Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="2"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/> <Grid Grid.Column="1" Width="16"> <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <RepeatButton x:Name="PART_IncreaseButton" Grid.Row="0" Delay="400" Interval="60" Focusable="False" IsTabStop="False"/> <RepeatButton x:Name="PART_DecreaseButton" Grid.Row="1" Delay="400" Interval="60" Focusable="False" IsTabStop="False"/> </Grid> </Grid> </Border> <ControlTemplate.Triggers> <Trigger Property="IsReadOnly" Value="True"> <Setter TargetName="PART_IncreaseButton" Property="IsEnabled" Value="False"/> <Setter TargetName="PART_DecreaseButton" Property="IsEnabled" Value="False"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style>

这里有两个细节值得单独说。Focusable="False"和IsTabStop="False"必须都设,只设一个的话,Tab 键仍然会把焦点送进按钮里,用户按 Tab 想离开输入框,结果焦点跑到上箭头上了。只读状态下要禁用按钮,否则用户点着按钮,IsReadOnly的文本框内容不变,但值在背后被改了,这是个很隐蔽的状态不一致。

3.2 OnApplyTemplate 里的挂接与解绑

模板不是一辈子只应用一次。控件换主题、动态改样式、甚至控件被移出视觉树再放回来,OnApplyTemplate都可能被再调用一次。如果只挂事件不解绑,第二次应用模板时旧按钮的引用还留着,点一次会触发两次步进,用户会觉得“这按钮怎么一次跳两格”。

public override void OnApplyTemplate() { if (_increaseButton != null) _increaseButton.Click -= OnIncreaseClick; if (_decreaseButton != null) _decreaseButton.Click -= OnDecreaseClick; base.OnApplyTemplate(); _increaseButton = GetTemplateChild(PartIncreaseButton) as RepeatButton; _decreaseButton = GetTemplateChild(PartDecreaseButton) as RepeatButton; if (_increaseButton != null) _increaseButton.Click += OnIncreaseClick; if (_decreaseButton != null) _decreaseButton.Click += OnDecreaseClick; }

顺序也有讲究:base.OnApplyTemplate()要在GetTemplateChild之前调,因为模板是在基类里完成实例化的。反过来的话取到的一直是 null,而且不报错,只能靠打断点才看得出来。

3.3 为什么用 Click 而不是绑 Command

RepeatButton 的长按重复是靠它内部的定时器不断触发 Click 事件实现的。有人喜欢把上下按钮做成命令绑定,暴露IncreaseCommand给外部用,我不推荐这么做。命令绑定的解析依赖 DataContext,而控件模板里的 DataContext 继承链在复杂布局里未必如你所愿,外部一改 DataContext,模板里的按钮就点不动了。事件的方式直接、可靠,且不需要外部做任何配置。

4. 依赖属性与值强制:把约束塞进 CoerceValueCallback

4.1 Value 的可空类型与 Coerce 重入

Value 是控件的心脏,声明的时候要把几个选项一次配齐:

public static readonly DependencyProperty ValueProperty = DependencyProperty.Register( nameof(Value), typeof(decimal?), typeof(NumberUpDown), new FrameworkPropertyMetadata( null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnValueChanged, CoerceValue));

BindsTwoWayByDefault是我加的一个便利项,这样在 XAML 里写Value="{Binding Width}"不用再额外写Mode=TwoWay,减少了漏写导致“界面改了模型不变”的概率。不过它也会带来一个副作用:如果绑定的源属性没有 setter,运行时会抛异常,这个在调试阶段容易被误判成控件的问题。

CoerceValueCallback是用来做值夹紧的地方,超出范围的值在这里被拉回边界。这里有个坑:Coerce 会在值设置的任意时刻触发,包括初始化、绑定更新、用户输入,如果 Coerce 里再调SetValue,很容易形成递归。正确做法是直接在回调里返回修正后的值,不要去改属性本身。

private static object CoerceValue(DependencyObject d, object baseValue) { var ctl = (NumberUpDown)d; if (baseValue is not decimal raw) return null; var v = raw; if (v < ctl.Minimum) v = ctl.Minimum; if (v > ctl.Maximum) v = ctl.Maximum; if (ctl.DecimalPlaces is >= 0 and <= 28) v = Math.Round(v, ctl.DecimalPlaces, MidpointRounding.AwayFromZero); return v; }

四舍五入用MidpointRounding.AwayFromZero而不是默认的银行家舍入,原因是业务上“2.345 保留两位”用户期望的是 2.35,不是 2.34。银行家舍入在财务场景有它的道理,但放在一个通用输入控件里,绝大多数用户会觉得“这程序算错了”。

4.2 Increment 和 DecimalPlaces 的参数校验

并不是所有属性都能用 Coerce 处理,有些值本身就不合法,比如增量为 0 或者负数。这种情况用ValidateValueCallback更合适,它在属性赋值阶段就拒绝,抛出的异常能直接定位到出错的 XAML 行。

public static readonly DependencyProperty IncrementProperty = DependencyProperty.Register( nameof(Increment), typeof(decimal), typeof(NumberUpDown), new FrameworkPropertyMetadata(1m), value => (decimal)value > 0m);

DecimalPlaces我限定了 0 到 28 之间,因为 decimal 类型本身最多支持 28 位小数,超过这个数格式化时会抛异常或者被截断。上界在 Coerce 里也做了一次保护,双保险。

还有一个隐含的约束容易被忽略:Minimum 不能大于 Maximum。这两个属性谁先赋值不确定,如果只在各自的回调里校验,先设 Minimum=100,此时 Maximum 还是默认的 100,合法的;再设 Maximum=50,此刻 Minimum 大于 Maximum 了。我的处理是在 CoerceValue 里加一层防御,当 Minimum 大于 Maximum 时,把范围单向裁成一个点,避免出现“值永远在范围外”的死锁状态。

4.3 双向绑定的更新时机

WPF 绑定默认的UpdateSourceTrigger是LostFocus,意味着用户输入的过程中源属性不会变。很多人第一次用这个控件会觉得“我输入之后 ViewModel 里的值怎么没变”,其实要等失焦。

我一般会建议在业务绑定里显式写UpdateSourceTrigger=PropertyChanged,让每次输入都同步。但这样做需要控件配合:因为中间态文本(比如单独一个负号)解析失败时,不能把 null 或者 0 推给源属性,否则用户输入被打断。所以同步逻辑必须是“解析成功才更新,解析失败保持原值”,而不是“每次 TextChanged 都推一个值”。

5. 输入过滤:字符级拦截解决不了全部问题

5.1 PreviewTextInput 只做“准入”,不做“判断”

最朴素的写法是在PreviewTextInput里遍历字符,只放行数字,这个方案能挡住大部分手滑输入,但挡不住三件事:用户只敲了负号怎么办、小数点敲了两个怎么办、粘贴一长串文本怎么办。所以定位要摆正——这个拦截器只负责“挡掉明显不可能属于数字的字符”,真正的合法性判断留给解析逻辑。

protected override void OnPreviewTextInput(TextCompositionEventArgs e) { foreach (var ch in e.Text) { if (!IsCharAllowed(ch)) { e.Handled = true; return; } } base.OnPreviewTextInput(e); } private bool IsCharAllowed(char ch) { if (char.IsDigit(ch)) return true; var sep = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; if (DecimalPlaces > 0 && sep.Length == 1 && ch == sep[0]) return true; var neg = CultureInfo.CurrentCulture.NumberFormat.NegativeSign; if (Minimum < 0 && neg.Length == 1 && ch == neg[0]) return true; return false; }

5.2 小数点符号别写死成点

这是我在一个多语言项目里栽的跟头。测试同学把系统区域切成某个欧洲地区,小数分隔符是逗号,用户在框里敲逗号被拦掉了,敲点号能进去但解析失败,输入框直接变成只读。原因是代码里到处写着ch == '.'和text.Contains(".")。

正确做法是从CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator里拿符号,负号从NegativeSign拿。这两个属性返回的是字符串不是字符,某些区域设置里可能是多个字符,所以要判断长度再比较。解析的时候也把对应的NumberStyles传进去,让框架按当前区域规则处理,别自己手写拆分逻辑。

还有一个更细的点:即使是同一个系统,用户复制粘贴的文本可能用了另一套分隔符(比如从英文文档里复制的1.5),粘贴进去会解析失败。我的处理是粘贴失败时尝试用不变区域再解析一次,成功就替换成本地分隔符再写入。

5.3 粘贴是最大的漏洞

PreviewTextInput管不住粘贴,因为粘贴走的是命令通道。要拦粘贴得挂DataObject.Pasting事件,从头检查要粘进来的内容。用户可能粘一个完整数字,也可能粘一段带千分位、带货币符号、带空格的文本,甚至粘一整段文章。

private void OnPasting(object sender, DataObjectPastingEventArgs e) { if (!e.SourceDataObject.GetDataPresent(DataFormats.UnicodeText, true)) { e.CancelCommand(); return; } var raw = e.SourceDataObject.GetData(DataFormats.UnicodeText) as string; if (string.IsNullOrWhiteSpace(raw)) { e.CancelCommand(); return; } var trimmed = raw.Trim(); var combined = Text.Remove(SelectionStart, SelectionLength) .Insert(SelectionStart, trimmed); if (!TryParseText(combined, out _)) { e.CancelCommand(); return; } e.CancelCommand(); Text = combined; CaretIndex = SelectionStart + trimmed.Length; }

先CancelCommand再手动写入,看起来多此一举,其实是为了在写入前做完整的拼接校验。直接放行粘贴的话,文本框内容会先被改掉,你再判断非法去还原,光标位置和撤销栈都乱了。

另外别忘了一类输入法输入。数字框一般用不到输入法,但用户可能开着中文输入法往里敲,PreviewTextInput收到的信息形态不一样。稳妥的做法是给控件设InputMethod.IsInputMethodEnabled="False",从源头断掉这条路径。

5.4 光标位置和批量刷新之间的拉锯

Text属性被重新赋值时,光标会跳到末尾(有时是开头),这是 TextBox 的默认行为。如果用户在中间插入一个数字,值更新后代码又把格式化后的文本写回去,光标就跑了——这个体验极其糟糕,用户会觉得“我打的字被吞了”。

我的处理是刷新文本前记录CaretIndex,写完之后按新长度做一次裁剪再恢复:

private void SyncTextFromValue() { if (_isSyncingText) return; _isSyncingText = true; try { var text = FormatValue(Value); if (string.Equals(Text, text, StringComparison.Ordinal)) return; var caret = CaretIndex; Text = text; CaretIndex = Math.Min(caret, Text.Length); } finally { _isSyncingText = false; } }

更关键的是:用户正在输入的时候,不要去刷新文本。用_isUpdatingFromText标志区分来源,文本变化引起的值更新不回写文本,外部绑定引起的值变化才回写。这一行判断省掉了几十条奇怪的 bug。

6. 步进执行:三条输入通道走同一段逻辑

6.1 累加用 decimal,不要碰 double

听起来是常识,但我确实见过用 double 存 Value 的实现,结果就是“点十次加 0.1 得到 0.9999999999999999”。浮点数的二进制表示决定了有些十进制小数根本存不准,累加误差会越积越大,最后格式化成两位小数显示看着没问题,一到等于判断就崩。

decimal 是十进制浮点,0.1m + 0.1m精确等于0.2m,这在金额、尺寸、比例这类需要精确十进制表现的场景里是刚需。代价是运算比 double 慢,但对一个输入控件来说,这点性能差异完全感觉不到。

6.2 长按加速的参数怎么定

RepeatButton 有两个关键参数:Delay是首次点击到开始重复的等待时间,Interval是之后每次重复的间隔。我一般设Delay=400、Interval=60。400 毫秒的意义是区分“单击”和“长按”——短于这个时间的抬起不会触发重复,用户正常点一下就是一步。60 毫秒对应大约每秒 16 步,这个速度按住三秒能走 50 步,从 0 到 50 在可接受范围内。

调这两个参数要看业务粒度。如果 Increment 是 1,取值范围是 0 到 100,那 Interval 小一点无妨;如果 Increment 是 0.01,取值范围到 10000,那可能需要更大的 Interval,否则一秒就能从最小值冲到最大值,用户根本停不住。我在一个价格输入的场景里就把 Interval 调到了 120,同时把 PageUp 的步进改成 10 倍,给用户一个“快速跳到”的通道。

6.3 滚轮事件到底被谁吃掉了

滚轮是个容易被忽略的输入通道。TextBox 自身带一个 ScrollViewer(就是PART_ContentHost),单行模式下它不滚动,但事件可能在内部被处理掉,冒泡不到你的处理函数。所以要在OnPreviewMouseWheel(隧道阶段)里拦截,这时候事件还没往下走。

protected override void OnPreviewMouseWheel(MouseWheelEventArgs e) { if (!IsReadOnly && !IsEnabled && e.Delta == 0) return; if (!IsReadOnly && IsEnabled && e.Delta != 0) { Step(e.Delta > 0 ? 1 : -1); e.Handled = true; } }

e.Handled = true这一句很重要,否则滚轮事件继续往外层 ScrollViewer 冒泡,用户滚动数字框的时候整个页面跟着滚,非常难受。但如果这个控件本身放在一个必须滚动的长表单里,完全吃掉滚轮会让用户滚不动页面,这时候要权衡——我的做法是只在控件有焦点时才响应滚轮,没焦点就让事件穿透出去。

6.4 步进函数本身要足够简单

所有通道最后都汇到同一个Step方法,这样一致性才有保障:

private void Step(int direction) { var current = Value; decimal next; if (current is null) next = direction > 0 ? Math.Max(Minimum, 0m) : Math.Min(Maximum, 0m); else next = current.Value + direction * Increment; if (next < Minimum) next = Minimum; if (next > Maximum) next = Maximum; SetCurrentValue(ValueProperty, next); SyncTextFromValue(); }

用SetCurrentValue而不是SetValue是个细节:SetValue会把绑定的表达式覆盖掉,一旦调过,Value和源属性之间的绑定关系就断了,之后源属性再变界面也不刷新。SetCurrentValue保留绑定,只是改当前值,这是自定义控件里最容易踩的坑之一,而且症状特别隐蔽——单次操作看着都对,多操作几次就发现绑定失效了。

7. 上项目之前的验证清单与几个真实翻车点

7.1 边界用例表格

控件写完不等于能用,我自己维护了一份必测清单,每次改完都要过一遍:

场景期望行为常见错误实现
输入为空后失焦Value 为 null,文本保持空Value 变 0,文本显示 0
输入 9999,上界为 100失焦时夹到 100保留 9999 或直接报错
只输入-后失焦回滚到上一次有效值Value 变 0,负号消失
长按上箭头到顶停在最大值不再变化抛异常或跳到最小值
值变化时光标在中间光标位置基本保持光标跳到开头
只读模式下点按钮无任何反应值变了但文本没变
从小数点分隔符为逗号的区域输入逗号被接受,解析正确只认点号,输入被拦
粘贴带千分位的文本按规则接受或拒绝半截文本被写进去
绑定源被外部修改文本同步更新文本不刷新
连续操作后再次修改源属性界面仍然响应绑定被 SetValue 打断

7.2 三个我踩过的坑

第一个坑是初始化顺序。控件的构造函数里如果直接读Minimum、Maximum来做一些预计算,拿到的全是依赖属性的默认值,XAML 里设置的值还没生效。表现就是“我在 XAML 里设了最大值 100,控件启动时却按 decimal.MaxValue 算的”。修正方法要么是在OnInitialized里做,要么把这段逻辑挪进 Coerce 回调,让它跟着值变化走。

第二个坑是格式化字符串。我一开始用"N"格式,想着能带千分位好看,结果用户输入的时候千分位分隔符也被当成正常字符,光标定位全乱,解析时还得额外处理。改成"F"定点格式之后就干净了,只保留小数位,不带分组。如果业务确实要千分位显示,建议在只读状态下用"N",编辑态切回"F",两套格式分开。

第三个坑是样式继承。覆盖了默认样式之后,控件不再自动继承 TextBox 的那些基础设置,比如Padding、VerticalContentAlignment、CaretBrush,都得在模板里手动TemplateBinding或者给默认值。上线前发现光标颜色在暗色主题下根本看不见,查了半天才发现CaretBrush没有绑定。这类缺失不会报错,只会让外观慢慢跑偏,建议写模板的时候对照 TextBox 的默认模板逐项过一遍。

7.3 还可以往下扩展的方向

基础版本跑通之后,有几个可选的增强点,按需取舍。

一是环绕模式。到最大值后再加一变成最小值,对角度、月份这类取值天然循环的场景有用。实现上要在Step里先把范围判断处理掉,因为 Coerce 会把越界值拉回边界,环绕逻辑必须在 Coerce 之前完成。

二是表达式输入。允许用户输入100*2这样的算式,失焦时求值写回。这个功能看着酷,但要引入表达式解析,还得防注入,投入不小。我的做法是只在明确需要的数据录入场景开启,其余场景关掉,避免用户养成奇怪的使用习惯。

三是无障碍支持。自定义控件默认只暴露成 TextBox 的自动化语义,屏幕阅读器读不出这是“数值输入,当前值 5,范围 0 到 100”。要做的话需要重写OnCreateAutomationPeer返回一个自定义的AutomationPeer,实现IRangeValueProvider,工作量不算大但对可用性提升明显,尤其是面向企业内部的系统,这类要求会写进验收标准。

最后分享一个实际操作中的体会:这类基础控件不要指望一次写完美,我前后改了四五版,每一版都是被具体的用户反馈推着走的。真正有价值的不是那两三百行代码,而是那份边界用例清单——它才是保证这个控件在任何项目里都能用得住的东西。后续如果换项目,我基本都是把类文件和清单一起带走,模板稍微调一下配色就能直接上。

返回列表