习惯了网页高度抽象的样式绘制,上手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("主内容区");
        });
    }
}

三条必须记住的规则:

  1. CentralPanel 永远最后添加——它占的是前面所有面板挖剩下的空间。
  2. 面板之间不能互相嵌套,只能一个接一个地添加。
  3. 顺序决定位置:先添加的在最外层,后添加的在里层。

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 里的习惯翻译成对应的方法名。