写PyQt5的人,十有八九都用过QSpinBox和QDoubleSpinBox,但真正去碰它们底下的QAbstractSpinBox基类的,估计不多。我以前也一直把它当成一个"抽象类摆设",直到有次接了个需求,要做一个输入范围横跨好几个数量级、步长还得随数值大小动态变化的步长调节器,QSpinBox那点固定步长的能力根本使不上劲,我这才老老实实把QAbstractSpinBox从头到尾啃了一遍。啃完之后最大的感受是:这个基类才是整个步长调节器体系里真正干活的角色,QSpinBox和QDoubleSpinBox不过是它默认配置好的两个具体实现。这篇文章就把我实际用下来的理解、子类化经验和踩过的坑都写出来,给同样想在PyQt5里做自定义步长控件的朋友一个参考。
1. 为什么说QAbstractSpinBox才是真正干活的基类
1.1 继承关系里藏着的设计意图
先看最直接的继承链:QAbstractSpinBox继承自QWidget,QSpinBox和QDoubleSpinBox又继承自QAbstractSpinBox。很多初学者会习惯性把QSpinBox当成"最基本的控件",但只要你点开Qt的帮助文档看一眼就会发现,QSpinBox自身几乎没写多少逻辑,它只是把QAbstractSpinBox留出来的一堆虚函数重新覆盖了一遍,塞进去了"整数"这个具体类型。真正的步长调节、文本显示、按键响应、滚轮处理、校验修复,这些框架性逻辑全在基类里。
这个设计思路跟我后来写业务代码时经常用的"模板方法模式"一模一样:基类把整个交互流程定死,把"每一步具体怎么做"留成虚函数交给子类去填。QAbstractSpinBox的流程大概是这样的:用户按一下上箭头,控件就调用stepBy(1);按一下下箭头,调用stepBy(-1);在输入框里敲字符,文本一变就触发validate去判断能不能接受;提交文本的时候又调用valueFromText把字符串转成内部数值。这套流程对所有"可调数字控件"都是通用的,唯一不同的就是"数值用什么类型存""步长怎么算""字符串怎么解析",这些恰恰就是子类需要覆盖的部分。
想清楚这一层之后,你真的会发现很多问题其实不用去翻QSpinBox的源码,直接在基类层面就能找到答案。
1.2 QSpinBox和QDoubleSpinBox只是"默认实现"
QSpinBox默认帮你把内部数值类型定为int,步长定为1,范围是0到99,valueFromText直接用QString的toInt()去转换,textFromValue直接用QString::number()去格式化。QDoubleSpinBox则把内部值换成double,步长定为1.0,范围0.0到99.99,字符串转换走的是toDouble()。这些默认配置适合80%的普通场景,所以大部分教程都在教"直接在界面上拖一个QSpinBox,设个min、max、step"就完事了。
但只要需求稍微变一下,默认实现就会露馅。举个我真实遇到的例子:当时某项目X需要一个"税率输入框",税率可以是0.03、0.07、0.125这种很别扭的小数,还要在界面上显示成"3%""7%""12.5%"这样的格式。用QDoubleSpinBox去setSuffix("%"),显示出来是"0.03%",完全不是产品要的效果。这时候唯一的正路就是子类化QAbstractSpinBox,重写textFromValue,把0.03转成字符串"3",再手动拼一个百分号后缀。后面我会写一个完整的案例来说明。
1.3 基类直接暴露的能力清单
我把QAbstractSpinBox里实际用得上的能力和入口整理了一下,用表格看起来最清楚:
| 能力/入口 | 说明 | 什么时候用 |
|---|---|---|
| stepBy(int steps) | 步进执行函数,上/下键、滚轮最终都走到这里 | 子类化时必重写 |
| stepEnabled() | 返回StepEnabledFlag,决定上下箭头是否可点 | 需要动态禁用某方向时 |
| setStepType() | StepDefault/StepOne/StepAny三种步进语义 | 要让滚轮走"任意步长"时 |
| validate(text, pos) | 返回可接受/中间态/拒绝三种校验结果 | 控制输入的合法性 |
| fixup(text) | 文本失焦后的自动修正入口 | 把"3."修正成"3.0" |
| textFromValue(value) | 内部值转显示文本 | 自定义显示格式的核心 |
| valueFromText(text) | 显示文本转内部值 | 自定义解析逻辑的核心 |
| lineEdit() | 拿到内部QLineEdit | 要操作光标、选中文本时 |
| setSpecialValueText() | 最小值时显示的替代文本 | 显示"自动""无"等语义 |
| setCorrectionMode() | 选择修正模式 | 控制非法输入的回退方式 |
有了这份清单,你会发现这个基类就像一个"半成品控件",剩下的尺寸由你自己按业务去填。接下来的几节,我重点拆一下步进和文本验证这两个最核心的机制,因为你只有把它们吃透了,才能写出真正能用的自定义步长控件。
2. 步进逻辑与文本验证:两步拆开看才不迷糊
2.1 步长调节到底是怎么"走"起来的
先说步进这条链路。你在界面上连续按上箭头,或者滚动滚轮,QAbstractSpinBox内部不会直接改数值,它会先走一遍"能不能走的检查",再决定下一步怎么走。
这个检查就是stepEnabled()。它返回一个QFlags,值是StepUpEnabled和StepDownEnabled的组合。默认情况下,基类会先问你子类当前能不能往上走、能不能往下走。QSpinBox的实现很朴素:如果当前值小于最大值,就允许往上;大于最小值,就允许往下。等于最大值时往上箭头自动置灰,等于最小值时往下箭头自动置灰。这一点在你直接用QSpinBox时是完全自动的,但一旦子类化,很多人会忘了维护stepEnabled(),导致数值到了边界箭头还亮着,点了又弹回去,体验很怪。
真正执行步进的是stepBy(int steps)。注意这个参数是"步数",不是"目标值"。基类已经把"按一下=1步""滚轮滚一格=1步"这类语义翻译好了,你只需要在stepBy里决定"走1步意味着数值变化多少"。QSpinBox的做法是newValue = value + steps * singleStep,QDoubleSpinBox同理。但这是"固定步长"的思路,自定义时完全可以不这么算。我在后面做的分钟秒数联动控件里,就是根据当前秒数动态决定steps到底代表多少秒的。
另外有一个跟步进强相关的枚举叫StepType,里面三个值:StepDefault、StepOne、StepAny。StepDefault是常规的单步走法;StepOne是指每次滚轮滚动都固定走一步;StepAny则是让Qt根据滚轮滚动的角度换算出一个"任意步数"传进stepBy。我建议做精细控制时用StepDefault,需要逐格微调的场景用StepOne,别去依赖StepAny的换算结果,因为不同系统滚轮速度不一样,同一个滚动角度可能触发不同步数,这在调试时很难复现。
关于加速:基类有个setAccelerated(bool),开启后长按箭头会随时间推移逐步加快步进频率,类似"按住自动连发"。这个功能在数值范围大的时候很有用,比如范围0到100000的金额输入。但要注意,它加快的是stepBy的调用频率,不是单次步长。如果你希望按钮按得越久、单次步长越大,那得自己在stepBy里维护一个"按住时长动态增大步长"的逻辑,基类帮不了你。
2.2 validate和fixup:输入合法性的一场攻防战
步进只是数值变化的一个来源,另一个更重要的来源是用户直接在行编辑器里敲字。QAbstractSpinBox的文本处理分三个阶段:输入时校验、提交时解析、失焦时修正。
输入时校验对应validate(QString &text, int &pos)这个虚函数。它每次按键后都会被调用,返回三个状态之一:QValidator::Acceptable表示当前文本完全合法,Intermediate表示文本暂时可以接受但还没形成一个完整合法值,Invalid表示完全不能接受。典型的Intermediate例子是用户输入了一个"3.",此时数字没完成但过程合理,应该允许继续输入;而输入"abc"就是不合法,基类会直接拒绝这个按键。
这里有个很多教程没强调的细节:validate的第一个参数是QString&,不是const,因为校验器在返回Invalid之前可能会顺手把文本改掉。比如签入一个只允许0-9的写法时,你完全可以在validate里把非数字字符直接剔除,然后把text改成清理后的版本再返回Acceptable。但pos参数必须跟着同步调整,否则用户的光标会突然跳到一个诡异的位置。
提交时的解析走valueFromText。基类的默认流程是:文本确认提交时调用valueFromText把字符串解析成数值,然后rangeCheck去检查是否在最小最大值之间,不在的话根据correctionMode来决定是回退到上一个合法值,还是就近取最近的一个合法值。QAbstractSpinBox::CorrectToPreviousValue是回退,CorrectToNearestValue是取最近值。我实际项目里用的是CorrectToPreviousValue,因为范围输入通常不希望用户敲到一半被"就近取整"悄悄改了数。
失焦时的修正走fixup。很多人会忽略这个函数,但它在体验上非常重要。用户输了个"3."然后直接Tab走,此时validate可能已经返回过Acceptable(因为"3."在解析时可以视作3.0),但如果不做fixup,文本会一直停在"3.",提交出来就变成"3.",看着很业余。我一般会在fixup里把文本规范化,比如补全小数位、去掉多余空格、把中文符号转成英文符号,然后把清理后的文本重新解析并更新。
2.3 textFromValue和valueFromText:显示逻辑与存储逻辑的脱钩
基类把"内部存的值"和"界面上显示的字"完全分开了,这可能是整个QAbstractSpinBox最有价值的设计,也是很多自定义功能的基础。举个例子,QSpinBox内部存的是int,但显示时可以带上下标、带上千分位、带任意前后缀;QDoubleSpinBox内部存的是double,显示时可以控制小数位、控制是否显示尾随零。这些全是textFromValue的功劳。
textFromValue是把"值"变成"字符串"的唯一通道,你在界面上看到的所有文本都得经过它。价值就在于你可以在这个函数里做任意格式化。我当时做税率控件时,textFromValue里就把0.12转成"12",然后在外层setSuffix("%");做金额控件时,内部存double,textFromValue用QString的千分位格式化函数打点,顺手去掉多余的零。
反过来valueFromText负责把字符串变回值。这里有几个常见的主意点:一是要容忍各种格式,比如用户可能输入"1,000",你要先把逗号去掉再toDouble;二是要考虑前/后缀,基类一般在调用valueFromText之前已经把前缀后缀剥掉了,但如果你用了一些奇特的prefix写法,最好还是自己在解析前做一次清理;三是一定要做边界钳制,在valueFromText里return出去的值很可能超过min/max,哪怕validate已经拦了一道,仍建议在valueFromText内部再做一次min-max裁剪,双保险。
3. 从零子类化:做一个分钟秒数联动的计时器输入控件
3.1 最简继承:只重写stepBy的起步写法
理论讲再多都不如直接写代码。我先给一个最简单的继承写法,演示"只用QAbstractSpinBox做一个不带小数的时间输入框"。这个控件的内部值是一个int表示的秒数,但界面上显示的是"12分34秒"。你看,这就绕开了QSpinBox只能显示整数的问题,因为显示格式完全自己掌控。
from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import QAbstractSpinBox, QLineEdit, QWidget class TimeSpinBox(QAbstractSpinBox): def __init__(self, parent: QWidget = None): super().__init__(parent) # 内部以"秒"为存储单位 self._seconds = 0 self.setRange(0, 59 * 60 + 59) # 最多59分59秒 self.setWrapping(False) self.setAccelerated(True) self.setButtonSymbols(QAbstractSpinBox.UpDownArrows) def setRange(self, min_val: int, max_val: int): self._min = min_val self._max = max_val if self._seconds < self._min: self._seconds = self._min if self._seconds > self._max: self._seconds = self._max self.lineEdit().setText(self.textFromValue(self._seconds)) def stepBy(self, steps: int): # 步进:每步固定1秒 new_value = self._seconds + steps if new_value < self._min: new_value = self._min if new_value > self._max: new_value = self._max if new_value != self._seconds: self._seconds = new_value self.lineEdit().setText(self.textFromValue(self._seconds)) def stepEnabled(self): flags = QAbstractSpinBox.StepNone if self._seconds < self._max: flags |= QAbstractSpinBox.StepUpEnabled if self._seconds > self._min: flags |= QAbstractSpinBox.StepDownEnabled return flags def textFromValue(self, value: int) -> str: # 内部秒数 -> "M分S秒" 显示文本 minutes, seconds = divmod(value, 60) return f"{minutes}分{seconds:02d}秒" def valueFromText(self, text: str) -> int: # "M分S秒" -> 内部秒数 import re m = re.search(r"(\d+)\s*分\s*(\d+)\s*秒", text) if m: minutes = int(m.group(1)) seconds = int(m.group(2)) return minutes * 60 + seconds return self._seconds上面这个写法已经能跑,但你一定会发现一个缺陷:直接敲键盘时,validate没重写,系统会用默认行为。而默认行为对"12分34秒"这种带中文单位文本几乎是一脸懵的,你敲个"1"它都不知道该不该接受。所以第二步关键是补上validate和fixup。
3.2 补上validate和fixup:让键盘输入也有规矩
沿用上面的TimeSpinBox,我给validate重写的时候把逻辑定为"允许用户以任意顺序输入数字,最长输入两位分钟数和两位秒数",单位符号在失焦时自动补上。因为validate的QString&参数可以就地修改文本,所以实现比较直接:
def validate(self, text: str, pos: int): # 允许用户输入过程中的中间文本 if text == "": # 空文本先放行,交给fixup兜底 return (QValidator.Intermediate, text, pos) # 去掉已有的单位符号和空格 cleaned = text.replace("分", "").replace("秒", "").replace(" ", "") if not cleaned.isdigit(): return (QValidator.Invalid, text, pos) # 最多4位数字,代表MMSS if len(cleaned) > 4: return (QValidator.Invalid, text, pos) # 超过2位数字,说明分钟可能会超过9,仍然允许,最终解析会钳制 return (QValidator.Acceptable, text, pos) def fixup(self, text: str) -> str: # 失焦时:把 "1234" 修正为 "12分34秒","5" 修正为 "0分05秒" import re cleaned = re.sub(r"\D", "", text) if cleaned == "": cleaned = "0" digits = cleaned.rjust(4, "0")[-4:] minutes = int(digits[:2]) seconds = int(digits[2:]) if seconds >= 60: minutes += seconds // 60 seconds = seconds % 60 return f"{minutes}分{seconds:02d}秒"这样处理之后,用户体验变成了:用户直接在输入框里敲"1234",四个数字会直接铺开,然后失焦瞬间变成"12分34秒";如果敲了个"90",fixup会把90秒折算成"1分30秒",而不是直接拒绝。这种"先宽松接受、失焦修正"的模式,比每敲一下按键都弹错的交互舒服太多了。
3.3 让滚轮和上下键变得"听话":步进策略的精细控制
有时业务会要求"在分钟位上滚轮时每格加1分钟,在秒位上滚轮时每格加1秒"。这个看起来像梦话的需求,QAbstractSpinBox还真能做。思路是:在stepBy里根据当前光标在lineEdit中的位置,决定这一步到底改变多少。
复用内部的lineEdit(),可以拿到当前光标位置。如果光标停在"分"字前,就认为焦点在分钟位;如果光标停留在"秒"字前,就认为焦点在秒位。然后:
def stepBy(self, steps: int): le = self.lineEdit() cursor = le.cursorPosition() # 根据光标位置判断是调"分"还是调"秒" text_before = le.text()[:cursor] if "分" not in text_before: # 光标在分钟区,走分钟步进 minute_step = 1 new_seconds = self._seconds + steps * minute_step * 60 else: new_seconds = self._seconds + steps * 1 new_seconds = max(self._min, min(self._max, new_seconds)) self._seconds = new_seconds le.setText(self.textFromValue(self._seconds)) # 尽量把光标放回原来的语义位置 if "分" not in text_before: le.setCursorPosition(le.text().indexOf("分")) else: le.setCursorPosition(le.text().indexOf("秒"))这个实现的关键是光标位置判断。实际用下来,"滚动滚轮时光标在不在那个位置"本身是个模糊概念,所以我加了另一个更实用的策略:不依赖光标,而是直接加一个自定义属性_step_unit,提供setStepUnit(60)或setStepUnit(1)来切换"分钟步进"和"秒步进"。两种方式我都试过,光标方案体验上限高但代码复杂,属性方案稳定可控。真实的订单录入项目里,我最终用的是属性方案,因为产品不会要求光标指向那么细,只要求"这个输入框选分钟模式时滚轮按分钟跳,选秒模式时按秒跳"。
我想强调的一点是:不要怕在stepBy里做复杂的分支逻辑。stepBy本身就是子类该发挥的地方,基类不限制你一定要做简单的加减法。你甚至可以在这里访问外部业务对象,根据业务状态动态改变步进效果,这才是子类化的灵活之处。
4. 接进业务表单:数值格式化、范围限制与交互细节
4.1 一个"百分比税率"控件的完整实现
回到我开头提的税率输入场景,用一个自绘的PercentSpinBox来解决"显示3%"、内部存0.03的问题。
class PercentSpinBox(QAbstractSpinBox): valueChanged = pyqtSignal(float) def __init__(self, parent=None): super().__init__(parent) self._value = 0.0 self._min = 0.0 self._max = 1.0 self._decimals = 2 self.setSuffix("%") self.setCorrectionMode(QAbstractSpinBox.CorrectToPreviousValue) self.lineEdit().setText(self.textFromValue(self._value)) def setValue(self, val: float): val = max(self._min, min(self._max, val)) if abs(val - self._value) < 1e-9: return self._value = val self.lineEdit().setText(self.textFromValue(val)) self.valueChanged.emit(val) def value(self) -> float: return self._value def textFromValue(self, value: float) -> str: # 0.125 -> "12.5" -> 界面显示 "12.5%" text = f"{value * 100:.{self._decimals}f}".rstrip("0").rstrip(".") return "0" if text == "" else text def valueFromText(self, text: str) -> float: # "12.5%" -> 0.125 cleaned = text.replace("%", "").replace(" ", "") try: val = float(cleaned) / 100.0 except ValueError: return self._value return max(self._min, min(self._max, val)) def stepBy(self, steps: int): # 税率调整:每步调0.5个百分点 new_val = self._value + steps * 0.005 new_val = max(self._min, min(self._max, round(new_val, 4))) self.setValue(new_val) def stepEnabled(self): flags = QAbstractSpinBox.StepNone if self._value < self._max: flags |= QAbstractSpinBox.StepUpEnabled if self._value > self._min: flags |= QAbstractSpinBox.StepDownEnabled return flags def validate(self, text: str, pos: int): cleaned = text.replace("%", "").replace(" ", "") if cleaned == "": return (QValidator.Intermediate, text, pos) try: val = float(cleaned) except ValueError: return (QValidator.Invalid, text, pos) if val < 0 or val > 100: # 超过0~100视为中间态,允许用户删回 return (QValidator.Intermediate, text, pos) return (QValidator.Acceptable, text, pos) def fixup(self, text: str) -> str: cleaned = text.replace("%", "").replace(" ", "") if cleaned == "": cleaned = "0" try: val = float(cleaned) except ValueError: val = self._value * 100 val = max(0.0, min(100.0, val)) text = f"{val:.{self._decimals}f}".rstrip("0").rstrip(".") return "0" if text == "" else text + "%"这个控件放在表单里,用户输入"12.5"、按下回车或者Tab走人,界面就稳定显示"12.5%",内部拿到0.125,直接用value()去参与计算,再也不用来回解析字符串了。
4.2 文本提交时机与键盘追踪的取舍
QAbstractSpinBox有setKeyboardTracking(bool),这个属性直接影响valueChanged信号什么时候发。默认是true,也就是用户每敲一个数字,控件就会尝试解析当前文本并发射一次valueChanged。这对需要即时计算的场景(比如"输入金额后下面立刻显示税额")是方便的;但对某些需要"确认后再计算"的业务,比如订单数量、费率设置,经常会遇到用户输入"125"的过程中连续触发了"1""12""125"三次计算,表单里别的字段被迫跟着抖三次。这种场景把keyboardTracking关掉,信号就只在用户按回车或控件失焦时才发射,体验会稳得多。
我做的项目里凡是带联动计算的输入框,都设了setKeyboardTracking(False),然后依赖valueChanged做一次最终联动。如果你需要更精确地控制提交时机,还可以直接拦截lineEdit()的returnPressed信号,在用户按回车那一刻集中处理,这也是很实用的变体。
4.3 特殊值显示与空值语义
QAbstractSpinBox还有个很有意思的接口setSpecialValueText(),它允许当内部值等于最小值时,不显示数字文本,而是显示一行自定义文字。我做"批次数量"输入时用到过:数量为0时显示"自动",为1显示"1批",大于等于2显示"2批,每批XX个"。这个特性的实现其实不需要重写什么虚函数,只要在textFromValue里判断一下value是否等于最小值,返回特殊字符串即可。不过要注意,特殊文本状态下,lineEdit()里的内容不是数字,如果你在textFromValue之外又通过lineEdit().text()去取数,会拿到"自动"这种非数字字符串。每次都要记得走value()而不是去解析文本框。
5. 高频踩坑记录:文本同步、焦点丢失与数值漂移
5.1 派发信号后文本没更新的"幽灵"问题
子类化QAbstractSpinBox之后最容易遇到的一个问题,就是程序里改了内部数值,但界面上LineEdit的文本还是旧的,或者反过来。究其原因,是"内部值"和"行编辑文本"之间并没有自动绑定。QSpinBox之所以没这个感觉,是因为它内部维护了一套同步机制;但你在子类里自己setText之后,系统并不知道你的内部值已经变了,必须在setText的同时把内部值也更新掉,并且保持一致。
我推荐一个统一的写值入口,像我在PercentSpinBox里写的那样:setValue统一负责更新内部值、更新文本、发信号这三件事,其他任何地方都不要直接改self._value或者直接setText,所有路径都走setValue。这样就把"状态分散"的风险锁死在一处。这是我从一次线上bug里学到的:当时有两个业务模块分别通过两条代码路径改内部值,其中一条忘了刷新文本,导致界面上显示6%,而实际参与计算的已经变成了12%,排查了很久。统一入口之后,这类问题再没出现过。
5.2 焦点切换时fixup和valueChanged的顺序问题
另一个容易踩的坑是"失焦时信号顺序"。用户在一个自绘SpinBox里输了文本然后直接点别的控件,Qt的默认行为会先调用fixup,再走valueFromText解析,然后更新内部值、发射valueChanged。如果你在别的控件里写了槽函数来处理valueChanged,此时就会执行。我一开始假设"失焦时=用户已经认了这个值",于是在valueChanged的槽函数里直接提交数据,结果发现用户只是路过点了一下别的控件,表单就被提交了。
正确做法是区分"交互中"和"确认时"。只有键盘回车或显式提交按钮才触发提交,失焦时的valueChanged只做界面刷新和规则联动,绝对不能做持久化。我在订单系统里加了这样一条约定后,误提交的bug就清零了。
5.3 区域设置、输入法和中文符号的兼容
最后这个坑比较隐蔽:QString的toDouble、toInt在处理带千分位逗号、中文全角数字、以及某些输入法输入的符号时,行为会跟你想的不一样。比如"100"(全角数字)直接toInt是会失败的,而用户在小键盘上按出的"100"又带着数字键盘的信号标记,你拿text()出来看是一样的,但统一塞进valueFromText可能就解析不出来。为稳妥,我在valueFromText里一律先做一次Unicode归一化,把全角数字和全角标点转成半角,再去清除非数值字符,然后再解析。具体代码我也不多写了,核心就是善用str.translate和unicodedata.normalize。
区域化数字格式也是个容易被忽略的点。如果你setGroupSeparatorShown(True)让数字能显示千分位,那么用户在自行输入"1,200"时,valueFromText如果直接把字符串去点然后toFloat,在有些locale下反而是把逗号当成小数点,这会使数值乘了1000。处理方法是解析前先根据locale判断逗号和点的语义,或者干脆在解析时只认某个统一的字符。我的做法是界面上千分位显示归显示,解析时一律把逗号删掉,这样不管什么locale都稳定。
5.4 步长边界值与stepEnabled不一致的体验问题
还有一个小但很影响手感的细节:设置了wrapping(循环)之后,stepEnabled要怎么处理。如果setWrapping(True),意味着到了最大值再按上箭头会跳回最小值,此时StepUpEnabled其实始终应该是true,因为始终可以往上走。但如果你照抄了QSpinBox的stepEnabled写法——"等于max就不能再上"——你就会得到一个"数值还能循环但箭头已经灰掉"的bug。正确逻辑是先判断是否启用wrapping,是的话就返回StepUpEnabled|StepDownEnabled这两个标志都置上,而不是留给基类去判断。这个坑我第一次写就踩了,花了半天才反应过来。
另外注意,配合wrapping和setSpecialValueText也有个交互边界:当最小值显示"自动"这种特殊文本时,如果wrapping从最大值跳回最小值,界面会出现"最大数字突然变成'自动'文字"的跳变。从语义上倒说得通,但对用户来说还是有点突兀。如果产品不能接受,我建议在业务层就别开wrapping。
最后分享一个我自己用的调试技巧:每次重写完textFromValue和valueFromText,我都会写一小段双向转换测试——从0到max按步长生成所有值,每个值都做一次"值→文本→值"往返,断言相等。这种测试几分钟就能写完,但能一次性把所有边界问题全部揪出来,包括负数、零、极小步长、特殊文本这些容易出事的点。比起手动在界面上按几百次,这个办法高效太多了。
QAbstractSpinBox其实并不复杂,它就是把你平时忽略的"如何调节一个数"的完整决策过程摆到了你面前。只要记住"步进走stepBy、显示走textFromValue、解析走valueFromText、校验走validate",再配合一个统一的setValue写值入口,你就能快速写出任何业务所需的步长调节器。遇到需求时别再急着拼凑QSpinBox的setSingleStep了,停下来想一想:这个调节器的步长是真的恒定不变吗?显示格式真的只是一个suffix能解决的吗?如果答案是否定的,就去子类化QAbstractSpinBox吧,它比你想的要亲民得多。