Skip to content

项目实战 II:完整设置界面

本节把前面学过的控件组装成一个真实的设置面板:音量滑条、全屏 / 垂直同步复选框、应用按钮。这比第 20 章的打砖块小得多——它的目的是演示"用 headless 控件搭 UI"的工程模式。

拆解

设置面板的布局是一列:

┌──────────────────────────┐
│  设置                     │  ← 标题
│  音频                     │  ← 分组标签
│  音量:50                 │  ← 值标签(VolumeLabel)
│  ═══════════●═══════════  │  ← Slider
│  显示                     │  ← 分组标签
│  ☑ 全屏模式               │  ← Checkbox
│  ☐ 垂直同步               │  ← Checkbox
│  [ 应用设置 ]             │  ← WidgetButton + Activate
└──────────────────────────┘

每个控件都是一个独立的函数,返回 impl Bundle。这是官方 standard_widgets.rs 示例的惯用模式——函数返回 Bundle 而不是直接 spawn,让组装代码读起来像声明式布局。

控件函数

滑条封装成 slider_widget(value, min, max)

rust
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)

rust
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 事件:

rust
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 控件的样式需要你自己维护。三个系统分别处理滑条滑块位置、音量标签文本、复选框勾选标记:

rust
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_thumbChanged<SliderValue> 过滤器,只在值变化时更新滑块位置。toggle_checkbox_style 遍历 Checkbox 的后代找到 CheckboxMark 实体,根据 Has<Checked> 决定背景色。

完整示例

Listing 29-7 是上述代码的完整版(examples/listing-29-07.rs),增加了 Settings Resource 和更细致的分组标签。运行:

console
cargo run -p ch29-ui-widgets --example listing-29-07

你会看到一个深色面板,包含可拖拽的滑条、可勾选的复选框、可点击的按钮。控制台会在点击"应用设置"时打印日志。

要点回顾

  1. headless 控件不提供样式——轨道、滑块、勾选标记的外观全靠 Node 属性 + 系统手动维护;
  2. 数据流是单向的——控件触发事件(ValueChangeActivate),你在 Observer / System 里决定如何响应;
  3. 函数返回 Bundle——每个控件封装成一个函数,组装代码声明式、可复用;
  4. 样式系统用 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 中构建有主题系统的控件库"的好参考。