Skip to content

把词排进框:换行、出框与裁切

秋白的词一句比一句长,字幕框只有那么宽。文字的“地界”由 TextBounds 组件划定——Text2d 的 required component,默认 UNBOUNDED 无界:一行字想多长就多长,遇到 \n 才折行。给了界,排版引擎就开始打理换行;换行的规矩则归 TextLayoutlinebreak 字段——LineBreak 枚举管。三只一模一样的字幕框,三种规矩:

rust
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(Camera2d);
    let zh_font = asset_server.load("fonts/book-sans-sc-regular.otf");
    let text_font = TextFont {
        font: FontSource::Handle(zh_font.clone()),
        font_size: FontSize::Px(28.0),
        ..default()
    };

    // 三只一模一样的字幕框,三种排版规矩
    let cases = [
        (220.0, "给宽度,自动换行", TextBounds::new_horizontal(560.0), LineBreak::WordBoundary),
        (0.0, "不许换行", TextBounds::new_horizontal(560.0), LineBreak::NoWrap),
        (-220.0, "宽高都给,装不下就裁", TextBounds::new(560.0, 30.0), LineBreak::WordBoundary),
    ];
    for (y, label, bounds, linebreak) in cases {
        // 字幕框:ch15 的九宫格画框
        commands.spawn((
            Sprite {
                image: asset_server.load("props/scroll-panel.png"),
                custom_size: Some(Vec2::new(620.0, 110.0)),
                image_mode: SpriteImageMode::Sliced(TextureSlicer {
                    border: BorderRect::all(12.0),
                    max_corner_scale: 4.0,
                    ..default()
                }),
                ..default()
            },
            Transform::from_xyz(80.0, y, 0.0),
            // 词是框的子实体:跟着框走,画在框上面一层
            children![(
                Text2d::new(LINE),
                text_font.clone(),
                TextColor(Color::srgb(0.24, 0.16, 0.08)),
                bounds,
                TextLayout::new(Justify::Left, linebreak),
                Transform::from_translation(Vec3::Z),
            )],
        ));
        // 旁注小签
        commands.spawn((
            Text2d::new(label),
            TextFont {
                font: FontSource::Handle(zh_font.clone()),
                font_size: FontSize::Px(18.0),
                ..default()
            },
            Transform::from_xyz(-510.0, y, 0.0),
        ));
    }
}

Listing 16-6:同一句词、三种地界——自动换行、不许换行、装不下就裁(examples/listing-16-06.rs)

console
cargo run -p ch16-text --example listing-16-06

三只字幕框:第一只词换成两行装下;第二只一行长词从框右侧伸出去,出框部分落在浅青灰背景上清晰可见;第三只只有一行,后半句被裁掉了

Figure 16-7:TextBounds 三态——new_horizontal 只管宽、NoWrap 出框、宽高都给则裁掉装不下的行

逐框看:

  • 框一:TextBounds::new_horizontal(560.0),只给宽度。词到 560 逻辑像素就折行,高度随行数自己长。这是字幕、对话框最常用的形态。注意中文的换行毫不费力——LineBreak::WordBoundary(默认值)按 Unicode 断行规则走,而汉字之间天然处处可断,所以中文文本用默认值就对了。WordBoundary 真正的用武之地是英文:它不拆单词。如果文本里混着超长英文单词或 URL,一个“单词”比框还宽时整行憋住不折,那才轮到 AnyCharacter(哪个字符越界就从哪断,不惜拆词)或 WordOrCharacter(先按词,词太长再按字符)出场。
  • 框二:LineBreak::NoWrap,不许软换行(\n 硬换行仍有效)。词成了一条长龙——而且注意它从框内左端起笔、溢出全堆在右边,不是两头均匀出界。这不是巧合:文字块的 Anchor 对齐的是 TextBounds 那个“框”(给了宽度时),Justify::Left 让每行从框的左边起笔,装不下的部分自然全甩到右侧。出框的词照画不误——画到了字幕框图片的外面,落在浅青灰清屏色上,所以这次能清楚看到它从框右侧伸出去。
  • 框三:TextBounds::new(560.0, 30.0),宽高都给。30 像素只够一行(28 号字 × 1.2 行高 ≈ 34),折行后的第二行整行消失。裁切的细则要记牢:只裁完全出界的字形——第一版示例给的高度是 76,第二行的字头还压在界内,结果两行全保留了,什么都没裁掉。源码文档对此很坦白:TextBounds 主要用来管换行,别拿它当严格的裁切框(真要硬裁,等第 28 章 UI 的 overflow)。

词是框的子实体:Listing 16-6 里 Text2d 挂在字幕框 Sprite 的 children! 里——框挪词跟着挪(第 9 章的层级),Transform::from_translation(Vec3::Z) 让词画在框上面一层(第 13 章的 z 排序)。2D 的“对话框”就是这么一对父子。

Justify 与 Anchor 的分工

TextLayout 的另一个字段 justify对齐。它和 Anchor 的关系容易缠成一团,拆开看其实泾渭分明:

  • Justify 管块内:多行文字之间,行与行怎么对齐——左齐、居中、右齐、两端撑满(Justified)。它不挪整块字的位置
  • Anchor 管块外:整块字作为一个矩形,钉在 Transform 坐标的哪个点上——和第 15 章 Sprite 的锚点一字不差。
rust
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(Camera2d);
    let zh_font = asset_server.load("fonts/book-sans-sc-regular.otf");
    let text_font = TextFont {
        font: FontSource::Handle(zh_font.clone()),
        font_size: FontSize::Px(26.0),
        ..default()
    };
    // 每个样本的 Transform 钉子位置都画一枚金色图钉作参照
    let pin = |commands: &mut Commands, x: f32, y: f32| {
        commands.spawn((
            Sprite::from_color(Color::srgb(0.91, 0.72, 0.29), Vec2::splat(8.0)),
            Transform::from_xyz(x, y, 1.0),
        ));
    };

    // 第一排:同一块两行字,三种 Justify——只挪行与行的相对位置
    let verse = "渡口\n夜话连台";
    for (x, justify) in [
        (-380.0, Justify::Left),
        (0.0, Justify::Center),
        (380.0, Justify::Right),
    ] {
        pin(&mut commands, x, 150.0);
        commands.spawn((
            Text2d::new(verse),
            text_font.clone(),
            TextLayout::justify(justify),
            Transform::from_xyz(x, 150.0, 0.0),
        ));
    }

    // 第二排:同一行字,三种 Anchor——挪的是整块字相对钉子的位置
    for (x, anchor) in [
        (-380.0, Anchor::TOP_LEFT),
        (0.0, Anchor::CENTER),
        (380.0, Anchor::BOTTOM_RIGHT),
    ] {
        pin(&mut commands, x, -150.0);
        commands.spawn((
            Text2d::new("梢公摇橹"),
            text_font.clone(),
            anchor,
            Transform::from_xyz(x, -150.0, 0.0),
        ));
    }
}

Listing 16-7:上排同一块两行字换三种 Justify,下排同一行字换三种 Anchor,金色图钉标出 Transform 的位置(examples/listing-16-07.rs)

上排三块两行文本,行间对齐方式分别为左、中、右,金钉都在文本块中心;下排三行字相对金钉分别位于右下、正中、左上

Figure 16-8:Justify 挪的是行与行的相对位置(金钉不动),Anchor 挪的是整块字相对金钉的位置

一个常见的“为什么不生效”:给单行文字设 Justify::Center 毫无反应。道理在源码文档里写明了——Justify 对齐的参照物是文本块自己的宽度,单行文字块宽就是行宽,没有可对齐的余地;除非配上带明确宽度的 TextBounds,行才有“框”可以参照。单行文字想居中,用的是 Anchor(默认 CENTER 已经是居中)。

框、行、块都安排明白了。但字幕真正的灵魂是会变——下一节让词一个字一个字地长出来。