egui水平居中没那么简单
> 一个 Immediate Mode GUI 的经典坑,以及四种解法
> 一个 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");
});
});
});几个关键点:
- `ui.columns(3)` 创建三等分列,每列宽度由 egui 自动分配,不会溢出
- 每列先用 `vertical_centered` 让内容垂直居中,保证三行控件在一条水平线上
- 中列用 `center_horizontal` 让播放按钮在列内水平居中
- 左右两列分别用 left_to_right 和 right_to_left 控制文本对齐方向
- 时间轴滑块单独放在控制行上方,用 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 的简单粗暴。只不过,"简单"是对框架作者而言的,我们这些使用者得承受一些代价。
评论 ()