uni-app SCSS 容器与 Flex 布局:从页面根节点到 Kotlin Compose
写 uni-app 布局时,可以先确定三件事:页面占多大、子项怎么排、剩余空间给谁。根容器的属性由这三个问题决定,不需要给每个容器都套同一组样式。
本文从一个普通页面开始,逐步介绍子容器、Flex 和 Kotlin Compose 的对应写法。
适用范围:普通
.vue页面的 CSS 布局。nvue、uni-app x 的支持范围需要另行确认。Compose 示例省略 import,尺寸用于示意,rpx与dp不能直接等量换算。
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;
}
标题上的 flex、min-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 的 width、padding 声明顺序不能这样类比。
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。


