写 uni-app 布局时,可以先确定三件事:页面占多大、子项怎么排、剩余空间给谁。根容器的属性由这三个问题决定,不需要给每个容器都套同一组样式。

本文从一个普通页面开始,逐步介绍子容器、Flex 和 Kotlin Compose 的对应写法。

适用范围:普通 .vue 页面的 CSS 布局。nvue、uni-app x 的支持范围需要另行确认。Compose 示例省略 import,尺寸用于示意,rpxdp 不能直接等量换算。

1. 页面根容器怎么写

对于内容从上往下排列、超过一屏后由页面滚动的场景,可以这样开始:

<template>
  <view class="pages-container">
    <text class="pages-container__title">个人资料</text>

    <view class="pages-container__content">
      <text>这里放表单、列表或其他组件</text>
    </view>
  </view>
</template>

<style scoped lang="scss">
.pages-container {
  box-sizing: border-box;
  width: 100%;
  min-height: 100vh;
  padding: 32rpx;

  display: flex;
  flex-direction: column;
  align-items: stretch;
}

.pages-container__title {
  margin-bottom: 24rpx;
}

.pages-container__content {
  box-sizing: border-box;
}
</style>

这个页面的结构是:

页面根容器
  ├─ 标题
  └─ 内容容器
       └─ 表单、列表或业务组件

pages-container 是自定义类名,不是 uni-app 的内置类。节点之间的父子关系由 template 决定,类名只用于选择节点、应用样式。

为什么这里用 stretch,而不是 center

列布局中,align-items 控制子项在水平方向上的排列。

  • stretch:宽度为 auto 的子项通常会横向拉满。
  • center:子项水平居中,但不再自动拉满。

普通表单和列表通常需要满宽,所以使用 stretch 更方便;也可以省略它,采用 Flex 的默认拉伸行为。

如果需要标题居中、内容满宽,可以改成:

.pages-container {
  align-items: center;
}

.pages-container__content {
  width: 100%;
}

align-items: center 控制子项的位置,不会让子项内部的文字自动居中。文字居中使用 text-align: center

min-height 与 height 怎么选

写法 适合的页面
min-height: 100vh 至少一屏,内容多时继续向下增长
height: 100vh 限定一屏,内部区域单独滚动
height: 100% 由具有确定高度的包含块提供尺寸

给普通卡片设置 height: 100vh,会让每张卡片都占据一屏高度。屏幕尺寸通常由页面根容器负责,内部组件按内容或剩余空间决定高度。

2. 子布局的根容器怎么写

一个子容器同时有两种身份:

作为子项,它决定自己如何占用父级空间;作为容器,它决定自己的子项如何排列。

例如,一个横向信息条,左边是说明,右边是状态:

<template>
  <view class="info-row">
    <view class="info-row__body">
      <text>存储空间</text>
      <text>用于保存图片与视频</text>
    </view>
    <text class="info-row__status">可用</text>
  </view>
</template>

<style scoped lang="scss">
.info-row {
  box-sizing: border-box;
  width: 100%;
  display: flex;
  align-items: center;
}

.info-row__body {
  flex: 1;
  min-width: 0;

  display: flex;
  flex-direction: column;
}

.info-row__status {
  flex: 0 0 auto;
  margin-left: 24rpx;
}
</style>

这里有两层独立的布局:

info-row:横向排列
  ├─ body:占剩余宽度,内部纵向排列
  │    ├─ 标题
  │    └─ 说明
  └─ status:保留自身宽度

父级的 display: flex 只影响直接参与布局的子项,不会让所有后代都变成 Flex 容器。flex-direction 也不会继承。

按用途选择根容器属性

子容器用途 关键样式
满宽、随内容变高 width: 100%; box-sizing: border-box
横向布局中占剩余宽度 flex: 1; min-width: 0
纵向布局中占剩余高度 flex: 1; min-height: 0
保留头像、图标等尺寸 flex: 0 0 auto
给角标提供定位参考 position: relative

后两种“占剩余空间”的写法都要求父级是相应方向的 Flex 容器。分配剩余高度时,还需要父级提供有界的高度。

如果把这段布局提取为子组件,应保留组件自己的根类名。跨端使用时,不要假定传入子组件的 class 一定直接作用到内部根节点;可以由外层 view 控制占位,组件内部负责自身布局。

3. 五个常见属性分别做什么

最初这组样式可以分成“尺寸计算、定位、排列”三个部分:

.pages-container {
  box-sizing: border-box;  // 尺寸计算
  position: relative;     // 定位参考

  display: flex;          // 启用 Flex
  flex-direction: column; // 纵向排列
  align-items: center;    // 子项水平居中
}

box-sizing:宽高包含哪些部分

border-box 表示指定的宽高包含内容、内边距和边框,不包含 margin

width: 300px; padding: 20px; border: 2px solid 为例:

盒模型 元素外宽 内容宽度
content-box 344px 300px
border-box 300px 256px

因此,width: 100% 的容器添加 padding 时,通常同时设置 border-box。这个属性不会自动继承给子节点。

position:绝对定位以谁为参考

position: relative 保留元素在普通流中的占位。没有设置偏移时,视觉位置不变;它还能为绝对定位后代建立定位参考。

例如,右上角的状态标签:

.preview {
  position: relative;
  height: 320rpx;
}

.preview__badge {
  position: absolute;
  top: 16rpx;
  right: 16rpx;
}

这里假设 .preview__badge.preview 内部,且两者之间没有其他建立定位包含块的祖先。

标签脱离普通流,不会撑高预览区域。因此,容器需要通过正常内容或明确高度获得尺寸。

没有覆盖元素时,不必为了“根容器标准写法”而添加 relative。

display:启用哪种布局

display: flex 让当前元素成为弹性布局容器。

它解决的是直接子项如何排布,不表示当前元素会自动占满屏幕,也不表示子项会自动均分空间。

flex-direction:沿哪个方向排

row 是默认值,通常横向排列;column 通常纵向排列。

以下说明按常见的从左到右、横排书写界面理解:

row                        column
主轴 →                     主轴 ↓
[A] [B] [C]                [A]
                           [B]
                           [C]
交叉轴 ↓                   交叉轴 →

align-items:交叉轴如何排列

Flex 有两个方向:

属性 控制方向
justify-content 主轴
align-items 交叉轴

所以,同样写 align-items: center

  • row 中,是上下居中。
  • column 中,是左右居中。

主轴方向变化后,不能继续把 justify-content 理解为固定的“水平排列”。

4. Flex 怎样分配空间

父级的 display: flex 决定采用 Flex 布局;子项的 flex 决定怎样参与尺寸分配。

先认识 flex 的三个值

.item {
  flex: 1 1 0;
  //    │ │ └─ basis:分配前的基准尺寸
  //    │ └─── shrink:空间不足时的收缩系数
  //    └───── grow:空间充足时的伸展系数
}
写法 含义
flex: 0 1 auto 初始行为,按内容或指定尺寸计算,允许收缩
flex: 0 0 auto 不伸展,也不收缩
flex: 1 1 auto 保留内容基准,再分配空间
flex: 1 1 0 从零基准开始分配空间
flex: 1 常用简写,浏览器通常展开为 1 1 0%

grow:剩余空间怎么分

假设容器宽 600px,两个子项的基准宽度都是 100px:

.first  { flex: 1 1 100px; }
.second { flex: 3 1 100px; }

先扣除基准尺寸,剩余 600 - 100 - 100 = 400px

子项 基准宽度 获得剩余空间 最终宽度
first 100px 100px 200px
second 100px 300px 400px

grow 按比例分配剩余空间,不直接规定最终宽度比例。 上面系数为 1:3,最终宽度却是 1:2。

计算假设没有 gap、边距、内边距、边框,也没有触发最小或最大尺寸限制。

shrink:空间不足怎么缩

假设容器宽 300px,两个子项的基准宽度为 200px、400px:

.first  { flex: 0 1 200px; min-width: 0; }
.second { flex: 0 1 400px; min-width: 0; }

需要减少的宽度是 200 + 400 - 300 = 300px

收缩按“shrink 系数 × 基准尺寸”分配。在没有其他尺寸限制时,第一项减少 100px,第二项减少 200px,最终宽度是 100px 和 200px。

所以,两个子项都写 flex-shrink: 1,不意味着各缩小相同宽度。

basis:从多大开始计算

flex-basis 是主轴上的基准尺寸:row 中影响宽度,column 中影响高度。

auto 通常参考主轴上的 width 或 height;该尺寸也是 auto 时,再参考内容。0 则从零长度基准开始分配,常用于比例布局。

flex: 1 常见的 0% 与长度 0 并不总是等价。主轴尺寸不确定时,百分比可能无法解析,进而按内容参与计算。需要局部滚动时,应先给父级明确的可用高度。

为什么还要 min-width: 0

Flex 子项的自动最小尺寸可能受内容限制。长标题即使写了 flex: 1,仍可能拒绝继续缩小。

  • 横向需要允许收缩:min-width: 0
  • 纵向需要允许收缩:min-height: 0

这两个属性只放开尺寸下限,不负责文字省略或开启滚动。

5. 三个常见 Flex 用例

用例一:左侧长标题,右侧固定按钮

预期效果是按钮保持宽度,标题使用剩余空间,放不下时显示省略号。

<view class="task-row">
  <text class="task-row__title">一段可能很长的任务名称</text>
  <button class="task-row__action" size="mini">查看</button>
</view>
.task-row {
  display: flex;
  align-items: center;
}

.task-row__title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.task-row__action {
  flex: 0 0 auto;
  margin: 0 0 0 16rpx;
}

标题上的 flexmin-width 和省略号规则需要配合使用。只写 text-overflow 不会限制标题宽度。

用例二:空状态水平、垂直居中

<view class="empty-state">
  <text>暂无内容</text>
</view>
.empty-state {
  min-height: 400rpx;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

justify-content 在这里负责垂直居中,align-items 负责水平居中。

如果容器高度恰好等于文字高度,就没有额外空间可供分配,垂直居中看不出效果。

用例三:标签放不下时换行

<view class="tags">
  <text class="tags__item">风景</text>
  <text class="tags__item">家庭相册</text>
  <text class="tags__item">最近上传</text>
</view>
.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 16rpx;
}

.tags__item {
  flex: 0 1 auto;
  max-width: 100%;
  overflow-wrap: anywhere;
}

Flex 默认不换行,flex-wrap: wrap 才允许产生多行。

align-items 管每行内部的排列;align-content 在多行且交叉轴有剩余空间时,管多行整体的分布。各行独立分配空间,因此 Flex 换行不保证各列严格对应。

6. 固定头尾,中间滚动

这种页面需要先限定整体高度,再把剩余高度分给中间区域:

页面:高度有界
  ├─ 头部:内容高度
  ├─ 主体:占剩余高度
  │    └─ scroll-view:填满主体
  └─ 底部:内容高度

模板

<view class="page">
  <view class="page__header">标题</view>

  <view class="page__body">
    <scroll-view class="page__scroll" scroll-y>
      <view class="page__list">
        <view v-for="n in 50" :key="n">第 {{ n }} 项</view>
      </view>
    </scroll-view>
  </view>

  <view class="page__footer">底部工具栏</view>
</view>

外层:分配高度

.page {
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.page__header,
.page__footer {
  flex: 0 0 auto;
  padding: 24rpx 32rpx;
}

.page__body {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

内层:提供滚动区域

.page__scroll {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.page__list {
  box-sizing: border-box;
  padding: 24rpx 32rpx;
}

中间的 view 参与 Flex 分配,内部 scroll-view 通过定位填满这个确定的区域。

overflow: hidden 负责裁剪,scroll-y 才负责开启纵向滚动。如果改用页面自身滚动,则使用随内容增长的页面容器,不必再套同方向的局部滚动区。

7. 对应到 Kotlin Compose 怎么写

CSS 与 Compose 可以按布局意图对应,但没有逐个属性完全等价的转换。下面集中看几个常见场景。

纵向排列:Column

对应 display: flex; flex-direction: column; align-items: center

Column(
    modifier = Modifier.fillMaxSize(),
    horizontalAlignment = Alignment.CenterHorizontally,
) {
    Text("标题")
    Text("内容", modifier = Modifier.fillMaxWidth())
}

Compose 的 Column 默认不会把子项拉满宽度,需要在相应子项上写 fillMaxWidth()。它与 CSS 默认的 stretch 行为不同。

横向分配:Row 与 weight

对应前面的“长标题 + 固定按钮”:

Row(
    modifier = Modifier.fillMaxWidth(),
    verticalAlignment = Alignment.CenterVertically,
) {
    Text(
        text = "一段可能很长的任务名称",
        modifier = Modifier.weight(1f),
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
    )
    Spacer(Modifier.width(8.dp))
    Button(onClick = {}) {
        Text("查看")
    }
}

weight(1f) 表示标题使用 Row 分配的剩余宽度。它只能在相应 Row 或 Column 作用域中使用,权重必须大于零。

weight 没有 CSS shrink 的完整计算规则。在同方向无限约束的滚动容器内,也不能期待它按有限屏幕空间分配尺寸。

主轴排列:Arrangement

对 Row 设置 horizontalArrangement,对 Column 设置 verticalArrangement

CSS Row / Column 中的 Arrangement
justify-content: flex-start Start / Top
justify-content: center Center
justify-content: flex-end End / Bottom
justify-content: space-between SpaceBetween
固定 gap spacedBy(8.dp)

交叉轴排列:Alignment

对 Row 设置 verticalAlignment,对 Column 设置 horizontalAlignment

CSS Row / Column 中的 Alignment
align-items: flex-start Top / Start
align-items: center CenterVertically / CenterHorizontally
align-items: flex-end Bottom / End

单个子项覆盖父级规则时,CSS 使用 align-self,Compose 使用相应作用域的 Modifier.align(...)

stretch 没有直接对应的 Alignment。常见做法是在有界尺寸下给子项设置 fillMaxWidth()fillMaxHeight()

自动换行:FlowRow

对应 flex-wrap: wrap

FlowRow(
    modifier = Modifier.fillMaxWidth(),
    horizontalArrangement = Arrangement.spacedBy(8.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp),
) {
    listOf("风景", "家庭相册", "最近上传").forEach {
        Text(it)
    }
}

部分 Compose Foundation 版本需要为 FlowRow 添加 ExperimentalLayoutApi 的 opt-in。

右上角覆盖:Box

对应前面的 relative + absolute 场景:

Box(modifier = Modifier.fillMaxWidth().height(160.dp)) {
    Text("预览内容")
    Text(
        text = "已完成",
        modifier = Modifier
            .align(Alignment.TopEnd)
            .padding(top = 8.dp, end = 8.dp),
    )
}

Box 普通子项可以参与父级测量;CSS 绝对定位元素不参与正常流尺寸计算。这里是布局效果相近,测量规则并不相同。

中间滚动:Column 与 LazyColumn

Column(modifier = Modifier.fillMaxSize()) {
    Text("标题", modifier = Modifier.padding(16.dp))

    LazyColumn(
        modifier = Modifier.weight(1f).fillMaxWidth(),
        contentPadding = PaddingValues(16.dp),
    ) {
        items(count = 50) { index ->
            Text("第 ${index + 1} 项")
        }
    }

    Text("底部工具栏", modifier = Modifier.padding(16.dp))
}

普通 scroll-view + v-for 通常创建全部条目;LazyColumn 按可见区域等需要组合和布局条目。两者都能滚动,但不能据此认为渲染性能相同。

内边距:注意 Modifier 顺序

Compose 没有 box-sizing 开关。以父约束允许目标尺寸为前提:

// 外宽 300.dp,内边距从这个宽度中扣除
Modifier.width(300.dp).padding(20.dp)

// 内部宽 300.dp,外部再加内边距,总宽可达 340.dp
Modifier.padding(20.dp).width(300.dp)

Modifier 的顺序会改变测量结果。CSS 的 widthpadding 声明顺序不能这样类比。

8. SCSS 只负责组织样式

前面使用的 Flex 属性都属于 CSS。lang="scss" 让我们可以使用嵌套、变量和 mixin;它不会改变布局算法。

例如:

.info-row {
  display: flex;

  &__body {
    flex: 1;
    min-width: 0;
  }
}

会生成:

.info-row {
  display: flex;
}

.info-row__body {
  flex: 1;
  min-width: 0;
}

&__body 只是拼接类名,不要求后代关系;.info-row .body 才是后代选择器。

scoped 用于限制样式作用范围,不会自动修正容器尺寸,也不等于可以直接修改子组件内部样式。

9. 使用前留意四点

视口高度不一定是内容可用高度。 100vh 示例假设页面根节点能使用整个视口。浏览器工具栏、软键盘、导航栏和 tabBar 可能改变可用区域;安全区和导航占位应由页面统一处理。

rpx 与 dp 使用不同换算方式。 rpx 通常按屏幕宽度相对 750 换算,dp 按 Android 密度换算;大屏配置也可能改变 rpx 的结果。

跨端样式支持需要确认。 gap、复杂换行规则及组件宿主行为可能存在差异。不支持 gap 时,可以用相邻项 margin 实现间距。

先找尺寸来源,再改属性。 横向溢出先看宽度和 min-width;纵向滚动异常先看祖先高度和 min-height。不要同时给每一层加满屏高度、flex 和 overflow。

参考资料