Bevy Feathers NumberInput 数字输入控件实战:scrubbing 拖拽、限制区间与受控数值绑定的完整指南
【免费下载链接】bevyA refreshingly simple>项目地址: https://gitcode.com/GitHub_Trending/be/bevy
导读
FeathersNumberInput是 Bevy Feathers UI 组件库中的数字输入控件,本文基于其新增的 scrubbing(按住鼠标拖拽改值)能力展开,讲清该控件的支持类型、HardLimit/SoftLimit/NumberInputPrecision/Step等可选配置组件各自的语义,以及“受控组件”模式下与ValueChange事件的双向同步写法。读完本文,你可以直接用场景 DSL 组装一个具备 Blender 式交互、可限制区间、可带单位换算的数字输入框,并理解其底层状态机与拖拽速度启发式算法。
一、这次更新改了什么:向 Blender 的数字输入交互对齐
在本次重构之前,FeathersNumberInput更像一个"能输入数字的文本框"。更新后该控件从 Blender 的数字输入字段(numeric input)中借鉴了最佳交互元素,核心变化是引入了多种编辑模式并存:
- Scrubbing(拖拽改值):按住鼠标左右拖动即可连续修改数值;
- 直接键盘输入(direct keyboard entry):点击后进入编辑态,直接键入数字;
- 与此同时保留了对
f32、f64、i32、i64四种数值类型的完整支持,由NumberInputValue的枚举变体决定(见 crates/bevy_feathers/src/controls/number_input.rs)。
这意味着 Feathers 的数字输入控件在交互能力上大大贴近 Blender 的数字字段,适用于属性面板、变换工具(位置/旋转/缩放)、参数化编辑器等需要"快速、连续、精确"同时具备的场景。
二、受控组件模型:数值真相保存在应用侧
FeathersNumberInput是标准 Feathers 风格的受控控件(controlled widget)。所谓受控,指控件内部不会自动维护"当前数值",数值的真实来源始终是应用持有的某个数据结构;控件与数据之间通过两条通道保持双向同步(此设计在 number_input.rs 的文档注释中描述得很清楚):
- 用户在控件上拖拽或输入 → 控件发出
ValueChange<T>事件 → 应用事件处理器把新值写回应用数据; - 应用数据变化(无论来自该事件还是别处)→ 应用向控件实体插入
NumberInputValue组件→ 控件监听组件插入、刷新文本框内容与滑块位置。
ValueChange<T>事件定义在 crates/bevy_ui_widgets/src/lib.rs,除value外还携带一个is_final布尔标志:
- 拖拽过程中,
is_final为false,表示值在"预览/连续变化"状态; - 拖拽结束、回车提交或失焦时,
is_final为true,表示这是一个最终提交值。
源码注释特别提醒:即使is_final == false,应用也应该回写控件值(插入新的NumberInputValue),否则用户拖拽时看不到数字实时变化(见 number_input.rs)。
事件处理器的最小写法
feathers_number_input示例展示了这种"数值回写自身"的最小处理器(见 examples/ui/widgets/feathers_number_input.rs):
on( |value_change: On<ValueChange<f32>>, mut commands: Commands| { commands .entity(value_change.event_target()) .insert(NumberInputValue::F32(value_change.value)); }, )从源码看,控件通过On<Insert<NumberInputValue>>观察者number_input_on_insert_value监听组件插入:若新值与当前文本不同,就用queue_edit(TextEdit::SelectAll)+queue_edit(TextEdit::Insert(...))替换文本框内容,同时调用update_slider_pos把背景滑块条移动到对应位置(见 number_input.rs)。为减少无谓刷新,官方建议只在值真正变化时插入NumberInputValue(见 number_input.rs)。
三、行为配置组件:四件套 + 环绕模式
控件行为通过若干可选组件(直接随场景 DSL 挂到@FeathersNumberInput实体上)来配置。它们的语义是本次发布说明的核心内容,下面逐一展开。
HardLimit —— 绝对硬边界
HardLimit指定数值的绝对最小/最大范围(对应NumberInputRange的一个区间)。任何超出该范围的取值都会被clamp(截断)回边界内,无论来自拖拽、步进还是键入。
- 若该组件缺省,则使用该数据类型自身的自然范围(如
f32即整个 32 位浮点范围)。 - 按类型提供便捷构造:
HardLimit::f32(range)、HardLimit::f64(range)、HardLimit::i32(range)、HardLimit::i64(range)(见 number_input.rs)。 - clamp 行为本身定义在
NumberInputRange::clamp(见 number_input.rs)。注意若区间类型与值类型不匹配,会warn_once并原样返回值。
SoftLimit —— 仅约束拖拽的“软边界”
SoftLimit指定仅通过拖拽可到达的范围;通过键入输入的值可以超出这个范围。这正是它叫“软”的原因——它不是上限,而是拖拽舒适区。
- 例如
SoftLimit(NumberInputRange::F32(0.0..=10.0))意味着拖拽最多改到 0~10,但键盘可以输入 25.5。 - 应用场景:既希望鼠标操作限定在合理区间防止误拖,又保留高级用户直接输入越界值的灵活性。
NumberInputPrecision —— 拖拽精度量化
NumberInputPrecision(pub i32)表示拖拽时保留的小数位数:
- 值为
2→ 四舍五入到最近的 1/100; - 值为
0→ 取整; - 值为负数(如
-3)→ 四舍五入到最近的千位(见 number_input.rs)。
它的作用仅限于拖拽过程中量化数值,避免小数点后面一串数字乱跳;不影响键盘键入,也不阻止应用通过其他途径(如直接插入NumberInputValue)设置非量化值(见 number_input.rs 注释)。实现上NumberInputPrecision::round对F32/F64计算10^n因子做乘后取整再除;对整数类型保持恒等映射(见 number_input.rs)。
NumberInputStep —— 步进增量
NumberInputStep(pub f64)表示递增/递减时单次改变的增量(默认1.0)。当光标悬停在无软边界的输入框上时会出现左右两个 chevron 箭头(NumberInputDecrement/NumberInputIncrement),点击即按该步长加减;拖拽速度计算也会参考该值(见下文)。构造与默认值见 number_input.rs。
发布说明中描述的是
Step,实际实现中的组件名为NumberInputStep,二者指向同一配置语义。
NumberInputWrap —— 硬边界环绕
除四件套外,实现中还提供了NumberInputWrap(NoWrap/Wrap)组件:当存在HardLimit且开启Wrap时,越界值会wrap(环绕)回区间内而非被 clamp,典型用例是角度/朝向这类周期性数值。环绕实现在NumberInputRange::wrap(基于rem_euclid,见 number_input.rs);若配置了Wrap却没有HardLimit,会告警并忽略环绕(见 number_input.rs)。
各约束的合成顺序
当一个拖拽/提交同时携带多个约束时,应用顺序是有讲究的。emit_drag_value_change中可以看到完整流水线(见 number_input.rs):
- 以拖拽起始值
base_value为基准,累加偏移得到候选值(每次拖拽是相对拖拽开始值的增量,而不是相对上一帧值); - 若有
SoftLimit→ clamp 进软范围; - 若有
NumberInputPrecision→ 按小数位量化; - 最后处理硬边界:
HardLimit+Wrap则环绕,否则 clamp。
硬边界"最后应用",确保任何路径产出的值都不可能越出硬边界。
四、两种交互外观:Slider 模式与 Scrubber 模式
控件的外观与拖拽手感取决于是否配置SoftLimit(这是本次发布说明区分出的两种模式)。
有 SoftLimit:看起来像一个滑块
当SoftLimit存在时,控件观感与手感都更接近 FeathersSlider:
- 输入框背景会绘制一条滑轨(slide bar),数值所在位置有一条与滑块 thumb 等宽的“已填充”条;
- 拖拽速度按公式
(范围长度 / 滑轨像素宽度)计算(见 number_input.rs),也就是鼠标移动多少像素,值就按比例覆盖多少范围,鼠标位移与滑轨尺寸严格同步——这正是发布说明中“changes in the bar's size will be synchronized with movement of the mouse”的含义; - 滑轨位置由
update_slider_pos依据NumberInputRange::thumb_position计算出的 0~1 比例实时更新到BackgroundGradient的渐变色标上(见 number_input.rs),这种"用渐变画滑块"的做法可以让滑轨也保持圆角。
无 SoftLimit:更像一个“擦子”(Scrubber)
当SoftLimit不存在时,控件不画滑轨,表现为典型的“scrubber”:
- 拖拽速度不再由滑轨决定,而是依据一套启发式算法综合当前可用信息推断(见 number_input.rs),优先级从高到低为:
- 有
NumberInputStep→ 速度 =step × BASE_DRAG_SPEED(BASE_DRAG_SPEED = 0.01,见 number_input.rs); - 是整数类型(
I32/I64)→ 视为 step=1,使用基础速度; - 有
NumberInputPrecision→ 由精度反推速度 =10^(-precision); - 以上都没有 → 使用自适应算法:取当前值绝对值的数量级(距 1 最近的 10 的幂)作为量级缩放,
BASE_DRAG_SPEED × 10^decade,保证大数拖得快、小数拖得慢。
- 有
增强细节:Shift 慢速微调
scrubber_on_drag中还有一个实用设计:拖拽时按住Shift,位移增量乘以0.1,实现慢速精细调节(见 number_input.rs)。
五、点击 vs 拖拽:内部状态机如何区分两种手势
两种模式共同的前提是:需要区分"用户是想拖拽改值"还是"想点一下进入输入模式"。控件用一个DragState组件 +EditMode枚举(Idle/Scrubbing/Editing,见 number_input.rs)管理状态迁移,并定义一个DRAG_THRESHOLD_DISTANCE = 0.5像素阈值(见 number_input.rs)。
从事件处理器可以梳理出完整的判定流程:
- 输入框内部有一个绝对定位、覆盖整个输入区、用于拦截指针事件的透明子实体(scrubber),承载 press/release/drag_start/drag/drag_end/drag_cancel 一系列观察者(见 number_input.rs);
scrubber_on_press:非编辑态下按下 → 进入Scrubbing模式并把TextReadWriteMode切为Static(隐藏光标),同时propagate(false)阻止事件继续传给底层文本编辑(见 number_input.rs);scrubber_on_drag:实时记录累计最大位移max_distance,只有当位移超过 0.5px 阈值才开始改值(防止点击时的抖动误触发)——这是“click vs drag”判定的核心(见 number_input.rs);scrubber_on_release:若整次按下释放过程中max_distance从未超过阈值 → 视为一次点击,切换进Editing模式:恢复TextReadWriteMode::Editable、光标变成 I-Beam,并根据点击位置把文本光标MoveToPoint定位过去(见 number_input.rs);- 输入过程中若发生
PointerCancel(如窗口失焦打断),scrubber_on_drag_cancel会把状态还原为Idle(见 number_input.rs)。
拖拽/悬停时控件的滑轨与配色由set_slidebar_styles统一刷新:它根据 disabled / pressed / hovered / focused 状态在SLIDER_BAR_*、SLIDER_BG_*、TEXT_INPUT_TEXT_*等主题 token 之间切换颜色,并同步设置系统光标形状(禁用时NotAllowed,否则ColResize,见 number_input.rs)。
点击进入编辑模式后的行为
非拖拽点击会激活typing 模式,用户可以输入数字直接改值:
- 回车(
Enter)提交:number_input_on_enter_key把当前文本按控件声明的格式解析,应用硬边界与环绕约束后触发一次is_final = true的ValueChange,并把模式切回Idle(见 number_input.rs); - 失焦提交:
number_input_on_focus_lost走同样的解析提交路径并恢复光标与只读模式(见 number_input.rs); - 解析失败时只
warn!("number input parsing failed, invalid format")并放弃该次提交(见 number_input.rs); - 若文本被清空为空串,则不触发事件(见 number_input.rs)。
六、类型支持与格式解析:NumberInputValue 是如何分派的
NumberInputValue是承载数值的组件,也是一个带类型的枚举:
pub enum NumberInputValue { F32(f32), F64(f64), I32(i32), I64(i64), }默认值为F32(0.0)(见 number_input.rs)。由于FeathersNumberInput通过#[require(NumberInputValue)]要求该组件,创建控件时只需直接插入一个带值的NumberInputValue变体即可设定初始值并决定控件的数据格式(见 number_input.rs)。
NumberFormat枚举(默认F32)记录当前编辑格式,它决定发出的ValueChange<T>泛型类型:trigger_value_change会按枚举变体把commands.trigger(ValueChange { source, value, is_final })分派为ValueChange<f32>/ValueChange<f64>/ValueChange<i32>/ValueChange<i64>四者之一(见 number_input.rs)。事件中携带的source实体即FeathersNumberInput根实体,应用可以据此区分是哪个输入框发出的变化。
整数变体的加减采用saturating_add防止溢出(见 number_input.rs)。
七、带单位的数字输入:Units 注册表与展示换算
除了裸数值,实现还提供了成熟的单位系统,让同一个控件既显示"45°"又能以"45d"编辑:
NumberInputUnits:挂在控件上的引用组件,内容是一个字符串 id(如"length_meters"、"angle_degrees"),指向注册表中某个UnitsFormat(见 number_input.rs);UnitsFormattrait:定义id()、format(value, editing)(按展示/编辑两种模式格式化字符串)、parse(...)(把带后缀字符串解析回规范单位数值),见 number_input.rs;StandardUnitKind:以"后缀表 + 换算比例表"的形式便捷实现UnitsFormat,并支持canonical_index(内部存储单位,如弧度)/display_index(展示单位,如度)/editing_index(编辑单位,如 "d")三套索引分离,见 number_input.rs。
内置了三组标准单位(见 number_input.rs):
| 类型 | id | 典型后缀 | 内部规范单位 |
|---|---|---|---|
Dimensionless | "none" | 无 | — |
LengthMeters | "length_meters" | m / km / cm / mm / ft / in / mi | 米 |
TimeSeconds | "time_seconds" | s / ks / cs / ms | 秒 |
AngleDegrees | "angle_degrees" | rad / ° / d / deg | 弧度(显示为度) |
有意思的细节:角度单位内部规范量是弧度,但展示时输出45°;由于°不方便键入,编辑模式会自动把内容替换为45d(display_index与editing_index分离正是为此,见 number_input.rs)。scrubber_on_release在进入编辑态前也会先把显示文本替换为"可编辑格式",并在结束时换回展示格式(见 number_input.rs)。
这些单位对象由UnitsRegistry资源集中管理,NumberInputPlugin在Plugin::build时默认注册了上述四者,并挂接两个系统在PickingSystems::Last、InputFocusSystems::Dispatch之后同步滑轨配色(见 number_input.rs)。注册表支持通过insert注入自定义单位(见 number_input.rs)。解析/格式化的正确性有单元测试兜底,例如test_length_meters_convert_km_to_m("1km" → 1000m)、test_angle_degrees_format(π/4 rad → "45°" / 编辑时 "45d")、test_parse_negative_numbers("-50.5cm" → -0.505m)等(见 number_input.rs)。
八、从零搭一个输入框:完整示例
控件本身是一个可继承的 Scene 组件(#[derive(SceneComponent)],见 number_input.rs),在 bsn 场景 DSL 中用@FeathersNumberInput生成。仓库中的feathers_number_input示例(examples/ui/widgets/feathers_number_input.rs)把各种配置组合平铺展示,节选如下:
fn demo_root() -> impl Scene { bsn! { ... Children[ @demo_field_f32("soft limit", 2.0, bsn!( SoftLimit(NumberInputRange::F32(0.0..=10.0)) )), @demo_field_f32("hard limit", 3.0, bsn!( HardLimit(NumberInputRange::F32(-100.0..=100.0)) )), @demo_field_f32("soft + hard", 4.0, bsn!( SoftLimit(NumberInputRange::F32(0.0..=10.0)) HardLimit(NumberInputRange::F32(-100.0..=100.0)) )), @demo_field_f32("precision(2)", 6.0, bsn!( NumberInputPrecision(2) )), @demo_field_f32("hard limit + wrap", 0.0, bsn!( HardLimit(NumberInputRange::F32(-180.0..=180.0)) NumberInputWrap::Wrap )), @demo_field_f32("in meters", 2.0, bsn!( NumberInputUnits::new(&LengthMeters) )), @demo_field_f32("in degrees", PI, bsn!( NumberInputUnits::new(&AngleDegrees) )), ] } } fn demo_field_f32(label_text: &str, value: f32, options: impl Scene) -> impl Scene { bsn! { ... Children [ ( @FeathersNumberInput NumberInputValue::F32(value) @{options} Node { flex_grow: 1.0, max_width: px(120) } on( |value_change: On<ValueChange<f32>>, mut commands: Commands| { commands.entity(value_change.event_target()) .insert(NumberInputValue::F32(value_change.value)); }) ), ] } }要点拆解:
@FeathersNumberInput生成控件骨架,其内部结构(标签段、可编辑文本、透明 scrubber 层、左右 chevron)由FeathersNumberInput::scene预置(见 number_input.rs);NumberInputValue::F32(value)直接设置初始值与数值格式;- 可选行为组件(
SoftLimit、HardLimit、NumberInputPrecision、NumberInputStep、NumberInputWrap、NumberInputUnits、InteractionDisabled)用@{options}展开注入; - 每个输入框必须配套
On<ValueChange<f32>>(或对应泛型)处理器做受控回写; InteractionDisabled组件可让控件进入禁用态:文本只读、光标变为NotAllowed、滑轨与文字改用 disabled token(相关逻辑见 number_input.rs)。
需要 f32 / i32 标准字段时,也可以直接复用仓库封装好的 helper(注意它们需要bevy_feathersfeature 开启):examples/helpers/number_input_f32.rs 提供number_input_f32(name, identifier, value, precision, limits),内部组合了HardLimit::f32(limits)与可选标识组件;examples/helpers/number_input_i32.rs 提供number_input_i32(...)。标识组件用来在多输入框场景中区分事件来源——查询哪个带number_input_identifier的FeathersNumberInput是ValueChange的source实体即可。
视觉定制:sigil 与 label
创建控件时还可通过FeathersNumberInputProps定制两处外观(见 number_input.rs):
sigil_color:输入框左侧的一条彩色竖条(默认透明),常用于区分不同坐标轴(如 X/Y/Z 分别用不同 token 着色,示例中用的是tokens::TEXT_INPUT_X_AXIS);label_text:sigil 右侧的静态说明文字,惯例填 "X" / "Y" / "Z",带 label 时 sigil 会加宽为 4px 的左边框。
在 bsn 中使用 props 的写法是属性语法:@FeathersNumberInput { @sigil_color: ..., @label_text: "X" }(见 examples/ui/widgets/feathers_number_input.rs)。
运行示例
在启用bevy_feathersfeature 的前提下,可用 cargo 直接运行该示例:
cargo run --example feathers_number_input --features bevy/bevy_feathers(具体 feature 名称以仓库 Cargo.toml 中的配置为准。)
九、升级迁移:用插入组件取代触发事件
如果你是从旧 API 迁移到本次重构版本,需要特别注意程序化更新数值的通道变了:旧版本通过触发UpdateNumberInput事件来更新值,新版本改为插入NumberInputValue组件(详见 迁移指南)。后者在创建时指定初始值也更容易:
// BEFORE(旧 API,已废弃) commands.trigger(UpdateNumberInput { entity: input_ent, value: NumberInputValue::F32(new_value), }); // AFTER(新 API) commands .entity(input_ent) .insert(NumberInputValue::F32(new_value));其余不变:事件处理器仍然监听ValueChange<T>;ValueChange中is_final = false(拖拽中)与= true(提交)的语义配合受控回写,可以实现"拖拽实时预览、松开才落库"的编辑器级体验。
结语
把本次发布说明与 number_input.rs 的实现对照来看,FeathersNumberInput的交互模型可以概括为三句话:可选组件决定能力(限制/精度/步进/单位/环绕),有无SoftLimit决定观感(滑块还是擦子),受控回写决定数据流(事件外发 + 组件插入回流)。借助0.5px的手势阈值与EditMode状态机,点击与拖拽被可靠区分;借助启发式拖拽速度与 Shift 微调,从千分位小值到十万级大值都能用一只手顺滑调整。这套能力对需要高密度数值编辑的工具型 UI 非常实用,值得在属性面板、变换 gizmo 或参数化设置界面中优先采用。
【免费下载链接】bevyA refreshingly simple>项目地址: https://gitcode.com/GitHub_Trending/be/bevy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考