Skip to content

Microsoft.UI.Reactor(Reactor)的 flex 布局是同时推理两条轴的。主轴负责 把子元素分配到可用空间上 —— JustifyContentFlex(grow:)Flex(shrink:)Flex(basis:) 都作用在主轴上。交叉轴负责让每个子元素在容器的另一个 维度上对齐 —— AlignItems 和逐子元素的 AlignSelf 作用在交叉轴上。 同一个 FlexPanel 同时组合了这两者,正是这个组合解锁了 VStackHStack 表达不了的布局。带弹性空隙的工具栏、固定侧边栏加流式内容区的 应用外壳、溢出即换行的标签行 —— 这三者都是 FlexPanel,只是主轴/交叉轴 规则不同。当你想用 Grid 去解决一个本质上是"沿一条轴分配这些元素, 同时另一条轴上要对齐"的问题时,请先读完这一页 —— 那是 FlexPanel 的形态; 而一旦你觉得上网格有点杀鸡用牛刀,那就是 Flex 该上场的时候。

Flex 布局

Reactor 的 FlexPanel(通过 FlexRowFlexColumn 工厂方法暴露)是基于 Yoga 的 CSS Flexbox 实现。当布局需要 VStackHStack 表达不了的对齐控制、 换行或按比例分配尺寸时,就用它。加上 using Microsoft.UI.Reactor.Layout;(或导入 FlexDirectionFlexJustifyFlexAlignFlexWrap)即可使用这些枚举类型。

速查表

属性 设置位置 效果
JustifyContent 容器(with { ... } 主轴上的分布:FlexStart / Center / FlexEnd / SpaceBetween / SpaceAround / SpaceEvenly
AlignItems 容器(with { ... } 交叉轴对齐:Stretch / FlexStart / Center / FlexEnd / Baseline
AlignContent 容器(with { ... } 换行后的行在交叉轴上的对齐 —— 仅在开启 Wrap 且各行之间有剩余空间时才有意义
Wrap 容器(with { ... } NoWrap(单行,溢出)/ Wrap / WrapReverse
ColumnGap / RowGap 容器(with { ... } 子元素之间的间距 —— 换行后的行与行之间同样生效
FlexPadding 容器(with { ... } 由 Yoga 度量的容器内边距(与 .Padding(...) 修饰符不同)
.Flex(grow:, shrink:, basis:) 子元素 主轴尺寸
.Flex(minWidth:, minHeight:) 子元素 覆盖主轴上 CSS §4.5 的自动最小尺寸(null = CSS 的 auto0 = 不设下限;正数 = 硬性下限)。auto 如何解析见最小尺寸表。
.Flex(alignSelf:) 子元素 为单个子元素覆盖容器的 AlignItems

方向

FlexRow 沿水平主轴摆放子元素;FlexColumn 沿垂直主轴摆放。 交叉轴就是剩下的那个方向。通过 ColumnGap(行子元素之间) 或 RowGap(列子元素之间)设置间距:

class FlexDirectionDemo : Component
{
    public override Element Render()
    {
        return VStack(16,
            SubHeading("Row (default)"),
            FlexRow(
                Border("A").Padding(12).Background(Theme.AccentTertiary),
                Border("B").Padding(12).Background(Theme.SystemCriticalBackground),
                Border("C").Padding(12).Background(Theme.SystemSuccessBackground)
            ) with { ColumnGap = 8 },

            SubHeading("Column"),
            FlexColumn(
                Border("A").Padding(12).Background(Theme.AccentTertiary),
                Border("B").Padding(12).Background(Theme.SystemCriticalBackground),
                Border("C").Padding(12).Background(Theme.SystemSuccessBackground)
            ) with { RowGap = 8 }
        ).Padding(24);
    }
}

行方向与列方向

粗看之下 FlexRow 很像 HStack,但 FlexPanel 额外提供了 主轴分布、交叉轴对齐、换行和按比例分配尺寸 —— 这些 HStack 一个都不支持。

主轴分布与对齐

JustifyContent 沿主轴分配子元素;AlignItems交叉轴上对齐它们。 它们是同一个容器上两个各自独立的旋钮 —— 请把它们当成两句话分别读:

class JustifyAlignDemo : Component
{
    public override Element Render()
    {
        return VStack(16,
            SubHeading("JustifyContent: SpaceBetween"),
            FlexRow(
                Border("Left").Padding(8).Background(Theme.AccentTertiary),
                Border("Center").Padding(8).Background(Theme.SystemCriticalBackground),
                Border("Right").Padding(8).Background(Theme.SystemSuccessBackground)
            ) with { JustifyContent = FlexJustify.SpaceBetween },

            SubHeading("AlignItems: Center"),
            FlexRow(
                Border("Short").Padding(8).Background(Theme.AccentTertiary),
                Border("Tall\nItem").Padding(8).Background(Theme.SystemCriticalBackground),
                Border("Med").Padding(8).Background(Theme.SystemSuccessBackground)
            ) with {
                AlignItems = FlexAlign.Center,
                ColumnGap = 8
            }
        ).Padding(24).Height(300);
    }
}

主轴分布与对齐演示

JustifyContent 效果(主轴)
FlexStart 从起点开始排布(默认)
Center 沿主轴居中
FlexEnd 从终点开始排布
SpaceBetween 元素之间间距相等,两端不留
SpaceAround 每个元素周围间距相等
SpaceEvenly 元素之间及两端间距全部相等
AlignItems 效果(交叉轴)
Stretch 填满交叉轴(默认)
FlexStart 对齐到交叉轴起点
Center 在交叉轴上居中
FlexEnd 对齐到交叉轴终点
Baseline 按文本基线对齐

换行与间隙

当子元素在主轴上即将溢出容器时,设置 Wrap = FlexWrap.Wrap 把它们流到第二行。ColumnGapRowGap 的应用是一致的 —— 既作用于同一行内子元素之间,作用于换行后的行与行之间:

class WrapGapDemo : Component
{
    public override Element Render()
    {
        var tags = new[] {
            "C#", "WinUI", "Reactor", ".NET", "XAML",
            "Flex", "Layout", "Desktop", "Native"
        };

        return VStack(12,
            SubHeading("Wrapping Tags"),
            FlexRow(
                tags.Select(tag =>
                    Border(tag)
                        .Padding(horizontal: 6, vertical: 12)
                        .Background(Theme.ControlFillSecondary)
                        .CornerRadius(12)
                        .WithKey(tag)
                ).ToArray()
            ) with {
                Wrap = FlexWrap.Wrap,
                ColumnGap = 8,
                RowGap = 8
            }
        ).Padding(24);
    }
}

带间隙的换行标签

标签云、芯片行、面包屑溢出、筛选药丸 —— 任何元素个数会变、 容器宽度会收缩的地方 —— 都是适合换行的形态。如何用虚拟化渲染 长列表元素集合,见 Collections

grow、shrink、basis

子元素上的 .Flex(grow:, shrink:, basis:) 决定它如何与兄弟元素 分配主轴空间:

  • grow —— 在所有 basis 尺寸排布完成后,子元素可以认领的额外 空间的份额(默认 0 —— 子元素保持其内容尺寸)。
  • shrink —— 当 basis 总和超出容器时,子元素让出的缺口的份额 (默认 1 —— 子元素按比例收缩)。
  • basis —— 应用 grow/shrink 之前的起始尺寸,单位为像素。 null 表示"先度量我的内容"(Yoga 的 auto)。
  • minWidth / minHeight —— 子元素主轴尺寸的显式下限。 null(默认)表示 auto —— 见下文"最小尺寸(CSS §4.5)"。
class GrowShrinkDemo : Component
{
    public override Element Render()
    {
        return VStack(16,
            SubHeading("Grow: sidebar + content"),
            FlexRow(
                Border("Sidebar")
                    .Padding(16).Background(Theme.AccentTertiary)
                    .Flex(basis: 200, shrink: 0),
                Border("Main content area")
                    .Padding(16).Background(Theme.CardBackground)
                    .Flex(grow: 1)
            ) with { ColumnGap = 8 },

            SubHeading("Equal columns"),
            FlexRow(
                Border("Column 1").Padding(16).Background(Theme.SystemCriticalBackground).Flex(grow: 1),
                Border("Column 2").Padding(16).Background(Theme.SystemSuccessBackground).Flex(grow: 1),
                Border("Column 3").Padding(16).Background(Theme.AccentTertiary).Flex(grow: 1)
            ) with { ColumnGap = 8 }
        ).Padding(24);
    }
}

grow 与 shrink 布局

侧边栏用 basis: 200, shrink: 0 固定宽度;内容列用 grow: 1 吸收剩余的一切。等宽列那一行给每个子元素 grow: 1, 于是可用空间被平均分配。

注意: 不带 basisFlex(grow: 0) + Flex(shrink: 1) 会默认 basis: auto —— Yoga 会在第一遍度量子元素的内容尺寸,第二遍才做分布。 对于一行 200 个列表单元格的情况,第一遍度量会主导布局开销: 面板在做任何分配之前,都要先向每个单元格询问它的期望尺寸。 显式设置 basis: 0(或任意像素值)可以把两遍合成一遍: 只做分布,不度量内容。这个差别在 5 个按钮的工具栏上无感, 但在每次列宽调整都要重建的 200 行表格头上就是决定性的。

最小尺寸(CSS §4.5)

默认情况下,basis: auto 的 flex 子元素不会收缩到其 min-content(最小内容) 尺寸以下 —— 即在不溢出自身内容的前提下 它能占据的最小尺寸。这符合 CSS Flexbox 规范 §4.5 ("Automatic Minimum Size of Flex Items"),也正是大多数开发者在排布 一行卡片或一列区块时直觉上期待的行为:元素不会被压得比它的文字或图标更小。

Reactor 的 FlexPanel 按如下方式实现自动最小尺寸规则:

basis minWidth / minHeight 最终下限
auto(默认) null(默认) min-content
0 null 0(短路 —— basis 本身已经不参与内容度量)
像素值 N > 0 null min(N, min-content)
任意值 显式 0 0(你主动放弃这个下限)
任意值 显式 N > 0 N(你设置的硬性下限)

自动最小尺寸规则只作用于主轴FlexDirection.Row 时为宽度, FlexDirection.Column 时为高度)。在交叉轴上,元素可以自由收缩到 0, 除非你显式设置了 minWidth/minHeight

class MinSizingDemo : Component
{
    public override Element Render() => VStack(16,
        SubHeading("Default — items keep their min-content size"),
        FlexRow(
            Border("Long text that won't truncate").Flex(shrink: 1)
                .Background(Theme.AccentTertiary).Padding(8),
            Border("Short").Flex(shrink: 1)
                .Background(Theme.SystemCriticalBackground).Padding(8)
        ) with { ColumnGap = 8 },

        SubHeading("Opt out — minWidth: 0 lets items shrink below content"),
        FlexRow(
            Border("Long text that may be clipped").Flex(shrink: 1, minWidth: 0)
                .Background(Theme.AccentTertiary).Padding(8),
            Border("Short").Flex(shrink: 1, minWidth: 0)
                .Background(Theme.SystemCriticalBackground).Padding(8)
        ) with { ColumnGap = 8 },

        SubHeading("Explicit floor — never below 80px regardless of content"),
        FlexRow(
            Border("Hard floor").Flex(shrink: 1, minWidth: 80)
                .Background(Theme.SystemSuccessBackground).Padding(8)
        ) with { ColumnGap = 8 }
    ).Width(360);
}

注意:性能。 计算 min-content 需要在主 flex 算法运行之前, 对每个受影响的子元素做一次 WinUI 的 Measure(width: marginH, height: ∞)。 对典型界面来说没问题,但元素非常多时可能产生影响。有两种方式跳过这次预度量:

  • 显式设置 basis: 0 —— 自动最小尺寸直接塌缩为 0,无需度量。
  • 显式设置 minWidth: 0(或 minHeight: 0)—— 你主动放弃了这个下限。

类型为 ScrollViewScrollViewer 的子元素下限始终是 0: 它们的 min-content 会迫使虚拟化内容被实例化,那就违背了虚拟化的初衷。

注意:显式 Width 上 CSS 与 WinUI 的差异。 在 CSS 中, width: 200px; flex-shrink: 1; min-width: 0 的元素允许收缩到 200 以下。 而在 WinUI 中,FrameworkElement.Width = 200 是一个硬性尺寸约束 —— 无论可用空间多少,控件都会上报 DesiredSize.Width = 200, 并在分配到的槽位里按 200 排列。因此在一个 FlexPanel 内部, 对于任何你希望它能收缩的元素,请优先用 .Flex(basis: 200, ...) 而不是 .Width(200)。Yoga 读的是 basis;WinUI 的 Width 并不像 CSS 的 width 那样参与 flex 分配。

实践示例:带弹性空隙的工具栏

左对齐标题加右对齐按钮的工具栏,是最典型的 flex 形态。 用 Empty().Flex(grow: 1) 作为弹性空隙 —— 它吸收每一个多余像素, 把按钮推到末尾一侧:

class ToolbarDemo : Component
{
    public override Element Render()
    {
        var (selected, setSelected) = UseState("Home");

        return VStack(0,
            FlexRow(
                TextBlock("MyApp").Bold().Flex(shrink: 0),
                Empty().Flex(grow: 1),
                Button("Home", () => setSelected("Home")),
                Button("Settings", () => setSelected("Settings")),
                Button("About", () => setSelected("About"))
            ) with {
                AlignItems = FlexAlign.Center,
                ColumnGap = 8,
                FlexPadding = new Thickness(16, 8, 16, 8)
            },
            TextBlock($"Current page: {selected}")
                .Padding(24).FontSize(18)
        );
    }
}

工具栏布局

空隙模式可以组合 —— 三个按钮之间放三个空隙,就能在容器上不用 SpaceBetween 的情况下让它们均匀分布;当只有部分子元素需要伸缩时, 这一点很重要。

Flex 与 VStack/HStack、Grid 的取舍

选容器要看它承诺的形态,而不是看哪个写起来省事:

class FlexVsStackDemo : Component
{
    public override Element Render()
    {
        return VStack(16,
            SubHeading("HStack (fixed spacing)"),
            HStack(8,
                Button("A"), Button("B"), Button("C")
            ),

            SubHeading("FlexRow (justify + align)"),
            FlexRow(
                Button("A"), Button("B"), Button("C")
            ) with {
                JustifyContent = FlexJustify.SpaceEvenly,
                AlignItems = FlexAlign.Center
            }
        ).Padding(24);
    }
}

Flex 与 Stack 的对比

能力 VStack / HStack FlexRow / FlexColumn Grid
固定的子元素间距 支持 支持(ColumnGap / RowGap 支持
主轴分布 不支持 支持 逐行/逐列
交叉轴对齐 不支持 支持 每格 VAlign
溢出换行 不支持 支持 隐式的行/列矩阵
按比例分配尺寸 不支持 支持(Flex(grow:) 支持(GridSize.Star
二维布局 不支持 不支持 支持

如果布局本身具有行列结构(表单、设置网格,或任何列要跨行对齐的场景), 请用 Grid。如果它是"沿一条轴分配子元素,另一条轴上要对齐", 那 FlexPanel 才是正确的形态。

模式

应用外壳 —— 固定侧边栏,流式内容

固定宽度的导航栏加吸收剩余空间的内容面板,是生产环境中最常见的 flex 形态。关键在于侧边栏上的 basis: 220, shrink: 0(锁定宽度、 防止塌缩),配合内容区的 grow: 1, basis: 0(在一次分布过程中拿走剩余空间):

class AppShellDemo : Component
{
    public override Element Render()
    {
        return FlexRow(
            // 侧边栏 —— 固定 220px,绝不小于它。
            VStack(8,
                TextBlock("Inbox").Padding(8),
                TextBlock("Drafts").Padding(8),
                TextBlock("Sent").Padding(8)
            ).Background(Theme.CardBackground)
             .Flex(basis: 220, shrink: 0),

            // 内容区 —— 显式 basis: 0 + grow: 1,只做一次分布,
            // 不必先度量内部文本。
            VStack(12,
                Heading("Inbox"),
                TextBlock("Three messages, one starred. The sidebar stays 220px wide; this column absorbs every spare pixel.")
            ).Padding(16)
             .Flex(grow: 1, basis: 0)
        ) with { ColumnGap = 1 };
    }
}

内容区上显式的 basis: 0 正对应上面那条注意事项 —— 不做内容度量, 只做分布。侧边栏的 shrink: 0 让导航栏在窗口窄于内容固有最小宽度时 依然保持 220 像素。

溢出即换行的响应式导航栏

断点之上,导航栏是单行;断点之下,元素换到第二行。 FlexWrap.Wrap + RowGap + ColumnGap 就是全部机制 —— 不需要任何媒体查询之类的东西:

class ResponsiveNavDemo : Component
{
    public override Element Render()
    {
        // 当窄视口再也放不下一行时,换行就生效了。
        // RowGap 和 ColumnGap 在换行后的行与行之间同样生效 —— 无需手写 margin。
        return FlexRow(
            Border("Home").Padding(8).Background(Theme.AccentTertiary),
            Border("Catalog").Padding(8).Background(Theme.AccentTertiary),
            Border("Pricing").Padding(8).Background(Theme.AccentTertiary),
            Border("Docs").Padding(8).Background(Theme.AccentTertiary),
            Border("About").Padding(8).Background(Theme.AccentTertiary),
            Border("Contact").Padding(8).Background(Theme.AccentTertiary),
            Border("Status").Padding(8).Background(Theme.AccentTertiary)
        ) with {
            Wrap = FlexWrap.Wrap,
            ColumnGap = 8,
            RowGap = 8,
            AlignItems = FlexAlign.Center
        };
    }
}

同样的形态也适用于标签行、面包屑溢出和工具栏溢出。如果断点需要驱动 完全不同的布局(比如折叠成汉堡菜单),可以配合 UseWindowSize;但仅就原地换行而言,Flex 容器本身就够了。

带弹性空隙的工具栏

上面已经演示过 —— 这里再提一遍,因为这个模式最清楚地体现了 主轴与交叉轴的分工。AlignItems = FlexAlign.Center 让高度不一的按钮 垂直居中;Empty().Flex(grow: 1) 把末尾的按钮推到右边缘。 两个互不相干的旋钮,同一个容器。

常见错误

用 FlexPanel 去做网格形态的布局

// 别这样 —— 行与行之间的二维对齐不是 Flex 的职责。
FlexColumn(
    FlexRow(TextBlock("Name:"), TextBox(name, setName)) with { ColumnGap = 8 },
    FlexRow(TextBlock("Email:"), TextBox(email, setEmail)) with { ColumnGap = 8 },
    FlexRow(TextBlock("Phone:"), TextBox(phone, setPhone)) with { ColumnGap = 8 }
)

标签不会对齐 —— 每一行的 flex 分配都是各自独立的。任何列要跨行对齐的 布局(表单、设置网格、数据表)都用 Grid。FlexPanel 一次只能理解一条轴。

.Width(...).Flex(grow:) 混用

class WidthVsGrowWrong : Component
{
    public override Element Render()
    {
        // 别这样:.Width(200) 设置的是 WinUI 的 Width,但在 FlexPanel 内部
        // 子元素尺寸由 Flex(basis/grow/shrink) 决定。当 grow > 0 填满可用
        // 空间时,这个 200 会被静默忽略。
        return FlexRow(
            Border("Stays 200?")
                .Width(200)              // 被忽略 —— grow 优先
                .Flex(grow: 1)
                .Background(Theme.SystemCriticalBackground)
        ) with { ColumnGap = 8 };
    }
}

.Width(200) 设置的是底层 FrameworkElement.Width,但在 FlexPanel 内部,子元素的主轴尺寸来自 Flex(basis:, grow:, shrink:) —— 而 grow: 1 无论 Width 是多少都会填满可用空间。那个 200 被静默忽略了。 请把期望的尺寸编码成 basis

class WidthVsGrowRight : Component
{
    public override Element Render()
    {
        // 应该这样:把期望尺寸编码为 basis 并配 shrink: 0 ——
        // 尺寸计算归 Flex 管,就不会有意料之外的覆盖。
        return FlexRow(
            Border("Exactly 200")
                .Flex(basis: 200, shrink: 0)
                .Background(Theme.SystemSuccessBackground)
                .Padding(8)
        ) with { ColumnGap = 8 };
    }
}

通用规则:在 FlexPanel 内部,优先用 .Flex(basis: N) 而不是 .Width(N)。 basis 才是 Yoga 算法读取的值;width 是 WinUI 度量过程读取的值, 两者只在某些情况下一致。

忘了 Wrap,于是在窄宽度下溢出

// 别这样 —— FlexPanel 默认是 NoWrap。在 600px 宽的窗口上
// 这一行会溢出并裁掉末尾的元素。
FlexRow(/* 7 个导航项 */) with { ColumnGap = 8 }

默认的 FlexWrap.NoWrap 只产生一行,当子元素总宽超过可用空间时就会溢出容器。 对于元素个数或窗口宽度会变化的行,请设置 Wrap = FlexWrap.Wrap。 对于实际不需要换行的行,这个开销为零 —— 一行放得下时 Yoga 会短路。

想要 FlexPadding 却用了 .Padding

FlexPadding 是 FlexPanel 自身的属性,由 Yoga 在分配子元素的同一次算法中度量。 而 .Padding(...) 修饰符设置的是底层 WinUI 的 FrameworkElement.Margin / Padding,由 WinUI 在自己的那一遍里应用。要获得一致的容器内边距 —— 尤其是在涉及换行或 JustifyContent 时 —— 请用 FlexPadding.Padding(...) 留给不是 FlexPanel 的元素。

小贴士

从 VStack/HStack 起步。 它们生成的元素树更简单,也能覆盖大多数布局。 只有当你确实需要主轴分布、对齐、换行或按比例分配尺寸时,才上 FlexPanel。

ColumnGapRowGap,而不是逐子元素加外边距。 间隙会自动作用在子元素之间 —— 没有首元素/末元素的不对称, 而且与换行组合时行为正确。

给每个固定宽度的元素加上 shrink: 0 侧边栏、图标列或固定的操作按钮, 没有 shrink: 0 就会在窗口变窄时塌缩。把 shrink: 0basis 搭配使用, 即可锁定精确尺寸。

Empty().Flex(grow: 1) 作弹性空隙。 这就是 flex 里的弹簧 —— 它吸收全部剩余空间,把兄弟元素推到对侧。与伸缩属性各异的兄弟元素组合时 行为同样正确。

容器属性请记得用 with { } 语法。 FlexElement 是一个 C# record, 所以要通过 FlexRow(...) with { JustifyContent = FlexJustify.Center } 来设置 JustifyContentAlignItemsWrapColumnGapRowGap

下一步

  • Layout —— 前一篇:VStack、HStack、Grid、ScrollView 等核心容器
  • Forms and Input —— 下一篇:受控输入控件、校验与表单组合
  • Collections —— 用虚拟化渲染动态列表与网格
  • Styling and Theming —— 主题令牌、深色/浅色模式,以及 flex 容器的轻量样式
  • Input and Gestures —— flex 子元素上的指针与键盘事件