A
第 45 章KOTLIN40 分钟

Compose 布局系统

Column / Row / Box 三大容器、Modifier 链式调用、Spacer、ConstraintLayout Compose、Scaffold 结构、verticalScroll 与 BoxWithConstraints 响应式

学习目标

  • 熟练使用 Column / Row / Box 的排列与对齐参数
  • 理解 Modifier 链式调用顺序的意义
  • 掌握 Scaffold 顶 / 底 / FAB 结构与 contentWindowInsets
  • 用 verticalScroll 与 BoxWithConstraints 处理滚动与响应式
  • 了解 ConstraintLayout Compose 与传统布局的取舍

学习目标

布局是任何 UI 框架的基石。Compose 的布局系统比传统 View 更简洁:没有 XML、没有 LayoutParams、没有各种 LayoutXXX,只有 Composable 函数 + Modifier。

读完本章你将能够:

  • 用 Column / Row / Box 拼出任意静态布局;
  • 理解 Modifier 链式调用的“顺序敏感”,能正确组合 padding / background / size / clickable;
  • 用 Scaffold 一次搭好 TopAppBar / BottomBar / FloatingActionButton;
  • 用 verticalScroll 处理可滚动内容;
  • 用 BoxWithConstraints 实现响应式(窄 / 宽屏切换布局);
  • 了解 ConstraintLayout Compose 的定位与何时用它。

三大容器:Column / Row / Box

1. Column:垂直排列

Column(
    verticalArrangement = Arrangement.spacedBy(8.dp),
    horizontalAlignment = Alignment.CenterHorizontally,
    modifier = Modifier.fillMaxSize(),
) {
    Text("第一行")
    Text("第二行")
    Text("第三行")
}
参数 含义
verticalArrangement 子元素在垂直方向的对齐与间距
horizontalAlignment 子元素在水平方向的对齐

常用 Arrangement:

  • Arrangement.Top / Center / Bottom:按起点 / 中 / 末对齐;
  • Arrangement.spacedBy(8.dp):每两个子之间留 8dp;
  • Arrangement.spacedBy(8.dp, alignment = Alignment.Center):留间距 + 居中;
  • Arrangement.SpaceBetween / SpaceEvenly / SpaceAround:等距分布。

2. Row:水平排列

Row(
    horizontalArrangement = Arrangement.spacedBy(12.dp),
    verticalAlignment = Alignment.CenterVertically,
) {
    Icon(Icons.Default.Person, contentDescription = "用户")
    Spacer(modifier = Modifier.width(4.dp))
    Text("Alice")
}

注意 Row 的对齐轴颠倒:horizontalArrangement 控制水平方向,verticalAlignment 控制垂直。

3. Box:层叠 + 对齐

Box(
    modifier = Modifier.size(120.dp),
    contentAlignment = Alignment.Center,
) {
    Image(painter = painterResource(R.drawable.bg), contentDescription = null)
    Text(
        text = "标签",
        modifier = Modifier.align(Alignment.BottomStart),
    )
}

Box 内部子组件默认从左上开始堆叠,可用 Modifier.align(...) 单独指定每个子对齐位置。

Box 还可设 propagateMinConstraints,决定父级 Box 的 min 约束是否传给子。

Modifier 链式调用

Modifier 是 Compose 中修饰一个 Composable 的所有“附加属性”的载体:padding、background、size、点击、滚动、对齐……它的写法是链式:

Text(
    text = "Hello",
    modifier = Modifier
        .padding(16.dp)
        .background(Color.LightGray)
        .size(200.dp, 80.dp)
        .clickable { /* onClick */ },
)

1. 顺序敏感

Modifier 链的顺序就是应用顺序,前后颠倒效果完全不同。这是 Compose 新手最容易踩的坑:

// 先 padding 再 background:背景只覆盖内容区,padding 外是透明的
Modifier.padding(16.dp).background(Color.Red)

// 先 background 再 padding:背景覆盖到 padding 外,padding 内是内容
Modifier.background(Color.Red).padding(16.dp)

记忆技巧:把 Modifier 链想象成一层层从外向内的“包装”。先写的在外层,后写的在内层。

2. 常用 Modifier 速览

Modifier 作用
padding(all / horizontal / vertical / start / top / end / bottom) 内边距
background(color, shape) 背景
fillMaxSize() / fillMaxWidth() / fillMaxHeight() 填满
size(width, height) / width / height 固定尺寸
wrapContentSize(align) 内容自适应
clickable(onClick) / pointerInput 点击 / 手势
clip(shape) / border(width, color, shape) 圆角与边框
alpha(value) 透明度
offset(x, y) 位移
align(alignment) 在 Box / Column / Row 中对齐
weight(weight) 在 Row / Column 中按比例分配空间

3. Modifier 不可变 + 拼接

val baseModifier = Modifier
    .fillMaxWidth()
    .padding(16.dp)

// 复用
Text("A", modifier = baseModifier.background(Color.Gray))
Text("B", modifier = baseModifier.background(Color.Yellow))

Modifier 是不可变的,每次 .xxx() 都返回新实例,安全复用。

4. weight:比例分配

只在 Row / Column 的直接子级有效:

Row {
    Text("左", modifier = Modifier.weight(1f))
    Text("右", modifier = Modifier.weight(2f))
}
// 左:右 = 1:2

Modifier.weight(1f, fill = true) 中 fill 决定是否撑满剩余空间(默认 true)。

5. 自定义 Modifier

fun Modifier.debugBorder(color: Color = Color.Red): Modifier =
    this.then(Modifier.border(1.dp, color))

// 使用
Text("调试", modifier = Modifier.debugBorder())

复杂自定义可用 composed { } 让 Modifier 内部访问 remember 与状态。

Spacer

Column {
    Text("A")
    Spacer(modifier = Modifier.height(8.dp))
    Text("B")
}

Spacer 在视觉上留空,比 padding 在多子组件场景更直观。

ConstraintLayout Compose

ConstraintLayout 在传统 View 中是性能王者,但在 Compose 中由于 Column / Row / Box 已能拼出绝大多数布局,且 Compose 自身测量机制更高效,ConstraintLayout Compose 不再是首选。

它的用途:

  1. 复杂约束关系(多个组件相对位置相互依赖);
  2. 从传统 ConstraintLayout XML 迁移时保持等价。

依赖:

implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
@Composable
fun ConstraintLayoutExample() {
    ConstraintLayout(modifier = Modifier.fillMaxSize()) {
        val (logo, title, subtitle) = createRefs()

        Image(
            painter = painterResource(R.drawable.logo),
            contentDescription = null,
            modifier = Modifier
                .size(64.dp)
                .constrainAs(logo) {
                    top.linkTo(parent.top, margin = 16.dp)
                    start.linkTo(parent.start, margin = 16.dp)
                },
        )
        Text(
            text = "标题",
            modifier = Modifier.constrainAs(title) {
                top.linkTo(logo.top)
                start.linkTo(logo.end, margin = 8.dp)
            },
        )
        Text(
            text = "副标题",
            modifier = Modifier.constrainAs(subtitle) {
                top.linkTo(title.bottom)
                start.linkTo(title.start)
            },
        )
    }
}

建议:能用 Column / Row / Box 表达的就别用 ConstraintLayout,可读性更高、维护更简单。只在约束真复杂时用。

Scaffold:App 骨架

Scaffold 是 Material3 提供的“页面骨架”,预置了 TopAppBar / BottomBar / FAB / Snackbar 的位置与 WindowInsets 处理:

@Composable
fun AppScaffold() {
    var clickCount by remember { mutableStateOf(0) }
    val snackbarHostState = remember { SnackbarHostState() }
    val scope = rememberCoroutineScope()

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("首页") },
                navigationIcon = {
                    IconButton(onClick = { /* 抽屉 */ }) {
                        Icon(Icons.Default.Menu, contentDescription = "菜单")
                    }
                },
            )
        },
        bottomBar = {
            NavigationBar {
                NavigationBarItem(
                    selected = true,
                    onClick = { },
                    icon = { Icon(Icons.Default.Home, contentDescription = null) },
                    label = { Text("首页") },
                )
            }
        },
        floatingActionButton = {
            FloatingActionButton(onClick = {
                clickCount++
                scope.launch {
                    snackbarHostState.showSnackbar("点击 $clickCount 次")
                }
            }) {
                Icon(Icons.Default.Add, contentDescription = "添加")
            }
        },
        snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
        contentWindowInsets = WindowInsets.statusBars,
    ) { innerPadding ->
        Box(modifier = Modifier.padding(innerPadding)) {
            Text("内容区")
        }
    }
}

要点:

  • Scaffold 接收 6 个 @Composable 槽:topBar / bottomBar / floatingActionButton / snackbarHost / content (lambda) / drawerContainer;
  • innerPadding 自动处理 TopAppBar 与状态栏 / 导航栏的 WindowInsets,不要手动加 padding,否则会双重留白;
  • contentWindowInsets 控制 content 区域消费哪些 insets(默认消费全部)。

滚动:verticalScroll / horizontalScroll / lazy

1. 单组件可滚动

短列表 / 表单常用 verticalScroll:

val state = rememberScrollState()

Column(
    modifier = Modifier
        .fillMaxSize()
        .verticalScroll(state),
) {
    repeat(50) { i ->
        Text("第 $i 行", modifier = Modifier.padding(8.dp))
    }
}

verticalScroll 接收 ScrollState,可保存 / 恢复滚动位置。它的特点是:所有子节点一次性创建(不像 LazyColumn 复用),适合 少量内容。

2. 嵌套滚动

Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
    // 标题
    Text("Banner", modifier = Modifier.height(200.dp))

    // 横向滚动
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .horizontalScroll(rememberScrollState()),
    ) {
        repeat(10) { i ->
            Text("卡片 $i", modifier = Modifier.padding(8.dp))
        }
    }
}

不同方向滚动天然支持嵌套,不需要特殊配置。

3. 大列表用 LazyColumn

数据量大(超过一屏)时必须用 LazyColumn / LazyRow,它会按需创建并复用节点(下章详讲)。

BoxWithConstraints:响应式

BoxWithConstraints 暴露父容器施加的尺寸约束,让你在窄屏 / 宽屏切换布局:

@Composable
fun ResponsiveLayout(items: List<String>) {
    BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
        val isWide = maxWidth > 600.dp
        if (isWide) {
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                items.forEach { Text(it) }
            }
        } else {
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                items.forEach { Text(it) }
            }
        }
    }
}

maxWidth / minWidth / maxHeight / minHeight 都是 Dp,可读也可比较。

实际项目中常用的“平板分屏 / 手机单列”切换,就是 BoxWithConstraints + 阈值 的组合。

综合示例:登录表单

把以上知识串成一个完整表单:

@Composable
fun LoginForm(onSubmit: (String, String) -> Unit) {
    var username by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    val state = rememberScrollState()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(state)
            .padding(24.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Text("登录", style = MaterialTheme.typography.headlineMedium)

        Spacer(Modifier.height(8.dp))

        TextField(
            value = username,
            onValueChange = { username = it },
            label = { Text("用户名") },
            singleLine = true,
            modifier = Modifier.fillMaxWidth(),
        )

        TextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("密码") },
            visualTransformation = PasswordVisualTransformation(),
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
            modifier = Modifier.fillMaxWidth(),
        )

        Button(
            onClick = { onSubmit(username, password) },
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 8.dp),
        ) { Text("提交") }
    }
}

@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun LoginFormPreview() {
    MaterialTheme {
        LoginForm(onSubmit = { u, p -> println("$u / $p") })
    }
}

观察:

  • verticalScroll(state) 让键盘弹出时表单仍可滚动;
  • Arrangement.spacedBy 让子元素自动留间距,省 Spacer;
  • Modifier.fillMaxWidth() 让输入框占满;
  • @Preview 配 widthDp / heightDp 模拟真机尺寸。

常见坑与最佳实践

  1. Modifier 顺序写反:padding 与 background / clickable 顺序错位导致样式不对。链 = 包装顺序,先写在外层。
  2. Modifier.weight 在 Column 外用:weight 只在 Column / Row 直接子级有效,写在 Box 子级或嵌套层级里会被忽略。需要拉到外层。
  3. fillMaxWidth 之后又加 wrapContentWidth:两者冲突,后者覆盖前者。布局抖动通常是 modifier 写法不一致。
  4. verticalScroll 包 LazyColumn:LazyColumn 自带滚动,再被 verticalScroll 包会被编译器警告甚至崩溃。两者择一。
  5. innerPadding 被忽略:用 Scaffold 必须把 content lambda 收到的 padding 应用到根,否则状态栏 / 顶栏会盖住内容。
  6. 嵌套 Column 又用 weight:外层 Column 与内层 Column 都用 weight 时高度会爆。复杂层级要拆 BoxWithConstraints 或换 ConstraintLayout。
  7. 用 Box 装 weight:weight 在 Box 子级不存在。Box 内对齐靠 Modifier.align(...)。
  8. 手动处理 statusBars:Android 13+ 状态栏 / 导航栏用 WindowInsets.statusBars / navigationBars 处理,不要写死 Modifier.padding(top = 24.dp)。
  9. verticalScroll 放大列表:少量内容用 verticalScroll,数据量大用 LazyColumn 才不会爆内存。
  10. Modifier 重复创建:高频调用的 Modifier 建议 val 缓存,或写到顶层 Modifier。重组时反复 new 会有微小开销。

章节小结

  • Column / Row / Box 是三大容器:注意 Arrangement 与 Alignment 轴对应;
  • Modifier 链是顺序敏感的“包装”,先写的在外层;常用 padding / background / size / fillMax / clickable / align / weight;
  • Spacer 留白比反复 padding 更直观;weight 在 Row / Column 子级按比例分;
  • ConstraintLayout Compose 不是首选,仅在约束真复杂或迁移 XML 时使用;
  • Scaffold 提供顶 / 底 / FAB / Snackbar 骨架,content lambda 的 innerPadding 必须应用到根;
  • verticalScroll 适合少量内容,LazyColumn 适合长列表;BoxWithConstraints 用 maxWidth > N.dp 切换响应式布局。

下一章预告

下一章我们走进 Compose 常用组件:Text / Button / OutlinedButton / TextButton、TextField / OutlinedTextField、Image / Icon / Card / Slider / Checkbox / RadioButton / Switch、AlertDialog 与 Snackbar(SnackbarHostState)。掌握这些 M3 组件后,你就能拼出绝大多数业务表单与卡片。