习惯了网页高度抽象的样式绘制,上手egui第一时间就会发现:没有 CSS、没有 class、没有 display: flex。所有布局都写在 Rust 代码里,而且写法和绘制代码混在一起。
但把稍微熟悉之后会发现,egui 的布局模型基本就是 flexbox 的一个子集——它把 flex 里最常用的那几种用法,做成了几个现成的方法。本文用「CSS 怎么写 / egui 怎么写」的对照方式,把 eframe 0.36.2 的布局讲一遍。
本文所有代码都在
eframe = "0.36.2"(egui 0.36.2)上编译验证过,不是凭记忆写的示例。
先对齐心智模型:egui 和网页到底差在哪
1. 没有样式表,样式必须写在绘制代码旁边。 CSS 能把结构和样式分开,egui 不能——它就是立即模式(immediate mode):每帧重新执行一遍你的代码,布局和绘制是同一次调用里发生的。所以「先写结构,再调 CSS」这个习惯要改掉。
2. 没有块级 / 行内 / 流式排版。 浏览器默认那一套 display: block、文字环绕、baseline 对齐,egui 全都没有。它只有主轴方向加交叉轴对齐两个参数,也就是 flex 的那一套。
3. 默认是纵向堆叠。 一个 Ui 默认从头往下排,相当于 flex-direction: column。你想横排,得显式调用 ui.horizontal()。
4. 尺寸默认由内容决定。 egui 里没有「元素默认撑满父容器」这回事。它默认是 width: fit-content;想占满可用宽度,得自己写 ui.available_width()——相当于 width: 100%。
5. 没有 CSS 继承,也没有 z-index。 字号、颜色这类东西不会从父级继承下来,必须在具体控件上设置;层级由 Area / Window 这类独立图层决定,不是靠一个数字。
下面是本文会反复用到的概念对照表:
| CSS | egui | 说明 |
|---|---|---|
display: flex; flex-direction: column |
ui.vertical() |
默认布局 |
flex-direction: row |
ui.horizontal() |
横向排列 |
flex-wrap: wrap |
ui.horizontal_wrapped() |
排满自动换行 |
flex-direction: row-reverse |
Layout::right_to_left(Align::Center) |
从右往左排 |
align-items |
Align::{Min, Center, Max} |
交叉轴对齐 |
align-items: stretch |
Layout::top_down_justified(...) |
交叉轴拉伸 |
justify-content: center |
ui.vertical_centered() |
主轴居中 |
width: 100% |
ui.available_width() |
取剩余空间 |
width / height |
Vec2 + allocate_ui_with_layout |
固定尺寸盒子 |
gap |
ui.spacing().item_spacing |
默认 Vec2::new(8.0, 3.0) |
padding |
Frame::inner_margin |
内边距 |
background + border + border-radius |
Frame::fill / stroke / corner_radius |
卡片三件套 |
position: fixed |
Panel::top/left/... |
顶栏、侧栏 |
overflow: auto |
ScrollArea |
滚动容器 |
display: grid |
Grid |
表格状布局 |
grid-template-columns: repeat(n, 1fr) |
ui.columns(n, ...) |
等分列 |
position: absolute |
allocate_exact_size + ui.put |
定位到指定区域 |
z-index |
Area / Window / Modal |
独立图层 |
核心心智模型:Ui 就是容器,闭包就是 div
这是最重要的一条。每一个 Ui 就是一个布局容器,它自己带着方向、对齐、间距和尺寸约束;所有容器方法都接收一个闭包,闭包里创建的控件自动属于这个容器:
ui.vertical(|ui| { // 开一个 div { display: flex; flex-direction: column }
ui.label("第一行");
ui.label("第二行");
}); // 闭包结束,这个 div 就闭合了
对照着看,就是:
<div style="display:flex; flex-direction:column">
<span>第一行</span>
<span>第二行</span>
</div>
想嵌套就继续套闭包;想换方向,就在里面再开一个 ui.horizontal()。
对照一:flex 的常用写法
横向排列与交叉轴对齐
// flex-direction: row; align-items: center
ui.horizontal(|ui| {
ui.label("主机");
ui.text_edit_singleline(&mut self.name);
ui.checkbox(&mut self.checked, "记住");
});
ui.horizontal() 相当于 flex-direction: row 加 align-items: center。如果想让子项在垂直方向对齐到顶部,用 ui.horizontal_top();要垂直居中并且把整行占满,用 ui.horizontal_centered()。
主轴与交叉轴的完整控制
需要更细的控制时,用 Layout 描述你想要的 flex 行为:
// flex-direction: column; align-items: center;
// 也就是「垂直排列,水平居中」
ui.with_layout(Layout::top_down(Align::Center), |ui| {
ui.label("居中一行");
});
// flex-direction: column; align-items: stretch; 子项宽度拉满
ui.with_layout(Layout::top_down_justified(Align::Center), |ui| {
if ui.button("整宽按钮").clicked() { /* ... */ }
});
// flex-direction: row-reverse; align-items: center; 从右往左排
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button("设置").clicked() { /* ... */ }
});
这里最容易踩的坑是 right_to_left:它同时也把主轴起点放到了右边,所以第一个添加的控件在最右边。想在左对齐的容器里把某个按钮推到右边,就把它放进一个 right_to_left 的布局里,这相当于 margin-left: auto。
居中的三种常见需求
| 需求 | 相当于 CSS | egui |
|---|---|---|
| 整块内容水平居中 | align-items: center |
ui.vertical_centered(|ui| ...) |
| 子项都撑满并居中 | align-items: stretch |
ui.vertical_centered_justified(|ui| ...) |
| 单个控件在整行居中 | justify-content: center |
ui.centered_and_justified(|ui| ...) |
注意 ui.available_width() 取到的是当前容器里剩下的宽度。在横向布局里连续取两次,第二次会比第一次小——这就是它和 width: 100% 的细微区别。
对照二:尺寸与间距
固定尺寸的盒子用 allocate_ui_with_layout,它相当于先声明一个 width / height 确定的 div,再把内容放进去:
// width: 240px; height: 80px; display: flex; align-items: center
ui.allocate_ui_with_layout(
Vec2::new(240.0, 80.0),
Layout::left_to_right(Align::Center),
|ui| {
ui.label("固定 240x80 的区域");
},
);
占满剩余宽度就是把 ui.available_width() 当作宽度传进去:
let full = ui.available_width(); // width: 100%
ui.allocate_ui_with_layout(
Vec2::new(full, 32.0),
Layout::left_to_right(Align::Center),
|ui| { ui.label("占满一行"); },
);
间距分两种,对应 CSS 的不同属性:
ui.add_space(12.0)—— 手动插入一段空白,相当于给某个元素单独加margin;ui.spacing_mut().item_spacing—— 同级控件之间的统一间隙,相当于gap,默认是Vec2::new(8.0, 3.0)。
对照三:卡片、边框、圆角
Frame 就是「一个带内边距和背景的盒子」,三件套和 CSS 一一对应:
// padding: 12px; margin: 0 0 8px 0; background: #f0f0f5;
// border: 1px solid gray; border-radius: 8px
Frame::new()
.inner_margin(Margin::same(12))
.outer_margin(Margin::symmetric(0, 8))
.fill(Color32::from_rgb(240, 240, 245))
.stroke(Stroke::new(1.0, Color32::GRAY))
.corner_radius(CornerRadius::same(8))
.show(ui, |ui| {
ui.label("一张卡片");
});
不想自己配色的话,egui 提供了一批预设,直接继承当前主题:Frame::group(ui.style()) 是分组框,Frame::window(ui.style()) 是窗口样式,Frame::central_panel(...) / Frame::side_top_panel(...) 分别给中央区域和边栏用。
对照四:grid、滚动与等分列
Grid:像表格一样对齐
Grid 适合做「左边标签、右边控件」这种整齐的行列对齐,注意它按行填充,每行结束要手动调用 ui.end_row()——这一点和 HTML 表格不同,后者靠标签闭合隐式换行:
Grid::new("hosts") // 相当于给这个 grid 一个 id
.num_columns(2)
.striped(true) // 斑马纹
.spacing(Vec2::new(16.0, 6.0))
.min_col_width(80.0)
.show(ui, |ui| {
ui.label("主机");
ui.label("端口");
ui.end_row(); // 换行,别漏
for (host, port) in &self.rows {
ui.label(host);
ui.label(port);
ui.end_row();
}
});
ScrollArea:overflow: auto
ScrollArea::vertical()
.auto_shrink([false, false]) // 不随内容收缩,撑满可用空间
.max_height(160.0) // max-height
.show(ui, |ui| {
for i in 0..40 {
ui.label(format!("第 {i} 行"));
}
});
auto_shrink 是最需要留意的参数:设成 [false, false],容器就撑满可用空间,类似 width: 100%; height: 100%;保持默认,它就跟着内容收缩,类似 fit-content。
columns:等分列
// grid-template-columns: repeat(3, 1fr)
ui.columns(3, |cols| {
cols[0].label("左");
cols[1].label("中");
cols[2].label("右");
});
columns 按可用宽度等分,所以放在不同容器里效果会不一样。要提醒的是,窗口变窄时它不会自动换行,需要自己做响应式判断。
对照五:绝对定位与浮层
allocate_exact_size 占一块矩形区域,再用 ui.put 把控件放进去,就相当于绝对定位:
let (rect, _response) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), 60.0), Sense::hover());
ui.put(rect, egui::Label::new("定位到这个 rect 里"));
浮层则交给 Area(自由浮动面板)、Window(可拖动窗口)和 Modal(模态框),它们在独立图层里绘制,对应 CSS 的 position: fixed 加高 z-index。
egui 没有 z-index 这种数字层级:图层的先后由添加顺序决定,后加的盖在前面。
页面骨架:Panel 就是固定定位的三栏布局
一个典型应用的骨架,用 Panel 家族搭出来。注意 0.36 里 SidePanel 和 TopBottomPanel 已经统一成了 Panel:
impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
Panel::top(Id::new("top_bar")) // position: fixed; top: 0
.exact_size(40.0)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.heading("Hatoba");
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button("设置").clicked() { /* ... */ }
});
});
});
Panel::left(Id::new("sidebar")) // position: fixed; left: 0
.resizable(true) // resize: horizontal
.default_size(200.0)
.size_range(160.0..=400.0) // min-width / max-width
.show(ui, |ui| { /* ... */ });
// CentralPanel 必须最后添加:它吃掉剩下的全部空间
CentralPanel::default().show(ui, |ui| {
ui.label("主内容区");
});
}
}
三条必须记住的规则:
CentralPanel永远最后添加——它占的是前面所有面板挖剩下的空间。- 面板之间不能互相嵌套,只能一个接一个地添加。
- 顺序决定位置:先添加的在最外层,后添加的在里层。
0.36 的坑:先确认你在看哪份教程
这是本文最需要提醒的一点。在 eframe 0.36.2 里,App trait 的方法签名已经变了:
// 0.36:参数是 root Ui,不是 Context
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame);
// 网上大量教程还是旧写法
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame);
update 在新版本里已经不存在了,照抄老教程会直接编译不过。对应的,面板的 show 接收的也是 &mut Ui 而不是 &Context。
其他几个容易绊住前端的地方:
item_spacing是全局的。 想改一小块区域的间距,记得用ui.scope(|ui| { ... })包起来,相当于把 CSS 规则限制在局部作用域。- 没有继承。 字号、颜色都得在具体控件上设置,或者用
ui.style_mut()在局部改。 - 换行要显式。
ui.horizontal()不会自动 wrap,需要换行就用ui.horizontal_wrapped()。 ui.with_layout的默认间距不会自动归零。 精细排版时可能要配合ui.spacing_mut()一起调。
速查表:想做什么,该调什么
| 我想…… | egui 写法 |
|---|---|
| 横着排一排元素 | ui.horizontal(|ui| ...) |
| 竖着排一排元素 | ui.vertical(|ui| ...) |
| 排满自动换行 | ui.horizontal_wrapped(|ui| ...) |
| 交叉轴居中 | Layout::top_down(Align::Center) |
| 拉到和容器一样宽 | ui.available_width() |
| 固定宽高 | allocate_ui_with_layout(Vec2::new(w, h), ...) |
| 加内边距和背景 | Frame::new().inner_margin(..).fill(..) |
| 改这一块的间距 | ui.scope(|ui| ui.spacing_mut().item_spacing = ..) |
| 表格状对齐 | Grid::new(..).show(..) + ui.end_row() |
| 等分列 | ui.columns(n, |cols| ...) |
| 内容超出可滚动 | ScrollArea::vertical().show(..) |
| 顶栏 / 侧栏 / 底栏 | Panel::top/left/right/bottom(Id::new(..)) |
| 主内容区 | CentralPanel::default(),且最后添加 |
| 浮层 / 弹窗 | Area / Window / Modal |
最后一句总结:把每个 Ui 当成一个 flex 容器,把闭包当成 div 的开合,把 Layout 当成 flex-direction 加 align-items。 剩下的,就是把 CSS 里的习惯翻译成对应的方法名。