当代码化为画笔,画布即是屏幕 —— 用声明式构图法绘制 Android 界面
艺术与工程的融合
「画家是万物的主宰。」五百年前,我在佛罗伦萨的工坊中同时研究人体解剖与透视法 —— 因为伟大的作品从来不是纯粹的艺术,也不是纯粹的工程,而是两者的完美融合。今天,Jetpack Compose 正是这样一件杰作:每一个 Composable 函数都是你的画笔,布局就是构图法则,状态管理则是画布背后精密的机械齿轮。当你用代码描绘界面时,你同时是艺术家和工程师。来吧,让我们一起在数字画布上创造杰作。
想象一下,如果每次作画都要先在一张纸上写下指令 —— "在坐标 (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
}
这种旧画法有几个致命的缺陷:
findViewById,手动管理所有状态同步 —— 画面越复杂,越容易出错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)。界面是状态的函数 —— 正如一幅画是光影和透视法的函数。状态变了,画面自动重新呈现。
| 对比项 | 传统 XML + View | Jetpack Compose |
|---|---|---|
| 布局文件 | 需要 XML | 纯 Kotlin,无需 XML |
| 控件引用 | findViewById / ViewBinding | 不需要 |
| 状态同步 | 手动更新每个控件 | 自动重组 |
| 代码量 | 多 | 显著减少 |
| 预览 | 需要编译运行 | @Preview 实时预览 |
| 学习曲线 | XML + Kotlin 双重学习 | 只需要学 Kotlin |
在我的工坊里,每一支画笔都有它的用途 —— 有的画轮廓,有的涂色彩,有的描细节。在 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 注解就是你的速写本 —— 无需将作品搬到手机上运行,Android Studio 会直接在屏幕上展示预览效果:
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
Greeting("Android")
}
速写预览的技巧
你可以添加多个 @Preview,用不同参数预览多种状态 —— 就像为同一个场景画多张不同光线条件下的速写。showBackground = true 会显示白色背景,showSystemUi = true 会显示手机外框。预览函数通常以 Preview 结尾命名,且不接受参数。
正如透视法有严格的数学规则,Composable 函数也有自己必须遵守的法则:
LaunchedEffect)。每位画家的调色盘上都有基本色 —— 红、黄、蓝。Compose 的基本组件就是你调色盘上的原色,所有复杂的界面都由它们混合调配而成。
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 就是你界面上精雕细琢的控制装置,onClick 是它的核心机关:
Button(
onClick = { /* 触发机关 */ },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF6200EE)
)
) {
Text("点击我")
}
// 其他按钮变体 —— 不同风格的控制装置
OutlinedButton(onClick = { }) { Text("描边按钮") }
TextButton(onClick = { }) { Text("文字按钮") }
输入框是用户与画面交互的入口,如同在羊皮纸上书写。它需要配合状态来使用 —— 每一笔输入都会被精密的齿轮系统记录下来:
@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("邮箱地址") }
)
}
一幅好的画作,图像、符号和留白缺一不可。留白(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)) // 水平留白
构图是一切视觉艺术的灵魂。在文艺复兴时期,我们用黄金分割和透视法来安排画面中每个元素的位置。在 Compose 中,Column、Row、Box 就是你的构图法则 —— 它们决定了画布上各元素如何排列、对齐、叠放。
Column 将子元素从上到下依次排列,如同在羊皮纸上从第一行书写到最后一行。这是最自然的纵向构图法:
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), // 元素间的呼吸间距
horizontalAlignment = Alignment.CenterHorizontally // 沿中轴线对齐
) {
Text("第一行")
Text("第二行")
Text("第三行")
}
Row 将子元素从左到右并排放置,如同在画架上横向排列颜料管。用 horizontalArrangement 控制水平分布,verticalAlignment 控制垂直对齐 —— 这就是工程精度在构图中的体现:
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, // 两端对齐
verticalAlignment = Alignment.CenterVertically // 垂直居中
) {
Text("左边")
Text("右边")
}
Box 让子元素互相叠放,如同油画中的分层技法 —— 先画背景,再画中景,最后画前景。每一层覆盖在上一层之上,形成丰富的层次感:
Box(
modifier = Modifier
.size(200.dp)
.background(Color.LightGray),
contentAlignment = Alignment.Center // 内容居于画面正中
) {
Text("我在正中间")
}
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 | 作用 | 示例 |
|---|---|---|
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)
}
}
}
想象你要绘制一幅超长的卷轴画 —— 比如《清明上河图》那样的鸿篇巨制。如果你一次性把所有内容都画在内存中,画布会不堪重负。聪明的做法是:只绘制观者当前看到的部分,当卷轴滚动时再补充新的内容。这就是 LazyColumn 的精髓 —— 它只渲染屏幕上可见的元素,是传统 RecyclerView 的现代继承者。
切记:不要用普通的 Column 加 forEach 来显示大量数据,那等于一次性展开整幅卷轴,内存会立刻爆掉。
@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() 也有几种不同的写法,适用于不同场景:
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 的用法与 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。
如果说 Composable 函数是画面上可见的笔触,那么状态管理就是画布背后隐藏的机械齿轮系统。在我的工坊里,许多精密装置的外表看起来很简单,但背后的齿轮、弹簧和连杆才是真正的核心。理解了状态,你就掌握了 Compose 这台精密机器的运转原理。
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")
}
}
}
}
在我设计飞行器时,控制系统和展示面板从来不是一体的 —— 控制杆在一处,指示器在另一处,但它们通过精密的连杆和齿轮相连。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-- }
)
}
状态提升的工程口诀
状态下沉,事件上浮。父级把状态值传给子级(下沉,如同齿轮驱动指针),子级通过回调把事件传给父级(上浮,如同操纵杆的反馈)。这样子级变成无状态的纯展示组件,可以像标准零件一样在不同地方复用和单独测试。
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 来管理 —— 那是一个更强大的保险库。
每一位大师都有自己的色彩哲学。提香以温暖的金色调闻名,卡拉瓦乔以戏剧性的明暗对比著称。在 Compose 中,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:基础画板
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 引入了动态颜色(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 中定义的颜色 —— 就像同一幅画作在烛光和日光下呈现出不同的韵味。
一座伟大的建筑不会只有一个房间。佛罗伦萨大教堂有中殿、侧廊、穹顶和地下墓室,参观者需要一条清晰的动线来游览。你的应用也是如此 —— Navigation Compose 就是你设计的参观动线,引导用户在不同的"展厅"(页面)之间流畅穿行。
首先在 build.gradle.kts 中准备好建筑材料:
// build.gradle.kts (Module)
dependencies {
implementation("androidx.navigation:navigation-compose:2.7.7")
}
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+ 支持)。
用 Compose 绘制一张个人资料卡片。卡片应包含:圆形头像占位符(用 Box + CircleShape + 首字母)、姓名(粗体,大字号)、个人简介(灰色小字)。使用 Card 组件作为画框,内部用 Column 进行纵向构图,用 Modifier 设置留白与圆角。
先用 Card 作为外层容器,设置 elevation 和 shape。内部用 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
)
}
}
}使用 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
)
}
}
}
}
}构建一个简单的待办事项管理器,练习状态管理。功能要求:一个输入框让用户输入新待办,一个按钮将待办添加到列表中,用 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
)
}
}
}
}
}
}运用状态提升原则,构建一个可复用的星级评分组件。要求:创建一个无状态的 RatingBar 组件,接收当前评分 rating: Int 和回调 onRatingChange: (Int) -> Unit,显示 5 颗星(选中的用实心图标,未选中的用空心图标)。然后创建一个父级 RatingScreen,持有状态,在评分下方显示文字(如 "你的评分:3 / 5")。
RatingBar 用 Row 排列 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
)
}
}综合运用本章所有知识,构建一个带搜索功能的联系人列表。要求:顶部有一个搜索框,用户输入时实时过滤联系人列表;下方用 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
)
}
}
}
}
}