egui水平居中没那么简单

> 一个 Immediate Mode GUI 的经典坑,以及四种解法

egui水平居中没那么简单

> 一个 Immediate Mode GUI 的经典坑,以及四种解法

最近在写一个视频剪切工具(dnclip),用 egui 做界面。功能本身不复杂——加载视频、预览、打 I/O 点、导出——但做到底栏的时候卡住了。

需求很简单:底栏有三组控件(时间显示、播放控制、I/O 标记),中间那一组要水平居中。

听起来像 ui.horizontal_centered() 一句话的事?我试了,没效果。

问题出在哪

先看看直观的做法:

ui.horizontal_centered(|ui| {
    ui.button("⏮");
    ui.button("◀◀");
    ui.button("▶");
    ui.button("▶▶");
    ui.button("⏭");
});

结果按钮全部挤在左侧。翻文档才发现——horizontal_centered 的"居中"是指在竖直布局的容器里,让元素水平居中,不是你想象的那种"把这一行整个摆中间"。

egui 是 Immediate Mode GUI,它的工作方式是:你告诉它放什么,它当场布局,布完就画。 它没法先画一遍量好尺寸再调整位置——因为已经画完了。常规 GUI(Retained Mode)那种"先占位、再居中"的思路,在 Immediate Mode 下走不通。

这个问题在社区里反复出现:Discussion #2916#1409、Rust Users Forum、Reddit、Stack Overflow……搜了一圈,基本可以确认:这是 egui 的老大难问题之一。

解法一览

方案 1:手动垫空间

如果控件宽度是固定的,可以直接算:

let total_width = 5 * button_size + 4 * spacing;
let space = (ui.available_width() - total_width) / 2.0;
ui.add_space(space);
// 然后放按钮

简单直接,但宽度一变化就不好使了。

方案 2:Sizing Pass(两帧法)

第一帧只算尺寸不渲染,第二帧用算出的尺寸真正画。egui 原生支持这个模式:

ui.scope_builder(
    UiBuilder::new().sizing_pass().invisible(),
    |ui| {
        // 第一帧:widget 在这里渲染但不显示
        ui.button("▶");
        // 记下尺寸
        widget_size = Some(ui.min_rect().width());
    },
);
// 第二帧:用算好的尺寸偏移
ui.add_space((ui.max_rect().width() - widget_size) / 2.0);
ui.button("▶");

好处是只渲染一次,坏处是需要额外状态、尺寸变化时会延迟一帧。

方案 3:双遍渲染(每帧画两次)

不用状态,同一帧内画第一次测得尺寸、第二次真正放:

// 第一遍:算尺寸(在不可见的 scope 里)
let measured_size = ui.scope_builder(
    UiBuilder::new().invisible(),
    |ui| { ui.button("▶"); },
).response.rect.width();

// 第二遍:按尺寸居中
ui.add_space((ui.available_width() - measured_size) / 2.0);
ui.button("▶");

省心但费性能——好在大部分场景下画两次的代价可以忽略。

方案 4:egui_alignments crate(强烈推荐)

`egui_alignments` 是个专门解决对齐问题的第三方库。它把上述逻辑封装成了开箱即用的函数:

use egui_alignments::center_horizontal;

center_horizontal(ui, |ui| {
    ui.button("⏮");
    ui.button("◀◀");
    ui.button("▶");
    ui.button("▶▶");
    ui.button("⏭");
});

就这一句。内部用的是 Ui::scope_builder + 尺寸计算,用户完全不用操心状态管理。

实战:dnclip 底栏三列布局

借这个居中方案,我把整个底栏重构了一下。先看最终效果:

// 底栏:三列等宽
ui.columns(3, |cols| {
    // 左列:时间显示(左对齐)
    cols[0].vertical_centered(|ui| {
        ui.with_layout(egui::Layout::left_to_right(egui::Align::LEFT), |ui| {
            ui.label("01:23:45.000 / 01:30:00.000");
        });
    });

    // 中列:播放控制(水平居中——终于对了)
    cols[1].vertical_centered(|ui| {
        center_horizontal(ui, |ui| {
            ui.spacing_mut().item_spacing.x = 4.0;
            ui.button("⏮");
            ui.button("◀◀");
            ui.button("▶");
            ui.button("▶▶");
            ui.button("⏭");
        });
    });

    // 右列:I/O 标记(右对齐)
    cols[2].vertical_centered(|ui| {
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            ui.label("IN:01:00.000");
            ui.button("I");
            ui.label("OUT:01:30.000");
            ui.button("O");
        });
    });
});

几个关键点:

  1. `ui.columns(3)` 创建三等分列,每列宽度由 egui 自动分配,不会溢出
  2. 每列先用 `vertical_centered` 让内容垂直居中,保证三行控件在一条水平线上
  3. 中列用 `center_horizontal` 让播放按钮在列内水平居中
  4. 左右两列分别用 left_to_rightright_to_left 控制文本对齐方向
  5. 时间轴滑块单独放在控制行上方,用 slider_width = ui.max_rect().width() 撑满

总结

方案 复杂度 适用场景
手动垫空间 宽度固定的简单场景
Sizing Pass 追求性能、尺寸变化不频繁
双遍渲染 尺寸频繁变化、不想维护状态
egui_alignments 最低 通用场景、推荐首选

我的建议是:先用 `egui_alignments`。 它把最脏的活干了,API 也干净,而且不会影响性能——center_horizontal 内部用的是基于 scope 的尺寸计算,不是每帧画两遍那种暴力的方式。

如果不想加三方依赖,可以直接抄它的思路:ui.scope_builder + min_rect().width() 计算偏移量。核心代码不到 20 行,但用起来远不如 center_horizontal(ui, |ui| { ... }) 优雅。


后记:这个问题让我想起了一个更大的命题——Immediate Mode GUI 是不是个错误的方向?我的看法是:不。布局上的不便换来的是极低的状态复杂度和可预测的渲染行为。当你调试一个 Retained Mode 的布局 bug 时,你会怀念 Immediate Mode 的简单粗暴。只不过,"简单"是对框架作者而言的,我们这些使用者得承受一些代价。

转发至

微信扫一扫分享

WeChat QR Code