第8章:Jetpack Compose UI

当代码化为画笔,画布即是屏幕 —— 用声明式构图法绘制 Android 界面

🎨

达芬奇 (Leonardo da Vinci)

艺术与工程的融合

「画家是万物的主宰。」五百年前,我在佛罗伦萨的工坊中同时研究人体解剖与透视法 —— 因为伟大的作品从来不是纯粹的艺术,也不是纯粹的工程,而是两者的完美融合。今天,Jetpack Compose 正是这样一件杰作:每一个 Composable 函数都是你的画笔,布局就是构图法则,状态管理则是画布背后精密的机械齿轮。当你用代码描绘界面时,你同时是艺术家和工程师。来吧,让我们一起在数字画布上创造杰作。

8.1 声明式 UI vs 命令式 UI

传统 XML 布局(命令式):笨拙的旧画法

想象一下,如果每次作画都要先在一张纸上写下指令 —— "在坐标 (100, 200) 处画一个红色方块",然后交给助手去执行。你看不到画布的全貌,只能通过一条条指令来控制每个细节。这就是传统 Android 开发中 XML 布局的工作方式:先在一个文件里定义结构,再到另一个文件中用 findViewById 逐一抓取控件,手动指挥每一个元素的变化。

// 传统方式:像给助手下指令,手动操控每一个控件
val tvCount = findViewById<TextView>(R.id.tvCount)
val btnAdd = findViewById<Button>(R.id.btnAdd)

var count = 0
btnAdd.setOnClickListener {
    count++
    tvCount.text = "点击了 $count 次"   // 手动更新 UI
}

这种旧画法有几个致命的缺陷:

Compose(声明式):画家亲自执笔

Jetpack Compose 带来了全新的创作方式。正如我在绘制《最后的晚餐》时,脑中先有完整的构图,再让画面自然呈现 —— Compose 让你直接描述画面应该是什么样子。当底层数据变化时,框架会自动重新渲染画布,你无需亲手擦拭和重画每一笔。

// Compose 方式:描绘你心中的画面,框架自动呈现
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text("点击了 $count 次")       // 数据变了,画布自动更新
        Button(onClick = { count++ }) {
            Text("点击 +1")
        }
    }
}

对比一目了然 —— 不需要 XML 草稿纸,不需要 findViewById 来寻找画布上的元素,不需要手动调用 setText 来修改笔触。艺术与工程,在这里合二为一。

跨平台开发者会觉得很亲切

如果你用过 React、Flutter 或 SwiftUI,Compose 的核心思想和它们一样:UI = f(state)。界面是状态的函数 —— 正如一幅画是光影和透视法的函数。状态变了,画面自动重新呈现。

为什么 Google 推荐 Compose?

对比项 传统 XML + View Jetpack Compose
布局文件 需要 XML 纯 Kotlin,无需 XML
控件引用 findViewById / ViewBinding 不需要
状态同步 手动更新每个控件 自动重组
代码量 显著减少
预览 需要编译运行 @Preview 实时预览
学习曲线 XML + Kotlin 双重学习 只需要学 Kotlin

8.2 Composable 函数基础

@Composable 注解:你的画笔

在我的工坊里,每一支画笔都有它的用途 —— 有的画轮廓,有的涂色彩,有的描细节。在 Compose 中,函数就是你的画笔。只要给一个函数加上 @Composable 注解,它就变成了一支可以在画布上留下痕迹的画笔,能够发射(emit)UI 元素。

@Composable
fun Greeting(name: String) {
    Text(text = "你好, $name!")
}

使用这支画笔也很自然 —— 在另一个 Composable 函数里直接调用即可,就像在大型壁画中复用同一种笔法:

@Composable
fun MyApp() {
    Greeting("Kotlin 开发者")
}

在 Activity 中展开你的画布:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApp()   // 从这里开始在画布上作画
        }
    }
}

@Preview:工坊中的速写预览

文艺复兴时期的大师在绘制正式作品前,总会先画速写来验证构图。@Preview 注解就是你的速写本 —— 无需将作品搬到手机上运行,Android Studio 会直接在屏幕上展示预览效果:

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    Greeting("Android")
}

速写预览的技巧

你可以添加多个 @Preview,用不同参数预览多种状态 —— 就像为同一个场景画多张不同光线条件下的速写。showBackground = true 会显示白色背景,showSystemUi = true 会显示手机外框。预览函数通常以 Preview 结尾命名,且不接受参数。

Composable 函数的法则

正如透视法有严格的数学规则,Composable 函数也有自己必须遵守的法则:

8.3 常用组件

每位画家的调色盘上都有基本色 —— 红、黄、蓝。Compose 的基本组件就是你调色盘上的原色,所有复杂的界面都由它们混合调配而成。

Text:书法与铭文

Text 是最基础的组件,如同画作中的铭文与标注。通过参数可以精细控制字体、字号、颜色 —— 就像选择不同的鹅毛笔和墨水:

@Composable
fun TextExamples() {
    Column {
        // 基本文字 —— 朴素的铭文
        Text("Hello Compose!")

        // 设置大小和粗细 —— 如同大写花体字
        Text(
            text = "大标题",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold
        )

        // 设置颜色和最大行数 —— 调色与裁剪
        Text(
            text = "这是一段带颜色的文字,最多显示两行...",
            color = Color.Blue,
            maxLines = 2,
            overflow = TextOverflow.Ellipsis
        )
    }
}

Button:精雕的控制装置

在我设计的机械装置中,每一个操纵杆都经过精心雕琢,既美观又实用。Button 就是你界面上精雕细琢的控制装置,onClick 是它的核心机关:

Button(
    onClick = { /* 触发机关 */ },
    colors = ButtonDefaults.buttonColors(
        containerColor = Color(0xFF6200EE)
    )
) {
    Text("点击我")
}

// 其他按钮变体 —— 不同风格的控制装置
OutlinedButton(onClick = { }) { Text("描边按钮") }
TextButton(onClick = { }) { Text("文字按钮") }

TextField:书写的羊皮纸

输入框是用户与画面交互的入口,如同在羊皮纸上书写。它需要配合状态来使用 —— 每一笔输入都会被精密的齿轮系统记录下来:

@Composable
fun InputExample() {
    var text by remember { mutableStateOf("") }

    TextField(
        value = text,
        onValueChange = { text = it },
        label = { Text("请输入用户名") },
        placeholder = { Text("例如: zhangsan") }
    )

    // OutlinedTextField: 带描边的输入框,更常用
    OutlinedTextField(
        value = text,
        onValueChange = { text = it },
        label = { Text("邮箱地址") }
    )
}

Image、Icon 与 Spacer:图像、图标与留白

一幅好的画作,图像、符号和留白缺一不可。留白(Spacer)尤其重要 —— 正如我常说,画家要学会画"无",空间本身就是构图的一部分:

// Image: 在画布上嵌入图像
Image(
    painter = painterResource(id = R.drawable.photo),
    contentDescription = "用户头像",
    modifier = Modifier
        .size(80.dp)
        .clip(CircleShape)         // 裁剪为圆形
)

// Icon: Material 图标 —— 精炼的符号语言
Icon(
    imageVector = Icons.Default.Favorite,
    contentDescription = "收藏",
    tint = Color.Red
)

// Spacer: 留白 —— 构图中不可或缺的呼吸空间
Spacer(modifier = Modifier.height(16.dp))  // 垂直留白
Spacer(modifier = Modifier.width(8.dp))    // 水平留白

8.4 布局组件

构图是一切视觉艺术的灵魂。在文艺复兴时期,我们用黄金分割和透视法来安排画面中每个元素的位置。在 Compose 中,ColumnRowBox 就是你的构图法则 —— 它们决定了画布上各元素如何排列、对齐、叠放。

Column:纵向构图

Column 将子元素从上到下依次排列,如同在羊皮纸上从第一行书写到最后一行。这是最自然的纵向构图法:

Column(
    modifier = Modifier.padding(16.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp),  // 元素间的呼吸间距
    horizontalAlignment = Alignment.CenterHorizontally  // 沿中轴线对齐
) {
    Text("第一行")
    Text("第二行")
    Text("第三行")
}

Row:横向构图

Row 将子元素从左到右并排放置,如同在画架上横向排列颜料管。用 horizontalArrangement 控制水平分布,verticalAlignment 控制垂直对齐 —— 这就是工程精度在构图中的体现:

Row(
    modifier = Modifier.fillMaxWidth(),
    horizontalArrangement = Arrangement.SpaceBetween,  // 两端对齐
    verticalAlignment = Alignment.CenterVertically     // 垂直居中
) {
    Text("左边")
    Text("右边")
}

Box:层叠构图

Box 让子元素互相叠放,如同油画中的分层技法 —— 先画背景,再画中景,最后画前景。每一层覆盖在上一层之上,形成丰富的层次感:

Box(
    modifier = Modifier
        .size(200.dp)
        .background(Color.LightGray),
    contentAlignment = Alignment.Center  // 内容居于画面正中
) {
    Text("我在正中间")
}

Modifier:画笔的附加属性

Modifier 是 Compose 最核心的概念之一。如果说 Composable 函数是你的画笔,那么 Modifier 就是控制画笔粗细、颜色、压力和角度的那只手。它用优雅的链式调用来描述组件的大小、边距、背景、交互行为:

Text(
    text = "带修饰符的文字",
    modifier = Modifier
        .fillMaxWidth()                    // 宽度铺满整个画布
        .padding(16.dp)                      // 四周留白
        .background(                         // 底色与圆角
            color = Color(0xFFF0F4FF),
            shape = RoundedCornerShape(8.dp)
        )
        .clickable { /* 触发交互 */ }        // 赋予可点击能力
)

Modifier 的顺序如同画层的顺序!

在油画技法中,先上底色再画细节和先画细节再铺底色,效果截然不同。Modifier 也是如此 —— 它按顺序执行。.padding(16.dp).background(Color.Red).background(Color.Red).padding(16.dp) 效果完全不同:前者背景在留白内侧,后者背景包含留白区域。掌握这个规律,你就掌握了 Compose 构图的透视法。

常用 Modifier 速查

Modifier 作用 示例
padding 内边距(留白) .padding(16.dp)
fillMaxWidth 宽度铺满 .fillMaxWidth()
fillMaxSize 宽高都铺满 .fillMaxSize()
size 固定尺寸 .size(100.dp)
weight 按比例分配空间(Row/Column 内) .weight(1f)
background 背景色(底色) .background(Color.Gray)
clickable 可点击(交互机关) .clickable { }
border 边框(画框) .border(1.dp, Color.Gray)

实战:构建一幅联系人卡片

现在,让我们将 Column、Row、Modifier 这些构图工具组合起来,像在画布上构图一样,精心安排一张联系人卡片的每一个元素 —— 左侧是圆形头像,右侧是姓名与电话,整体用圆角背景包裹:

@Composable
fun ContactCard(name: String, phone: String) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(12.dp)
            .background(
                color = Color.White,
                shape = RoundedCornerShape(12.dp)
            )
            .padding(16.dp),  // 内层留白
        verticalAlignment = Alignment.CenterVertically
    ) {
        // 左侧:圆形头像 —— 如同肖像画中的面部
        Box(
            modifier = Modifier
                .size(48.dp)
                .background(Color(0xFF7F5AF0), CircleShape),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = name.first().toString(),
                color = Color.White,
                fontWeight = FontWeight.Bold
            )
        }

        Spacer(modifier = Modifier.width(12.dp))

        // 右侧:文字信息 —— 如同画作的题款
        Column {
            Text(text = name, fontWeight = FontWeight.Bold)
            Text(text = phone, color = Color.Gray, fontSize = 14.sp)
        }
    }
}

8.5 列表:LazyColumn 与 LazyRow

想象你要绘制一幅超长的卷轴画 —— 比如《清明上河图》那样的鸿篇巨制。如果你一次性把所有内容都画在内存中,画布会不堪重负。聪明的做法是:只绘制观者当前看到的部分,当卷轴滚动时再补充新的内容。这就是 LazyColumn 的精髓 —— 它只渲染屏幕上可见的元素,是传统 RecyclerView 的现代继承者。

切记:不要用普通的 ColumnforEach 来显示大量数据,那等于一次性展开整幅卷轴,内存会立刻爆掉。

LazyColumn 基本用法

@Composable
fun ContactList() {
    val contacts = listOf(
        "张三" to "138-0000-0001",
        "李四" to "138-0000-0002",
        "王五" to "138-0000-0003",
        "赵六" to "138-0000-0004",
        "钱七" to "138-0000-0005"
    )

    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(contacts) { (name, phone) ->
            ContactCard(name = name, phone = phone)
        }
    }
}

items() 的几种笔法

就像书法有楷书、行书、草书之分,items() 也有几种不同的写法,适用于不同场景:

LazyColumn {
    // 1. 传入列表 —— 最常用的写法
    items(contacts) { contact ->
        ContactCard(contact)
    }

    // 2. 传入数量 —— 按数量重复绘制
    items(100) { index ->
        Text("第 $index 项")
    }

    // 3. 单个固定项 —— 卷轴的题头或落款
    item {
        Text("--- 列表结尾 ---")
    }

    // 4. 带 key 提升性能(推荐) —— 给每个元素一个唯一身份
    items(
        items = contacts,
        key = { it.id }   // 唯一标识,避免不必要的重绘
    ) { contact ->
        ContactCard(contact)
    }
}

LazyRow:横向卷轴

LazyRow 的用法与 LazyColumn 完全相同,只是卷轴展开的方向从纵向变为横向 —— 适合横向滚动的标签、图片廊等场景:

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(8.dp),
    contentPadding = PaddingValues(horizontal = 16.dp)
) {
    items(tags) { tag ->
        Chip(text = tag)  // 标签横向滚动
    }
}

LazyColumn vs RecyclerView

传统的 RecyclerView 需要写 Adapter、ViewHolder、布局文件,少说几十行代码 —— 就像用复杂的脚手架搭建一面壁画墙。LazyColumn 用寥寥数行就能完成同样的效果,构图清晰,可读性强。功能上它们不分伯仲 —— 都是只渲染可见区域,都支持不同类型的 item。

8.6 状态管理

如果说 Composable 函数是画面上可见的笔触,那么状态管理就是画布背后隐藏的机械齿轮系统。在我的工坊里,许多精密装置的外表看起来很简单,但背后的齿轮、弹簧和连杆才是真正的核心。理解了状态,你就掌握了 Compose 这台精密机器的运转原理。

remember 与 mutableStateOf:记忆齿轮

Compose 在状态变化时会重组(重新调用 Composable 函数来刷新画面)。如果不做任何处理,每次重组时局部变量都会被重置 —— 就像齿轮每转一圈就回到起点,永远无法前进。remember 是一个记忆齿轮,让值在重组时保留下来;mutableStateOf 则让 Compose 能感知到值的变化,从而触发画面的更新。

// 错误:count 每次重组都会回到原点 —— 齿轮空转
@Composable
fun BrokenCounter() {
    var count = 0  // 每次重组都重置!
    Button(onClick = { count++ }) {
        Text("$count")  // 永远显示 0
    }
}

// 正确:用 remember + mutableStateOf —— 齿轮有了记忆
@Composable
fun WorkingCounter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("$count")  // 正确递增
    }
}

完整计数器:一台精密的计数装置

@Composable
fun CounterApp() {
    var count by remember { mutableStateOf(0) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "$count",
            fontSize = 64.sp,
            fontWeight = FontWeight.Bold
        )

        Spacer(modifier = Modifier.height(24.dp))

        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
            Button(onClick = { count-- }) {
                Text("-1")
            }
            Button(onClick = { count = 0 }) {
                Text("重置")
            }
            Button(onClick = { count++ }) {
                Text("+1")
            }
        }
    }
}

状态提升(State Hoisting):工程师的分层设计

在我设计飞行器时,控制系统和展示面板从来不是一体的 —— 控制杆在一处,指示器在另一处,但它们通过精密的连杆和齿轮相连。Compose 中的状态提升也遵循同样的工程原则:当多个 Composable 需要共享状态时,应该把状态提升到它们共同的父级。子组件只负责呈现画面,状态由外部传入。

// 无状态的 Composable —— 纯粹的显示面板,更容易复用和测试
@Composable
fun CounterDisplay(
    count: Int,
    onIncrement: () -> Unit,
    onDecrement: () -> Unit
) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = "$count", fontSize = 32.sp)
        Row {
            Button(onClick = onDecrement) { Text("-") }
            Button(onClick = onIncrement) { Text("+") }
        }
    }
}

// 父级持有状态 —— 控制中枢
@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }

    CounterDisplay(
        count = count,
        onIncrement = { count++ },
        onDecrement = { count-- }
    )
}

状态提升的工程口诀

状态下沉,事件上浮。父级把状态值传给子级(下沉,如同齿轮驱动指针),子级通过回调把事件传给父级(上浮,如同操纵杆的反馈)。这样子级变成无状态的纯展示组件,可以像标准零件一样在不同地方复用和单独测试。

rememberSaveable:防灾备份机制

remember 只能在重组时保留状态。但当屏幕旋转(Configuration Change)时,Activity 会被销毁重建 —— 就像工坊遭遇了一场小火灾,所有临时草稿都会丢失。rememberSaveable 就是你的防灾备份机制,它把状态保存到 Bundle 中,即使 Activity 重建也能完整恢复:

// remember: 屏幕旋转后 count 回到 0 —— 草稿被火烧毁
var count by remember { mutableStateOf(0) }

// rememberSaveable: 屏幕旋转后 count 保留 —— 存入了保险箱
var count by rememberSaveable { mutableStateOf(0) }

注意

rememberSaveable 只适合保存简单数据(Int, String, Boolean 等基础类型)。复杂对象需要实现 Parcelize 或自定义 Saver。对于大量数据,应该用 ViewModel 来管理 —— 那是一个更强大的保险库。

8.7 主题与 Material Design 3

每一位大师都有自己的色彩哲学。提香以温暖的金色调闻名,卡拉瓦乔以戏剧性的明暗对比著称。在 Compose 中,MaterialTheme 就是你的色彩哲学 —— 它统一管理整个应用的调色盘、字体和形状,确保你的作品风格一致、浑然一体。

MaterialTheme:你的调色哲学

当你新建一个 Compose 项目时,Android Studio 会自动生成主题代码 —— 这就是你的调色盘模板:

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) {
        darkColorScheme(
            primary = Color(0xFFBB86FC),
            secondary = Color(0xFF03DAC5)
        )
    } else {
        lightColorScheme(
            primary = Color(0xFF6200EE),
            secondary = Color(0xFF03DAC6)
        )
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography(),   // 可自定义字体
        content = content
    )
}

在 Composable 中引用主题色 —— 就像从统一的调色盘上取色,保证画面的和谐统一:

Text(
    text = "使用主题色",
    color = MaterialTheme.colorScheme.primary,
    style = MaterialTheme.typography.headlineMedium
)

Surface 和 Card:画框与装裱

一幅画完成后需要画框和装裱。Surface 提供一个带背景色的基础画板,Card 则是自带阴影和圆角的精致画框:

// Surface:基础画板
Surface(
    modifier = Modifier.fillMaxSize(),
    color = MaterialTheme.colorScheme.background
) {
    Text("Hello")
}

// Card:带阴影的精致画框
Card(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp),
    elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
    shape = RoundedCornerShape(12.dp)
) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text("卡片标题", style = MaterialTheme.typography.titleMedium)
        Text("卡片内容", style = MaterialTheme.typography.bodyMedium)
    }
}

动态颜色(Android 12+):画布随环境而变

Android 12 引入了动态颜色(Dynamic Color),它能根据用户的壁纸自动调配色彩方案 —— 就像画家根据展厅的光线和墙壁颜色来调整作品的色调,让画面与环境融为一体:

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        // Android 12+ 且开启动态颜色 —— 与环境共鸣
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        darkTheme -> darkColorScheme()
        else -> lightColorScheme()
    }

    MaterialTheme(
        colorScheme = colorScheme,
        content = content
    )
}

暗色模式 —— 一键切换昼夜画面

用了 MaterialTheme 之后,只要在代码中用 MaterialTheme.colorScheme.xxx 来引用颜色(而不是硬编码颜色值),暗色模式就自动支持了。系统切换深色模式时,Compose 会自动使用 darkColorScheme 中定义的颜色 —— 就像同一幅画作在烛光和日光下呈现出不同的韵味。

8.8 导航(Navigation Compose)

一座伟大的建筑不会只有一个房间。佛罗伦萨大教堂有中殿、侧廊、穹顶和地下墓室,参观者需要一条清晰的动线来游览。你的应用也是如此 —— Navigation Compose 就是你设计的参观动线,引导用户在不同的"展厅"(页面)之间流畅穿行。

添加依赖

首先在 build.gradle.kts 中准备好建筑材料:

// build.gradle.kts (Module)
dependencies {
    implementation("androidx.navigation:navigation-compose:2.7.7")
}

NavController 和 NavHost:建筑师的蓝图

NavController 是导览员,负责记住当前位置和历史路径;NavHost 是建筑的主体结构,你在其中定义每个展厅的路由(route)和对应的内容:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "home"   // 入口大厅
    ) {
        // 定义"首页"展厅
        composable("home") {
            HomeScreen(navController = navController)
        }

        // 定义"详情"展厅
        composable("detail") {
            DetailScreen(navController = navController)
        }
    }
}

页面跳转:展厅之间的通道

navController.navigate() 引导用户穿过通道,到达目标展厅:

@Composable
fun HomeScreen(navController: NavController) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("首页", fontSize = 24.sp)
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = {
            navController.navigate("detail")
        }) {
            Text("去详情页")
        }
    }
}

@Composable
fun DetailScreen(navController: NavController) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("详情页", fontSize = 24.sp)
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = {
            navController.popBackStack()  // 沿原路返回
        }) {
            Text("返回首页")
        }
    }
}

带参数的路由:携带展品信息的通行证

许多时候,跳转页面需要传递数据(比如用户 ID、作品名称)。这就像参观者带着一张标注了特定展品编号的通行证进入展厅:

NavHost(
    navController = navController,
    startDestination = "home"
) {
    composable("home") {
        HomeScreen(
            onUserClick = { userId ->
                // 跳转时带上通行证
                navController.navigate("profile/$userId")
            }
        )
    }

    // 路由中用 {参数名} 定义占位符
    composable("profile/{userId}") { backStackEntry ->
        // 从通行证中读取信息
        val userId = backStackEntry.arguments?.getString("userId")
        ProfileScreen(userId = userId ?: "unknown")
    }
}

完整双页面导航示例:一座小型画廊

让我们把以上所有知识融会贯通,构建一座可以实际运行的小型画廊 —— 用户在列表中浏览作品,点击后进入详情页欣赏:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                Surface(color = MaterialTheme.colorScheme.background) {
                    AppNavigation()
                }
            }
        }
    }
}

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = "list") {
        composable("list") {
            FruitListScreen { fruitName ->
                navController.navigate("detail/$fruitName")
            }
        }
        composable("detail/{fruit}") { backStackEntry ->
            val fruit = backStackEntry.arguments?.getString("fruit") ?: ""
            FruitDetailScreen(fruit = fruit) {
                navController.popBackStack()
            }
        }
    }
}

@Composable
fun FruitListScreen(onFruitClick: (String) -> Unit) {
    val fruits = listOf("苹果", "香蕉", "橘子", "葡萄", "西瓜")

    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(fruits) { fruit ->
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable { onFruitClick(fruit) }
            ) {
                Text(
                    text = fruit,
                    modifier = Modifier.padding(20.dp),
                    fontSize = 18.sp
                )
            }
        }
    }
}

@Composable
fun FruitDetailScreen(fruit: String, onBack: () -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(text = fruit, fontSize = 48.sp)
        Spacer(modifier = Modifier.height(8.dp))
        Text(
            text = "你选择了: $fruit",
            style = MaterialTheme.typography.titleLarge
        )
        Spacer(modifier = Modifier.height(24.dp))
        Button(onClick = onBack) {
            Text("返回列表")
        }
    }
}

导航的工程规范

在真正的大型建筑项目中,每条通道都有编号和标识,不会用模糊的口头描述。你的应用也应如此 —— 建议把路由字符串定义为常量或用 sealed class / enum 管理,避免到处写"魔法字符串"。对于复杂应用,可以考虑使用 Type-safe Navigation(Compose Navigation 2.8+ 支持)。

本章练习

练习 1:绘制个人资料卡片 入门

用 Compose 绘制一张个人资料卡片。卡片应包含:圆形头像占位符(用 Box + CircleShape + 首字母)、姓名(粗体,大字号)、个人简介(灰色小字)。使用 Card 组件作为画框,内部用 Column 进行纵向构图,用 Modifier 设置留白与圆角。

提示

先用 Card 作为外层容器,设置 elevationshape。内部用 Column 居中排列元素。头像用 Box + CircleShape + 固定 size 实现。姓名用 Text 设置 fontWeight = FontWeight.Bold 和较大的 fontSize。简介用 Text 设置 color = Color.Gray。各元素之间用 Spacer 添加留白。

参考答案
@Composable
fun ProfileCard(name: String, bio: String) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
        shape = RoundedCornerShape(16.dp)
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(24.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Box(
                modifier = Modifier
                    .size(72.dp)
                    .background(Color(0xFF7F5AF0), CircleShape),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = name.first().toString(),
                    color = Color.White,
                    fontSize = 28.sp,
                    fontWeight = FontWeight.Bold
                )
            }
            Spacer(modifier = Modifier.height(12.dp))
            Text(
                text = name,
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold
            )
            Spacer(modifier = Modifier.height(4.dp))
            Text(
                text = bio,
                color = Color.Gray,
                fontSize = 14.sp,
                textAlign = TextAlign.Center
            )
        }
    }
}

练习 2:构建可滚动的作品画廊 入门

使用 LazyColumn 构建一个可滚动的"名画画廊"列表。定义一个数据类 Painting(val title: String, val artist: String, val year: Int),创建至少 5 幅名画的数据,用 Card 展示每一幅。每张卡片显示画作名称(粗体)、画家和年份。记得给 items 添加 key 参数。

提示

先定义 data class Painting,然后创建一个 listOf(...) 来存放数据。在 LazyColumn 中使用 items(paintings, key = { it.title })。每个 item 用 Card 包裹,内部用 Column 排列标题、画家和年份。用 Arrangement.spacedBy() 设置卡片之间的间距。

参考答案
data class Painting(
    val title: String,
    val artist: String,
    val year: Int
)

@Composable
fun PaintingGallery() {
    val paintings = listOf(
        Painting("蒙娜丽莎", "达芬奇", 1503),
        Painting("星夜", "梵高", 1889),
        Painting("戴珍珠耳环的少女", "维米尔", 1665),
        Painting("呐喊", "蒙克", 1893),
        Painting("最后的晚餐", "达芬奇", 1498)
    )

    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        items(
            items = paintings,
            key = { it.title }
        ) { painting ->
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(
                    defaultElevation = 2.dp
                )
            ) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Text(
                        text = painting.title,
                        fontWeight = FontWeight.Bold,
                        fontSize = 18.sp
                    )
                    Spacer(modifier = Modifier.height(4.dp))
                    Text(
                        text = "${painting.artist} · ${painting.year}年",
                        color = Color.Gray,
                        fontSize = 14.sp
                    )
                }
            }
        }
    }
}

练习 3:实现一个待办事项管理器 进阶

构建一个简单的待办事项管理器,练习状态管理。功能要求:一个输入框让用户输入新待办,一个按钮将待办添加到列表中,用 LazyColumn 显示所有待办事项,每个待办旁边有一个删除按钮。使用 remember + mutableStateListOf 管理待办列表,mutableStateOf 管理输入框文本。

提示

val todos = remember { mutableStateListOf<String>() } 存储待办列表,用 var inputText by remember { mutableStateOf("") } 存储输入内容。界面用 Column 包裹:顶部是 Row(含 OutlinedTextField + Button),下方是 LazyColumn 显示列表。添加时检查输入非空,添加后清空输入框。删除使用 todos.removeAt(index)

参考答案
@Composable
fun TodoApp() {
    var inputText by remember { mutableStateOf("") }
    val todos = remember { mutableStateListOf<String>() }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // 输入区域
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically
        ) {
            OutlinedTextField(
                value = inputText,
                onValueChange = { inputText = it },
                label = { Text("新待办") },
                modifier = Modifier.weight(1f)
            )
            Spacer(modifier = Modifier.width(8.dp))
            Button(
                onClick = {
                    if (inputText.isNotBlank()) {
                        todos.add(inputText.trim())
                        inputText = ""
                    }
                }
            ) {
                Text("添加")
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        // 待办列表
        LazyColumn(
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(todos.size) { index ->
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(
                            Color(0xFFF0F4FF),
                            RoundedCornerShape(8.dp)
                        )
                        .padding(12.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        text = todos[index],
                        modifier = Modifier.weight(1f)
                    )
                    IconButton(
                        onClick = { todos.removeAt(index) }
                    ) {
                        Icon(
                            Icons.Default.Delete,
                            contentDescription = "删除",
                            tint = Color.Red
                        )
                    }
                }
            }
        }
    }
}

练习 4:状态提升 —— 可复用的评分组件 进阶

运用状态提升原则,构建一个可复用的星级评分组件。要求:创建一个无状态的 RatingBar 组件,接收当前评分 rating: Int 和回调 onRatingChange: (Int) -> Unit,显示 5 颗星(选中的用实心图标,未选中的用空心图标)。然后创建一个父级 RatingScreen,持有状态,在评分下方显示文字(如 "你的评分:3 / 5")。

提示

RatingBarRow 排列 5 个 IconButton。通过 if (i <= rating) 判断使用 Icons.Default.Star(实心)还是 Icons.Default.StarBorder(空心,实际中可用 Icons.Default.Star 配合不同 tint 实现)。点击第 i 颗星时调用 onRatingChange(i)。父级用 var rating by remember { mutableStateOf(0) } 持有状态。

参考答案
// 无状态组件 —— 纯粹的显示面板
@Composable
fun RatingBar(
    rating: Int,
    onRatingChange: (Int) -> Unit,
    maxRating: Int = 5
) {
    Row {
        for (i in 1..maxRating) {
            IconButton(onClick = { onRatingChange(i) }) {
                Icon(
                    imageVector = Icons.Default.Star,
                    contentDescription = "$i 星",
                    tint = if (i <= rating)
                        Color(0xFFFFD700)  // 金色
                    else
                        Color.LightGray
                )
            }
        }
    }
}

// 父级持有状态 —— 控制中枢
@Composable
fun RatingScreen() {
    var rating by remember { mutableStateOf(0) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "请为本章评分",
            fontSize = 20.sp,
            fontWeight = FontWeight.Bold
        )
        Spacer(modifier = Modifier.height(16.dp))
        RatingBar(
            rating = rating,
            onRatingChange = { rating = it }
        )
        Spacer(modifier = Modifier.height(12.dp))
        Text(
            text = if (rating > 0) "你的评分:$rating / 5"
                   else "点击星星进行评分",
            color = Color.Gray
        )
    }
}

练习 5:综合实战 —— 带搜索功能的联系人列表 挑战

综合运用本章所有知识,构建一个带搜索功能的联系人列表。要求:顶部有一个搜索框,用户输入时实时过滤联系人列表;下方用 LazyColumn 显示匹配的联系人卡片(复用之前的 ContactCard);当搜索结果为空时显示提示文字。使用状态提升,将搜索逻辑与显示分离。

提示

定义联系人数据列表。用 var searchQuery by remember { mutableStateOf("") } 管理搜索词。用 val filteredContacts = contacts.filter { it.name.contains(searchQuery, ignoreCase = true) } 过滤列表。界面用 Column:顶部放 OutlinedTextField,下方用 if (filteredContacts.isEmpty()) 决定显示提示文字还是 LazyColumn

参考答案
data class Contact(
    val name: String,
    val phone: String
)

@Composable
fun SearchableContactList() {
    val contacts = remember {
        listOf(
            Contact("张三", "138-0000-0001"),
            Contact("李四", "138-0000-0002"),
            Contact("王五", "138-0000-0003"),
            Contact("赵六", "138-0000-0004"),
            Contact("张伟", "138-0000-0005"),
            Contact("李明", "138-0000-0006")
        )
    }
    var searchQuery by remember { mutableStateOf("") }

    val filteredContacts = contacts.filter {
        it.name.contains(searchQuery, ignoreCase = true)
    }

    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        // 搜索框
        OutlinedTextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            label = { Text("搜索联系人") },
            modifier = Modifier.fillMaxWidth(),
            leadingIcon = {
                Icon(Icons.Default.Search, "搜索")
            }
        )

        Spacer(modifier = Modifier.height(16.dp))

        if (filteredContacts.isEmpty()) {
            // 空状态提示
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = "未找到匹配的联系人",
                    color = Color.Gray,
                    fontSize = 16.sp
                )
            }
        } else {
            // 联系人列表
            LazyColumn(
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                items(
                    items = filteredContacts,
                    key = { it.phone }
                ) { contact ->
                    ContactCard(
                        name = contact.name,
                        phone = contact.phone
                    )
                }
            }
        }
    }
}
← 上一章 目录 下一章 →