首页
/ Bevy 可编辑文本滚动机制迁移:从 TextScroll 组件到 EditableText::viewport

Bevy 可编辑文本滚动机制迁移:从 TextScroll 组件到 EditableText::viewport

2026-09-05 19:35:49作者:咎竹峻Karen

在 Bevy 的 UI 文本编辑(EditableText)系统中,滚动状态的存储方式经历了一次结构性调整:bevy_ui::widget::TextScroll 组件被移除,取而代之的是直接内嵌在 EditableText 组件中的 bevy_text::TextViewport 视口。阅读完本文后,你将掌握旧版 TextScrollEditableText::viewport 的逐项迁移方法,并理解视口尺寸同步(sync_editable_text_viewports)与光标自动显现(caret reveal)这两项新机制的底层实现原理。

迁移内容总览

根据官方迁移指南 _release-content/migration-guides/editable_text_scrolling.md(对应 PR #24634),本次变更包含三个要点:

旧版(已移除) 新版(替代方案) 说明
bevy_ui::widget::TextScroll 组件 EditableText::viewport(类型 bevy_text::TextViewport EditableText::viewport.offsetTextScroll 的直接替代
scroll_editable_text 系统 无需替代 光标显现行为现在在 TextEdit 应用时自动处理
(无) 新系统 bevy_ui::widget::sync_editable_text_viewports 将每个 EditableText 的视口尺寸与其对应 ComputedNode 的尺寸保持同步

核心思想是:滚动不再是一个需要外部系统驱动、单独挂在实体上的"附加组件",而是可编辑文本自身状态的一部分。视口的偏移(offset)由编辑操作直接修改,视口的尺寸(size)则由布局系统自动同步。

新类型 TextViewport:滚动状态的载体

TextViewport 定义在 crates/bevy_text/src/scroll.rs

/// The region of the editable text layout visible to the user.
///
/// Scrolling changes the offset, size depends on the layout.
#[derive(Debug, Clone, Copy, Default, PartialEq, Reflect)]
pub struct TextViewport {
    /// The top-left corner of the text viewport in text-layout coordinates.
    pub offset: Vec2,
    /// The size of the viewport in text-layout coordinates.
    pub size: Vec2,
}

坐标约定来自该模块的文档说明(crates/bevy_text/src/scroll.rs):

  • 坐标处于文本布局空间(text layout space),向右、向下增长;
  • 采用"原点 + 尺寸"的表示法而非 min-max 矩形,因为尺寸一般是固定的,这样可避免浮点误差累积;
  • 若某个轴上文本布局小于视口,则该轴的 offset 会被钳制为零(不可滚动)。

offset 字段就是旧 TextScroll 的直接替代。TextViewport 提供了一组滚动方法(crates/bevy_text/src/scroll.rs),理解它们有助于在自定义系统中直接操作视口:

  • rect():把视口转成 Rect
  • clamp_inside(max):将 offset 钳制在 0..=max - size 内,防止滚动越界;
  • scroll_by(displacement, max):按位移量滚动(连续、无量化),随后钳制;
  • scroll_to(point, max):以最小移动量让指定点进入视野;
  • scroll_by_lines(scroll_lines, content_size, line_bounds):按视觉行数量滚动,支持小数行(在行间距内插值),因此能兼容换行文本和不等高行,而不会把视口"吸附"到行边界;
  • reveal_caret(caret, max, caret_margin, lines):以最小移动量让光标重新进入可见区域。

配套的 TextLineYBoundscrates/bevy_text/src/scroll.rs)描述单个视觉行的垂直边界,可直接从 Parley 的 Line 构造(from_line);scrollable_text_layout_widthcrates/bevy_text/src/scroll.rs)则根据 LineBreak 策略(NoWrap/WordBoundary 保留溢出宽度,AnyCharacter/WordOrCharacter 只允许视口宽度)决定水平方向可滚动的范围,并在必要时纳入尾部光标的位置。

滚动操作统一由 TextEdit 驱动

迁移指南指出"文本视口完全通过 TextEdit 控制"(the text viewport is controlled exclusively through TextEdits)。crates/bevy_text/src/text_edit.rsTextEdit 枚举新增了三个滚动变体:

/// Scroll vertically by the given number of visual lines, increasing downwards.
///
/// Fractional values scroll by the corresponding fraction of a visual line.
ScrollByLines(f32),
/// Scroll the minimum amount such that the given point is in view.
ScrollTo(Vec2),
/// Scroll by the given displacement
ScrollBy(Vec2),

它们的执行路径在 TextEdit::apply 中(crates/bevy_text/src/text_edit.rs):先 driver.refresh_layout() 刷新文本布局,再把 TextViewport 与可滚动内容尺寸(scroll_content_size,即布局宽高与光标矩形 max 的逐分量最大值)一起传入对应的视口方法。注意这三个滚动变体在 is_destructive() 中被归为非破坏性操作——它们只移动视野,不改变文本内容。

EditableText 组件(crates/bevy_text/src/editing.rs)则通过 pending_edits 队列与 apply_pending_edits 方法管理这些编辑:

pub struct EditableText {
    /// A [`parley::PlainEditor`], tracking both the text content and cursor position.
    pub editor: PlainEditor<TextBrush>,
    /// The bounds of the visible portion of the text layout.
    pub viewport: TextViewport,
    /// Text edit actions that have been requested but not yet applied.
    pub pending_edits: Vec<TextEdit>,
    // ...
    /// Cursor reveal margins as fractions of the viewport size.
    pub cursor_margin: Vec2,
    // ...
}

典型用法是通过 queue_edit 把滚动请求排队,随后由 Bevy 内部的 apply_text_edits 系统在更新周期的正确时机统一应用:

use bevy_text::{EditableText, TextEdit};

fn scroll_input_focus_to_bottom(
    mut text_input: Query<&mut EditableText, (With<InputFocus>, With<Focus>)>,
) {
    if let Some(mut editable) = text_input.get_single_mut() {
        // 向下滚动 3 个视觉行
        editable.queue_edit(TextEdit::ScrollByLines(3.0));
        // 或者按位移量滚动(单位:文本布局坐标)
        // editable.queue_edit(TextEdit::ScrollBy(bevy_math::Vec2::new(0.0, 20.0)));
        // 或者滚动到某个点(最小移动量)
        // editable.queue_edit(TextEdit::ScrollTo(bevy_math::Vec2::new(0.0, 60.0)));
    }
}

apply_pending_edits 还会正确处理异步剪贴板场景(如 wasm32 目标上的粘贴):未完成的读取会被存到 pending_paste,其余编辑保持 FIFO 顺序留待后续帧处理(见 crates/bevy_text/src/editing.rs 的注释与实现)。

光标自动显现:scroll_editable_text 系统为何被移除

旧版中需要 scroll_editable_text 系统在滚动后单独处理"让光标重新可见";现在这一步被内联到了 TextEdit::apply 的执行路径中。观察 crates/bevy_text/src/text_edit.rs 可以发现,InsertBackspaceDelete、方向键移动(Left/Right/WordLeft 等)等绝大多数编辑分支在驱动器操作之后都会紧跟一次 reveal_cursor(driver, viewport, cursor_margin) 调用

fn reveal_cursor(
    driver: &mut PlainEditorDriver<'_, TextBrush>,
    viewport: &mut TextViewport,
    cursor_margin: Vec2,
) {
    driver.refresh_layout();
    let Some(cursor) = cursor_reveal_rect(driver) else {
        return;
    };
    let layout = driver.layout();
    viewport.reveal_caret(
        cursor,
        Vec2::new(layout.full_width(), layout.height()),
        cursor_margin,
        layout.lines().map(|line| TextLineYBounds::from_line(&line)),
    );
}

这里有一个值得注意的细节:cursor_reveal_rectcrates/bevy_text/src/text_edit.rs)返回的光标矩形会额外向外扩展一个数字 0 的字形 advance 宽度(取不到字形度量时回退为 0.6 × font_size)。这意味着横向滚动时除了把光标本身滚进视野,还会为"接下来要敲的字符"预留出一个字符宽度的空间——这正是模块文档中所描述的 "keeping one 0-advance of space visible from the caret position onward" 规则。

reveal_caret 的完整显现规则(crates/bevy_text/src/scroll.rs):

  • cursor_marginEditableText 字段,默认 Vec2::splat(0.2),取值被钳制在 0.0..=0.5,即视口尺寸的比例)在视口四边内缩出一个"光标安全区",光标在该区域内移动时视口保持不动;
  • 横向越出安全区时,以最小移动量把光标滚回区内;
  • 纵向显现的是 Parley 光标矩形(覆盖整条视觉行),且偏移量会朝着滚动方向钳到下一个视觉行的起点,使上下移动光标后视口仍与某一行对齐;
  • 若光标或其所在行在某轴上大到装不进安全区,则视口在该轴上改为以它为中心;
  • 无论如何,视口不会滚出布局边界;某轴上视口大于布局时,该轴完全不滚动。

这些行为在 crates/bevy_text/src/scroll.rs 的单元测试中有大量精确断言覆盖,例如 text_view_reveal_caret_quantizes_vertical_scroll_to_lines(纵向显现会量化到行起点)、text_view_reveal_caret_rounds_margin_scroll_in_direction(边距滚动按方向取整)、text_view_reveal_caret_scrolls_horizontally_and_clamps(横向滚动与边界钳制)等;crates/bevy_text/src/text_edit.rs 的测试 cursor_navigation_reveals_cursorscroll_right_includes_caret_reveal 则验证了"键盘导航后光标自动滚回视野"以及"右侧对齐空输入框光标也能被滚进视野"这类端到端行为。

sync_editable_text_viewports:视口尺寸与 UI 布局的同步

视口的 offset 由编辑操作驱动,而 size 则由 bevy_ui 的新系统维护。sync_editable_text_viewports 定义在 crates/bevy_ui/src/widget/text_input_layout.rs

/// Syncs each [`EditableText`]'s viewport size with their `ComputedNode`'s content size before text edits are applied.
pub fn sync_editable_text_viewports(mut query: Query<(&mut EditableText, &ComputedNode)>) {
    for (mut editable_text, computed_node) in &mut query {
        let size = computed_node.content_box().size();
        if editable_text.viewport.size != size {
            editable_text.viewport.size = size;
            editable_text.editor.set_width(Some(size.x));
        }
    }
}

该函数做了两件事:把视口尺寸同步为对应 UI 节点 content box 的尺寸;当尺寸变化时顺带更新 Parley PlainEditor 的布局宽度(set_width),保证文本换行依据的是最新视口宽度。注意文档注释强调它运行在"文本编辑被应用之前"——在 crates/bevy_ui/src/lib.rs 中它与 update_editable_text_layout 等文本布局系统一同注册,而 crates/bevy_ui_widgets/src/text_input.rs 中也显式使用了 .after(sync_editable_text_viewports) 来保证输入组件的后续系统读到的是最新视口尺寸。这个顺序保证了:每一帧编辑发生时,clamp_insidereveal_caret 等操作所使用的 size 始终与 UI 节点实际尺寸一致。

与之配合的还有 update_editable_text_layoutcrates/bevy_ui/src/widget/text_input_layout.rs):当获得输入焦点时立即调用 viewport.reveal_caret 把光标滚入视野,之后每帧都会按 scrollable_text_layout_width 的结果把 offset 重新钳制在可滚动范围内。也就是说,视口偏移的"上限检查"既有编辑路径中的即时钳制,也有布局系统每帧的兜底钳制,双保险防止任何途径(包括你手动写入 viewport.offset)造成越界。

迁移实操:旧写法与新写法的对照

迁移时通常涉及两类代码:读取/写入滚动位置,以及移除对已删除系统/组件的引用。

1. 移除 TextScroll 的插入与读取,改写 viewport.offset

旧版典型模式是把一个 TextScroll 组件挂到实体上,并在系统里更新它(可能还依赖 scroll_editable_text)。新写法直接操作 EditableText

use bevy_text::{EditableText, TextEdit};
use bevy_ui::widget::sync_editable_text_viewports; // 无需手动注册,默认插件已包含

// 读滚动位置(直接替代旧的 TextScroll 值)
fn get_scroll_offset(q: Query<&EditableText>) -> bevy_math::Vec2 {
    q.single().viewport.offset
}

// 直接写滚动位置:写入后会被布局系统的钳制逻辑约束在可滚动范围内
fn jump_scroll(q: Query<&mut EditableText>) {
    q.single_mut().viewport.offset = bevy_math::Vec2::new(0.0, 40.0);
}

2. 若原来自己实现了滚动逻辑,改为排队 TextEdit

// 旧:自行计算 TextScroll 并 insert —— 现在应改用标准编辑通道
fn on_page_down(q: Query<&mut EditableText>) {
    q.single_mut().queue_edit(TextEdit::ScrollByLines(-4.0));
}

3. 删除对 scroll_editable_text 的引用(例如自己注册系统时设置的 After(scroll_editable_text) 排序约束),光标显现现在由编辑应用自动完成,无需补偿逻辑。

迁移验证可以直接参考仓库内的现成用例:bevy_text 的滚动测试(cargo test -p bevy_text scroll)覆盖了行滚动、小数行、边界钳制、光标显现的全部规则;bevy_ui_widgetscrates/bevy_ui_widgets/src/text_input.rs 则演示了输入组件如何以 sync_editable_text_viewports 为顺序基准编排自己的系统。

小结

这次迁移把可编辑文本的滚动从"独立组件 + 独立系统"的两段式结构,收敛为 EditableText::viewport 单一状态源:offsetTextEditScrollBy/ScrollTo/ScrollByLines)与编辑时的自动 reveal_cursor 共同维护,sizesync_editable_text_viewports 与 UI 布局同步,越界由 clamp_inside 及布局系统的每帧钳制兜底。对升级项目而言,只需做三件事:把 TextScroll 的读写替换为 viewport.offset、删掉 scroll_editable_text 相关引用、把自定义滚动逻辑迁移到 TextEdit 队列。

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