项目实战 II:完整设置界面
本节把前面学过的控件组装成一个真实的设置面板:音量滑条、全屏 / 垂直同步复选框、应用按钮。这比第 20 章的打砖块小得多——它的目的是演示"用 headless 控件搭 UI"的工程模式。
拆解
设置面板的布局是一列:
┌──────────────────────────┐
│ 设置 │ ← 标题
│ 音频 │ ← 分组标签
│ 音量:50 │ ← 值标签(VolumeLabel)
│ ═══════════●═══════════ │ ← Slider
│ 显示 │ ← 分组标签
│ ☑ 全屏模式 │ ← Checkbox
│ ☐ 垂直同步 │ ← Checkbox
│ [ 应用设置 ] │ ← WidgetButton + Activate
└──────────────────────────┘每个控件都是一个独立的函数,返回 impl Bundle。这是官方 standard_widgets.rs 示例的惯用模式——函数返回 Bundle 而不是直接 spawn,让组装代码读起来像声明式布局。
控件函数
滑条封装成 slider_widget(value, min, max):
fn volume_row(font: &Handle<Font>) -> impl Bundle {
(
Node {
flex_direction: FlexDirection::Column,
row_gap: px(8),
..default()
},
children![
(
Text::new("音量:50"),
TextFont {
font: FontSource::Handle(font.clone()),
font_size: FontSize::Px(18.0),
..default()
},
TextColor(Color::srgb(0.8, 0.8, 0.8)),
VolumeLabel,
),
(
Node {
width: percent(100),
height: px(12),
..default()
},
Slider {
track_click: TrackClick::Snap,
orientation: SliderOrientation::Auto,
},
SliderValue(50.0),
SliderRange::new(0.0, 100.0),
children![
(
Node {
height: px(6),
border_radius: BorderRadius::all(px(3)),
..default()
},
BackgroundColor(TRACK),
),
(
Node {
display: Display::Flex,
position_type: PositionType::Absolute,
left: px(0),
right: px(12),
top: px(0),
bottom: px(0),
..default()
},
children![(
SliderThumb,
Node {
width: px(12),
height: px(12),
position_type: PositionType::Absolute,
left: percent(50),
border_radius: BorderRadius::MAX,
..default()
},
BackgroundColor(THUMB),
)],
),
],
observe(on_slider_change),
),
],
)
}
fn on_slider_change(
value_change: On<ValueChange<f32>>,
mut commands: Commands,
) {
commands
.entity(value_change.source)
.insert(SliderValue(value_change.value));
}Listing 29-6(其一):volume_row——音量标签 + 滑条
滑条的核心结构和 Listing 29-3 一样:外层 Node + Slider 组件 + SliderValue/SliderRange + 子节点(轨道 + 滑块)。Observer 监听 ValueChange<f32> 并插入新的 SliderValue。
复选框封装成 checkbox_row(font, label):
fn checkbox_row(font: &Handle<Font>, label: &str) -> impl Bundle {
(
Node {
column_gap: px(8),
align_items: AlignItems::Center,
..default()
},
Checkbox,
children![
(
Node {
width: px(18),
height: px(18),
border: UiRect::all(px(2)),
border_radius: BorderRadius::all(px(3)),
..default()
},
BorderColor::all(Color::srgb(0.4, 0.4, 0.4)),
children![(
CheckboxMark,
Node {
width: px(10),
height: px(10),
position_type: PositionType::Absolute,
left: px(2),
top: px(2),
..default()
},
BackgroundColor(Color::NONE),
)],
),
(
Text::new(label),
TextFont {
font: FontSource::Handle(font.clone()),
font_size: FontSize::Px(18.0),
..default()
},
TextColor(Color::srgb(0.8, 0.8, 0.8)),
),
],
observe(checkbox_self_update),
)
}Listing 29-6(其二):checkbox_row——带标签的复选框
按钮用 WidgetButton + Activate 事件:
fn apply_button(font: &Handle<Font>) -> impl Bundle {
(
Node {
width: percent(100),
height: px(44),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(px(1)),
border_radius: BorderRadius::all(px(6)),
..default()
},
WidgetButton,
BackgroundColor(Color::srgb(0.25, 0.55, 0.25)),
BorderColor::all(Color::srgb(0.3, 0.6, 0.3)),
children![(
Text::new("应用设置"),
TextFont {
font: FontSource::Handle(font.clone()),
font_size: FontSize::Px(18.0),
..default()
},
TextColor(Color::WHITE),
)],
observe(|_activate: On<Activate>| {
info!("设置已应用!");
}),
)
}Listing 29-6(其三):apply_button——应用设置按钮
样式更新系统
headless 控件的样式需要你自己维护。三个系统分别处理滑条滑块位置、音量标签文本、复选框勾选标记:
fn update_slider_thumb(
sliders: Query<(Entity, &SliderValue, &SliderRange), Changed<SliderValue>>,
children_q: Query<&Children>,
mut thumbs: Query<&mut Node, With<SliderThumb>>,
) {
for (entity, value, range) in &sliders {
for child in children_q.iter_descendants(entity) {
if let Ok(mut thumb) = thumbs.get_mut(child) {
thumb.left = percent(range.thumb_position(value.0) * 100.0);
}
}
}
}
fn update_volume_label(
sliders: Query<&SliderValue, Changed<SliderValue>>,
mut labels: Query<&mut Text, With<VolumeLabel>>,
) {
for value in &sliders {
for mut text in &mut labels {
**text = format!("音量:{:.0}", value.0);
}
}
}
fn toggle_checkbox_style(
checkboxes: Query<(Entity, Has<Checked>), With<Checkbox>>,
children_q: Query<&Children>,
mut marks: Query<&mut BackgroundColor, With<CheckboxMark>>,
) {
for (entity, checked) in &checkboxes {
for child in children_q.iter_descendants(entity) {
if let Ok(mut bg) = marks.get_mut(child) {
bg.0 = if checked { CHECK_MARK } else { Color::NONE };
}
}
}
}Listing 29-6(其四):三个样式更新系统
update_slider_thumb 用 Changed<SliderValue> 过滤器,只在值变化时更新滑块位置。toggle_checkbox_style 遍历 Checkbox 的后代找到 CheckboxMark 实体,根据 Has<Checked> 决定背景色。
完整示例
Listing 29-7 是上述代码的完整版(examples/listing-29-07.rs),增加了 Settings Resource 和更细致的分组标签。运行:
cargo run -p ch29-ui-widgets --example listing-29-07你会看到一个深色面板,包含可拖拽的滑条、可勾选的复选框、可点击的按钮。控制台会在点击"应用设置"时打印日志。
要点回顾
- headless 控件不提供样式——轨道、滑块、勾选标记的外观全靠 Node 属性 + 系统手动维护;
- 数据流是单向的——控件触发事件(
ValueChange、Activate),你在 Observer / System 里决定如何响应; - 函数返回 Bundle——每个控件封装成一个函数,组装代码声明式、可复用;
- 样式系统用 Changed 过滤器——只在状态变化时更新视觉,避免每帧遍历。
这个模式可以推广到任何复杂 UI:设置界面、HUD、对话框、菜单。控件是积木,布局是图纸,样式系统是装修队。
延伸:bevy_feathers
如果你在构建编辑器或检查器工具(而不是游戏 UI),Bevy 0.19 还提供了一个实验性的 styled 组件库——bevy_feathers。它为 Bevy Editor 等工具场景设计,提供了一套带主题系统、光标管理、焦点轮廓和字体样式的预制控件,包括按钮、滑条、复选框、文本输入、列表视图、菜单等。
bevy_feathers 的定位是编辑器 / 工具 UI,而非游戏 UI。它的风格偏向简洁功能性,优先保证一致性而非高度可定制。这个 crate 仍然是实验性的,API 会随版本变动。如果你对它感兴趣,可以通过 bevy_feathers feature 启用,用 FeathersPlugins 插件组引入。即使不直接使用,它的代码也是学习"如何在 Bevy 中构建有主题系统的控件库"的好参考。