首页
/ Bevy 0.20 UI 迁移指南:Button 与 Interaction 的弃用及 Hovered/Pressed 替代方案

Bevy 0.20 UI 迁移指南:Button 与 Interaction 的弃用及 Hovered/Pressed 替代方案

2026-09-05 20:32:54作者:翟萌耘Ralph

本篇围绕 Bevy 迁移文档 _release-content/migration-guides/deprecate_button_and_interaction.md(对应 PR #25197)展开:ui::widgets::Buttonui::Interaction 已被弃用,官方推荐分别改用 ui_widgets::Buttonpicking::hover::Hovered + ui::Pressed 组件。读完后你将了解这两个旧 API 的源码级弃用机制、新无头(headless)按钮组件的事件模型,以及如何按官方 button.rs 示例把旧代码迁移到新的交互状态方案。

弃用的范围:旧 Button 与 Interaction 是什么

ui::widgets::Button:旧版 UI 按钮组件

ui::widgets::Button 原本可通过 bevy::ui::prelude 直接引入,是一个"按钮标记组件",通常配合轮询 Interaction 组件来驱动按钮外观。在当前仓库源码中,它已被改造为一个带弃用标注的类型别名:

  • crates/bevy_ui/src/widget/button.rs:实际实现是私有的 DeprecatedButton 结构体(#[require(Node, FocusPolicy::Block)]),而对外暴露的 pub type Button = DeprecatedButton 上挂着 #[deprecated(since = "0.20.0", note = "Use ui_widgets::Button.")](见 button.rs 第 24 行)。
  • crates/bevy_ui/src/lib.rsui 的 prelude 仍然再导出 widget::ButtonInteraction,但整块被 #[expect(deprecated, ...)] 包裹,注释明确写着 "Should be removed after 0.20 is released when Button & Interaction are removed",说明这两个别名属于过渡期兼容层。

代码里采用"type alias 弃用"这一手法的原因在源码注释中写得很清楚:直接给 DeprecatedButton 结构体加 deprecated 会触发无法用 #[expect] 压制的内部 lint,所以用别名来承载弃用属性。这意味着旧代码在弃用期内仍可编译(只是产生 deprecation 警告),属于平滑过渡,而非立即删除。

另一个值得注意的细节:DeprecatedButton 通过 #[require(InteractionTemp)] 仍然要求实体上存在 Interaction 组件——这体现了旧按钮与旧交互状态系统之间的耦合,也是本次迁移中两者被一起替换的原因。

ui::Interaction:旧版交互状态枚举

Interaction 原本是一个枚举组件,表示 UI 元素与指针的交互状态,旧版示例普遍用 Changed<Interaction> 过滤器来检测状态跳变。当前仓库中它同样以类型别名形式保留:

  • crates/bevy_ui/src/focus.rspub(crate) enum DeprecatedInteraction 定义枚举本身,pub type Interaction = DeprecatedInteraction; 作为对外别名。从源码中引用的变体看,它包含 NoneHoveredPressed 三个状态。
  • 同一文件中还保留了负责"根据鼠标光标活动为所有 UI 元素设置 Interaction"的旧系统(focus.rs 第 165 行起 附近的 set_interaction_states 逻辑),它会按点击/悬停把实体上的 InteractionNone/Hovered/Pressed 之间切换。这说明弃用期内旧行为仍然生效,迁移不会导致旧 UI 突然失效。

替代方案一:ui_widgets::Button 无头按钮组件

bevy_ui_widgets 是仓库中独立的组件 crate(见 crates/bevy_ui_widgets/src/lib.rs),提供一套"只管行为、不管外观"(behavior-only)的 widget 组件。新版 Button 正是其中的核心成员:

  • 组件定义在 crates/bevy_ui_widgets/src/button.rs:一个"无头按钮 widget,维护自身的 pressed 状态,并在按钮释放(un-press)时发出 Activate 事件"。它 #[require] 了一个 accesskit::Node::new(Role::Button)AccessibilityNode,即无障碍支持是组件自带的。
  • 状态维护完全基于 ECS 观察者(observer)而非每帧轮询,见 button.rs 第 141-153 行ButtonPlugin,它注册了 6 个 observer:
    • button_on_pointer_down:监听 PointerPress,未禁用且未处于按下状态时插入 Pressed 组件;
    • button_on_pointer_up / button_on_pointer_drag_end / button_on_pointer_cancel:监听 PointerReleasePointerDragEndPointerCancel,移除 Pressed 组件;
    • button_on_pointer_click:监听 PointerClick(一次完整点击),仅在"按下过且未禁用且未开启 press 激活"时触发 Activate { entity } 事件;
    • button_on_key_event:监听焦点实体的键盘输入,EnterSpace(非重复)按键同样触发 Activate
  • 事件定义 pub struct Activate 位于 crates/bevy_ui_widgets/src/lib.rs
  • 可选标记组件 ActivateOnPressbutton.rs 第 37 行):让按钮在按下瞬间(pointer down)而非释放时激活,适合菜单按钮这类"即按即发"的场景。
  • 禁用支持:插入 bevy::ui::InteractionDisabled 标记组件即可让按钮停止响应激活(该组件定义见 crates/bevy_ui/src/interaction_states.rs,它还会同步把对应的 AccessibilityNode 标记为 disabled)。

DefaultPlugins 已包含 bevy_ui_widgets 的插件,因此按钮行为开箱即用,无需额外注册。

替代方案二:用 Hovered + Pressed 组件替代 Interaction

弃用 Interaction 后,交互状态被拆分为两个独立的、基于 ECS 变更检测的组件:

  1. picking::hover::Hovered:定义在 crates/bevy_picking/src/hover.rs,形如 Hovered(pub bool),由 picking 后端负责维护。源码文档说明:
    • 语义与 CSS :hover 一致——指针悬停在实体或其任意后代上时均为 true;
    • 布尔值只在指针进入/离开时变化,因此可以高效配合 Bevy 的 change detection 使用(对比每帧都会变化的 HoverMap 资源);
    • 提供 hovered.get() 访问器。
    • 使用方式是把 Hovered::default() 插入到关心悬停状态的实体上。
  2. bevy::ui::Pressed:定义在 crates/bevy_ui/src/interaction_states.rs,是一个标记组件(marker component),表示按钮或 widget 当前处于"被按住"状态。查询时用 Has<Pressed> 即可判断是否存在。

两者的语义正好覆盖旧枚举:Interaction::Hoveredhovered.get() == trueInteraction::PressedHas<Pressed> 为 true;Interaction::None → 两者皆否。原来"一个枚举 + Changed<Interaction> 过滤器"的轮询模式,被替换为"两个组件 + 每帧读取(或 observer 观察组件变化)"的模式。

官方示例:迁移后的完整写法

迁移文档指引读者"查看更新后的 button.rs 示例",即 examples/ui/widgets/button.rs。该示例完整演示了新 API 的组合用法,可直接作为迁移模板:

use bevy::{
    color::palettes::basic::*,
    picking::hover::Hovered,
    prelude::*,
    ui::Pressed,
    ui_widgets::{observe, Activate, Button},
};

fn setup(mut commands: Commands, assets: Res<AssetServer>) {
    commands.spawn(Camera2d);

    commands.spawn((
        Node {
            width: percent(100),
            height: percent(100),
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center,
            ..default()
        },
        children![(
            button(&assets),
            // 按钮完成一次点击时触发 Activate 事件,用 observer 直接挂在按钮实体上
            observe(|_activate: On<Activate>| {
                info!("Button clicked!");
            }),
        )],
    ));
}

fn button(asset_server: &AssetServer) -> impl Bundle {
    (
        Button,                 // 无头按钮 widget:处理输入与按下状态
        Hovered::default(),     // 由 picking 后端跟踪指针是否悬停
        Node {
            width: px(150),
            height: px(65),
            border: UiRect::all(px(5)),
            justify_content: JustifyContent::Center,
            align_items: AlignItems::Center,
            border_radius: BorderRadius::MAX,
            ..default()
        },
        BorderColor::all(Color::BLACK),
        BackgroundColor(NORMAL_BUTTON),
        children![(
            Text::new("Button"),
            TextFont {
                font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
                font_size: FontSize::Px(33.0),
                ..default()
            },
            TextColor(Color::srgb(0.9, 0.9, 0.9)),
        )],
    )
}

/// 每帧按当前状态重绘外观:
/// Button 在被按住时维护 Pressed 组件,picking 后端负责更新 Hovered。
fn update_button_appearance(
    mut buttons: Query<
        (&Hovered, Has<Pressed>, &mut BackgroundColor, &mut BorderColor, &Children),
        With<Button>,
    >,
    mut text_query: Query<&mut Text>,
) {
    for (hovered, pressed, mut color, mut border_color, children) in &mut buttons {
        let Ok(mut text) = text_query.get_mut(children[0]) else {
            continue;
        };
        match (hovered.get(), pressed) {
            (_, true) => {
                **text = "Press".to_string();
                *color = PRESSED_BUTTON.into();
                border_color.set_all(RED);
            }
            (true, false) => {
                **text = "Hover".to_string();
                *color = HOVERED_BUTTON.into();
                border_color.set_all(WHITE);
            }
            (false, false) => {
                **text = "Button".to_string();
                *color = NORMAL_BUTTON.into();
                border_color.set_all(BLACK);
            }
        }
    }
}

这段示例覆盖了迁移的四个关键点:

  1. 事件替代轮询判断点击:不再需要自己比较 Interaction 的前后值来推断"完成了一次点击",直接 observe(On<Activate>) 即可,且 observer 可以精确挂到按钮实体上。
  2. HoveredHas<Pressed> 组合出三态外观match (hovered.get(), pressed) 的三分支与旧 Interaction 枚举的三态一一对应。
  3. 行为与样式解耦Button 只负责输入处理和按下状态,样式(NodeBackgroundColorBorderColor、文字)全部由开发者自行提供——这正是旧 ui::widgets::Button 与外观代码常常混在一起的模式所不同之处。
  4. 无障碍与禁用Button 自带 AccessibilityNode(Role::Button);如需禁用,插入 InteractionDisabled 并依据 Has<InteractionDisabled> 调整外观(示例文档注释还指引参考 standard_widgetsstandard_widgets_observers 两个 UI 示例)。

示例文件头部的文档注释还提示了两个进阶方向:加入 bevy::input_focus::tab_navigation::TabNavigationPluginTabGroupTabIndex 后可用 Tab 聚焦并以 Enter/Space 激活(触发同一个 Activate 事件);若偏好观察者而非每帧轮询,可用 On<Insert, Pressed> 这类组件变更 observer 来驱动外观更新。

迁移清单与兼容性注意

结合迁移文档与源码中的"0.20 发布后移除"注释,旧代码迁移可按以下清单进行:

旧写法 新写法 依据
use bevy::ui::prelude::* 引入的 Button use bevy::ui_widgets::Button bevy_ui/src/widget/button.rs 的弃用 note
查询/过滤 &Interaction / Changed<Interaction> &Hovered + Has<Pressed>(或 observer 观察 Pressed 的插入/移除) bevy_picking/src/hover.rsbevy_ui/src/interaction_states.rs
自行判断"一次完整点击" 观察 On<Activate> 事件 bevy_ui_widgets/src/button.rs
按钮按下即触发 ActivateOnPress 标记 bevy_ui_widgets/src/button.rs
禁用按钮 插入 InteractionDisabled bevy_ui/src/interaction_states.rs

适用前提与限制:

  • 弃用不等于删除:弃用期内 ui::prelude 中的 ButtonInteraction 仍可编译(见 bevy_ui/src/lib.rs),但会产生 deprecation 警告;源码注释表明这些别名计划在 0.20 发布后的后续版本中移除,因此不建议在新代码中继续依赖。
  • 行为差异:旧 Interactionbevy_ui 内部的单一系统统一驱动(见 focus.rs 附近的设置逻辑);新方案中 Pressedbevy_ui_widgets 的各 observer 在 pointer 事件上直接插入/移除,Hovered 则依赖 picking 后端——若你的项目自行实现了自定义 picking 后端,需要确认 Hovered 的更新链路仍然成立。
  • Hovered 的后代语义:与 CSS :hover 一致,指针悬停在按钮内部的子节点(如文字)上时,父按钮的 Hovered 也为 true,这在迁移时通常与旧 Interaction 的体感一致,但若有依赖"精确到单个实体"的旧逻辑,建议参考 hover.rs 中提及的不含后代的替代组件。

总体而言,这次迁移把"交互状态"从一个引擎内部统一维护的枚举,拆分成了可被 ECS 变更检测与 observer 直接消费的独立组件,并与 bevy_ui_widgets 的无头 widget 体系对齐:状态驱动事件(Activate),事件驱动逻辑,组件驱动外观。

登录后查看全文
热门项目推荐
相关项目推荐