跳到正文 Lab Week 4 Summary · Jimmy Huang's Blog
新增
如何实现一个基础的 ExposedDropdownMenu
Task 0: 创建项目并搭好实验入口
Step 0.1:创建 Android Studio 项目 打开 Android Studio,新建一个 Empty Activity 项目。
先拿到一个标准 Compose 项目,后面所有代码都在这个项目里完成。
本周重点是 Compose 组件和 state,不是项目结构本身,所以直接用默认模板最快。
Step 0.2:准备一个可切换的 MainActivity.kt先把 MainActivity.kt 整成下面这种最小可运行骨架。
后面你只需要把 CountryListDisplay1() 换成别的 Composable 名字即可。
把 App 入口固定下来,后面每做完一个版本,只切换这里调用的 Composable。
本周 Lab 是同一个主题下的多个版本。把入口先固定住,你就不用每一步都重新搭环境。
Step 0.3:先准备好常用 import 下面这些 import 后面几乎都会用到。Android Studio 大多会自动补,但如果报错,可以手动检查。
提前把后面高频会用到的 Compose、Material3、state、键盘和菜单相关 import 列出来。
Task 1: 按步骤完成 LazyColumn 基础功能 先做最简单的国家列表显示
再加入“选中后显示当前国家”
再加入删除按钮
最后加入输入框和 Add 按钮
这一部分是老师最容易检查的主线
Step 1.1:先做一个最基础的 LazyColumn先把下面这个 Composable 加到 MainActivity.kt 里,然后让 setContent 调用 CountryListDisplay1()。
LazyColumn 是 Compose 里显示长列表最常用的组件。它和普通 Column 的区别是:只会按需渲染当前可见内容,更适合真实列表场景。
remember { ... }:让状态在重新组合时保留下来
mutableStateListOf(...):创建一个“可被 Compose 观察”的列表
items(countries.size) { index -> ... }:按索引逐个显示列表项
Text(...):把当前国家名显示出来
页面上会看到 3 行文字: USA、 Canada、 Australia
运行 App 后,确认列表能正常显示,并且顺序正确。
Step 1.2:加入“选中某个国家并显示” 把 setContent 里的调用改成 CountryListSelection2(),然后加入下面这段代码。
给列表项加点击事件,点哪一项,上面的 Selected Country 就显示哪一项。
这一版开始进入 Compose 的核心: 用户操作会改变 state,state 一变,界面自动更新。
这里真正变化的不是 Text 本身,而是 selectedCountry 这个 state。
var selectedCountry by remember { mutableStateOf("") }:保存当前选中的国家
clickable { selectedCountry = countries[index] }:点击一行时,更新选中值
顶部 Text(...):把当前 state 直接显示出来
Step 1.3:加入删除功能 把 setContent 改成 CountryListDeletion3(),然后加入下面代码。
给每一行加一个删除按钮,点击后把对应国家从列表中移除。
这里演示的是: 不只是单个字符串可以作为 state,整个列表也可以作为 state。
当 countries.removeAt(index) 执行后, LazyColumn 会自动根据新的列表内容刷新。
Button(onClick = { countries.removeAt(index) }):删掉当前行
Icon(Icons.Default.Delete, ...):用垃圾桶图标表示删除
HorizontalDivider(...):每一行下方加一条分隔线,让视觉更清楚
点击某一行右侧删除按钮
该行应该立刻消失
剩下的列表会自动上移
Step 1.4:加入输入框和 Add 按钮 把 setContent 改成 CountryListAddition4(),然后加入下面代码。
在“显示列表 + 删除”的基础上,再增加一个输入框和一个 Add 按钮,让用户可以自己把国家加进去。
TextField 负责输入
Button 负责触发新增
LazyColumn 负责显示
删除按钮负责移除
输入 France
点击 Add
列表底部应该出现 France
输入框应被清空
再点击删除按钮,新增项也应能被删掉
Lab 原始代码用的是 isNotEmpty()。这里我改成了 isNotBlank() + trim(),这样用户只输入空格时不会被加进列表,实际使用更稳。
Task 2: Problem Solving —— 支持按钮添加,也支持键盘 Done/Enter这一部分是本周进阶重点
要让用户既可以点 Add,也可以直接按虚拟键盘的 Done/Enter
关键点是 KeyboardOptions、 KeyboardActions 和 singleLine = true
如果老师问为什么要这样写,这一题很容易被拿来检查理解
Step 2.1:实现 CountryListAddition5()把 setContent 改成 CountryListAddition5(),然后加入下面代码。
真实 App 里,输入完成后直接按键盘确认是很常见的交互。
这一题的重点不是单纯“再多写一行代码”,而是理解 TextField 可以接住输入法动作(IME Action) 。
singleLine = true:让输入框变成单行;这样按 Enter 时会触发动作,而不是换行
imeAction = ImeAction.Done:把软键盘动作键设置成 Done
KeyboardActions(onDone = { addCountry() }):用户按 Done 时执行新增逻辑
val addCountry = { ... }:把新增逻辑抽出来,按钮和键盘共用,避免写两遍
这一步真正要理解的点 TextField 不是只能“输入文字”的组件,它还可以和输入法交互。
TextField 的内容由 state 控制
键盘动作也可以绑定业务逻辑
同一份新增逻辑可以被多个交互入口复用
Step 2.2:可选扩展 —— 把字符串列表升级成对象列表 这里把每个国家从单纯的字符串,升级成一个对象,里面同时包含:
先准备 drawable 资源 app/src/main/res/drawable/
如果你的文件名不一样,记得同步改 R.drawable.xxx
Step 2.2.1:加入 Country data class 和升级版列表 一旦你学会把列表元素建模成 data class,后面做联系人列表、商品列表、任务列表都会更顺。
LazyColumn 可以显示复杂 item
item 里可以放图片、两行文字、按钮
列表的每个元素可以是对象,不一定只是字符串
Task 3: Additional Exercise —— 做一个 ExposedDropdownMenu
Step 3.1:实现下拉菜单 Composable 把 setContent 改成 TestMenu(),然后加入下面代码。
创建一个显示澳洲州名的下拉菜单,用户点开后可以选一个州。
表单里非常常见的输入方式就是“从固定选项里选一个值”。
states:下拉选项数据源
isExpanded:控制菜单当前是展开还是收起
selectedState:保存当前选中的州
ExposedDropdownMenuBox:下拉菜单容器
TextField(readOnly = true):作为显示当前选中值的输入框外观
DropdownMenuItem:定义每个选项被点到时该做什么
为什么要加 @OptIn(ExperimentalMaterial3Api::class)
因为 ExposedDropdownMenuBox 属于 Material3 的实验性 API,不加这个注解通常会直接报错。
官方讲义里提到 selectedState 就算暂时没显示出来,也应该保留,因为真实表单最终就是要拿这个值去提交。
Step 3.2:理解这道额外题真正要你会什么 这道题真正不是“把下拉菜单写出来”这么简单,而是要你知道:
Homework 可直接写的解释 selectedState 的作用是保存用户最后选择的州。
Summary 这周真正学到的,不是“把几个国家显示在手机上”,而是 Compose 里非常核心的一条主线: UI 是 state 的结果,state 一变,界面就跟着变。
从 CountryListDisplay1() 开始,你只是把列表显示出来;
到 CountryListSelection2(),你开始让点击去改变 state;
到 CountryListDeletion3() 和 CountryListAddition4(),你已经在操作整个列表;
到 CountryListAddition5(),你进一步学会把输入法动作也接进业务逻辑里;
最后 TestMenu() 则说明了:不只是列表,像下拉菜单这种表单组件,本质上也是 state 驱动。
本周必须理解的知识点 LazyColumn 适合显示可滚动、可能很长的列表;它不是单纯的 Column
remember 用来在重新组合时保留 state
mutableStateOf 适合单个值,例如输入框文字、选中项、展开状态
mutableStateListOf 适合可变列表,因为对列表的增删也要触发 UI 更新
Compose 的核心思路是:改 state,不是手动“刷新界面”
KeyboardOptions 和 KeyboardActions 可以把键盘动作接进你的逻辑
老师可能会问什么 1. 为什么这里要用 LazyColumn,不用普通 Column?
因为列表项可能很多, LazyColumn 会按需渲染可见内容,更适合列表场景。
2. 为什么 countries 要用 mutableStateListOf?
因为新增和删除都会改列表内容,Compose 需要观察到这个变化,才能自动更新 UI。
让 state 在重新组合时保留下来,否则每次重组都可能回到初始值。
4. 为什么 singleLine = true 很关键?
因为不加它时,Enter 更可能被当成换行;加了以后,才更符合“按 Done 触发提交”的输入行为。
5. 为什么 dropdown 还要额外保存 selectedState?
因为真正有业务意义的是“用户最后选了什么”,这个值后面要拿去显示、提交或保存。
6. 为什么说 Compose 是 state-driven UI?
因为界面不是手动一块一块刷新,而是 state 改了以后,Compose 自动把相关 UI 重组出来。
package your.package.name import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import com.example.lazycolumnlab.ui.theme.LazyColumnLabTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { LazyColumnLabTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { CountryListDisplay1() } } } } } import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExposedDropdownMenu import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp // @Composable 表示:这是一个“可以用来画界面”的函数 // 在 Jetpack Compose 里,界面就是靠这种函数一点点拼出来的 @Composable fun CountryListDisplay1() { // val countries: // 定义一个叫 countries 的变量,用来保存“国家列表” // // remember: // 表示把这个数据“记住”,避免界面刷新/重组时又重新创建 // // mutableStateListOf(...): // 创建一个“可变化的列表状态” // 这里先放了 3 个国家:USA、Canada、Australia // // 最简单理解: // 这是一个“Compose 能感知变化”的列表 // 以后如果往里面加项目、删项目,界面可以跟着更新 val countries = remember { mutableStateListOf( "USA" , "Canada" , "Australia" ) } // Column: // 纵向排列容器 // 里面的内容会从上往下依次摆放 // // modifier = Modifier.padding(16.dp): // 给整个 Column 四周加 16.dp 的内边距 // 这样内容不会紧贴屏幕边缘,看起来更舒服 Column(modifier = Modifier.padding(16.dp)) { // Spacer: // 空白占位组件 // 这里只是为了在顶部空出 36.dp 的距离 // 让内容不要太靠上 Spacer(modifier = Modifier.height(36.dp)) // LazyColumn: // 用来显示“竖向列表” // // 和普通 Column 的区别: // LazyColumn 更适合列表,因为它会按需要显示项目 // 可以理解为:更像真正的“列表控件” LazyColumn { // items(countries.size): // 表示“这个列表一共有 countries.size 个项目” // // { index -> ... }: // index 就是每一项的下标(索引) // 比如: // index = 0 时,对应 "USA" // index = 1 时,对应 "Canada" // index = 2 时,对应 "Australia" items(countries.size) { index -> // Text: // 用来显示一段文字 // // text = countries[index]: // 取出列表中第 index 个国家名字显示出来 // // modifier = Modifier.padding(8.dp): // 给每个文字项加一点内边距,让它们不要挤在一起 Text( text = countries[index], modifier = Modifier.padding(8.dp) ) } } } } @Composable fun CountryListSelection2() { // countries:保存国家列表 // remember:把这份数据“记住” // mutableStateListOf:创建一个可变化、可被 Compose 观察到的列表 val countries = remember { mutableStateListOf( "USA" , "Canada" , "Australia" ) } // selectedCountry:保存“当前被选中的国家” // var:因为这个值后面会改变,所以用 var // by remember { mutableStateOf("") }: // 表示这是一个会被 Compose 观察的状态变量,初始值为空字符串 // // 最简单理解: // 这个变量专门用来记录“你刚刚点了哪个国家” var selectedCountry by remember { mutableStateOf( "" ) } // Column:纵向排列容器 // padding(16.dp):给整个界面内容四周留 16.dp 的空白 Column(modifier = Modifier.padding(16.dp)) { // Spacer:放一个空白区域 // height(36.dp):在顶部空出 36.dp 的距离 Spacer(modifier = Modifier.height(36.dp)) // Text:显示一段文字 // // "Selected Country:$selectedCountry": // 会把当前选中的国家显示出来 // 比如如果点了 Canada,这里就会显示: // Selected Country:Canada // // style = MaterialTheme.typography.bodyLarge: // 设置文字样式,让它看起来更像标题/重点文本 Text( text = "Selected Country:$selectedCountry" , style = MaterialTheme.typography.bodyLarge ) // 再加一段空白,把上面的说明文字和下面的列表隔开 Spacer(modifier = Modifier.height(16.dp)) // LazyColumn:竖向列表组件 // 适合显示“很多项”的列表 LazyColumn { // items(countries.size): // 表示根据 countries 列表的长度,生成对应数量的列表项 // // index: // 当前这一项的下标 // 0 -> USA // 1 -> Canada // 2 -> Australia items(countries.size) { index -> // Row:横向排列容器 // 这里每一行列表项就是一个 Row Row( modifier = Modifier // fillMaxWidth():让这一整行尽量占满横向宽度 .fillMaxWidth() // padding(8.dp):给这一行加一点内边距 .padding(8.dp) // clickable { ... }: // 让这一整行可以被点击 // // selectedCountry = countries[index] // 表示:点击这一行时,把当前这一行对应的国家名 // 保存到 selectedCountry 里 // // 例如点击 Canada 那一行: // selectedCountry 就会变成 "Canada" .clickable { selectedCountry = countries[index] } ) { // Text:显示当前这一行的国家名 // // countries[index]: // 取出当前下标对应的国家 // // modifier = Modifier.weight(1f): // 让这个 Text 在 Row 里占据可用空间 // 你现在可以简单理解成: // “让这一行的文字区域正常铺开” Text( text = countries[index], modifier = Modifier.weight(1f) ) } } } } } @Composable fun CountryListDeletion3() { // countries:保存国家列表 // remember:把这份列表数据“记住” // mutableStateListOf:创建一个可变的、可被 Compose 观察到的列表 // // 最简单理解: // 这是屏幕上那份“国家名单” // 后面删除某一项时,改的就是它 val countries = remember { mutableStateListOf( "USA" , "Canada" , "Australia" ) } // Column:纵向排列容器 // 里面的内容会从上往下放 // // padding(16.dp):给整个界面四周加一点留白 Column(modifier = Modifier.padding(16.dp)) { // Spacer:空白占位 // 这里在顶部空出 36.dp 的距离 Spacer(modifier = Modifier.height(36.dp)) // Text:显示标题文字 // 这里显示的是“List of Countries” // // style = MaterialTheme.typography.bodyLarge: // 给标题设置一个稍微醒目一点的文字样式 Text( text = "List of Countries" , style = MaterialTheme.typography.bodyLarge ) // 再空出一点距离,让标题和下面的列表不要贴太近 Spacer(modifier = Modifier.height(16.dp)) // LazyColumn:竖向列表组件 // 适合用来显示一组一组重复的数据 LazyColumn { // items(countries.size): // 按照 countries 列表的长度,生成对应数量的列表项 // // index: // 当前这一项的下标 // 例如: // index = 0 -> USA // index = 1 -> Canada // index = 2 -> Australia items(countries.size) { index -> // Row:横向排列容器 // 每一行显示: // 左边 国家名字 // 右边 删除按钮 Row( modifier = Modifier .fillMaxWidth() // 让这一行尽量占满整行宽度 .padding(8.dp) // 给这一行加一点内边距 ) { // Text:显示当前这一行的国家名 // // countries[index]: // 取出当前下标对应的国家 // // weight(1f): // 让文字部分尽量占主要空间 // 这样右边的按钮就会自然贴到右边去 Text( text = countries[index], modifier = Modifier.weight(1f) ) // Button:按钮 // 点击这个按钮时,会执行 onClick 里面的代码 Button( // countries.removeAt(index): // 删除 countries 列表中“当前这一行”对应的项目 // // 比如当前 index = 1 // 那就删除第 2 个元素,也就是 Canada onClick = { countries.removeAt(index) } ) { // Icon:按钮里面显示一个删除图标 // // Icons.Default.Delete: // Material 图标库里的“删除”图标 // // contentDescription: // 给无障碍功能使用的描述文字 Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete" ) } } // HorizontalDivider:横向分割线 // 每一行下面画一条线,让列表项之间更清楚 HorizontalDivider( modifier = Modifier.fillMaxWidth(), thickness = 1.dp ) } } } } @Composable fun CountryListAddition4() { // countries:保存当前列表里的所有国家 // remember:把这份列表状态记住 // mutableStateListOf:创建一个“可变的、Compose 能观察到变化”的列表 // // 最简单理解: // 这就是屏幕上真正显示出来的国家名单 val countries = remember { mutableStateListOf( "USA" , "Canada" , "Australia" ) } // country:保存输入框里“当前正在输入的内容” // var:因为输入内容会不断变化,所以要用 var // by remember { mutableStateOf("") }: // 把这个输入内容做成状态,初始值是空字符串 // // 最简单理解: // 这个变量不是列表,它只是输入框里那一小段文字 var country by remember { mutableStateOf( "" ) } // Column:纵向排列容器 // 里面的内容会从上往下摆放 Column(modifier = Modifier.padding(16.dp)) { // Spacer:顶部留出一点空白,不让内容贴太上面 Spacer(modifier = Modifier.height(36.dp)) // Text:页面标题 Text( text = "List of Countries" , style = MaterialTheme.typography.bodyLarge ) // 标题和输入框之间留一点距离 Spacer(modifier = Modifier.height(16.dp)) // OutlinedTextField:带边框的输入框 // // value = country: // 输入框里当前显示的内容,来自变量 country // // onValueChange = { country = it }: // 每当用户输入新内容,就把新内容保存回 country // // label = { Text("Country") }: // 输入框标签,显示 Country // // fillMaxWidth(): // 让输入框尽量占满整行宽度 OutlinedTextField( value = country, onValueChange = { country = it }, label = { Text( "Country" ) }, modifier = Modifier.fillMaxWidth() ) // 输入框和按钮之间加一点间距 Spacer(modifier = Modifier.height(8.dp)) // Button:新增按钮 // // fillMaxWidth(): // 让按钮占满整行宽度 Button( modifier = Modifier.fillMaxWidth(), // onClick:点击按钮时执行的逻辑 onClick = { // isNotBlank(): // 判断输入内容是不是不为空、也不全是空格 // // 比如: // "" -> false // " " -> false // "France" -> true if (country.isNotBlank()) { // countries.add(country.trim()): // 把输入框里的国家名加到列表里 // // trim(): // 去掉前后多余空格 // 比如 " France " 会变成 "France" countries.add(country.trim()) // country = "": // 新增成功后,把输入框清空 country = "" } } ) { // 按钮里显示文字 Add Text(text = "Add" ) } // 按钮和下面列表之间留一点距离 Spacer(modifier = Modifier.height(16.dp)) // LazyColumn:竖向列表 // 用来显示当前所有国家 LazyColumn { // 根据 countries 的长度生成对应数量的列表项 items(countries.size) { index -> // Row:横向排列容器 // 每一行里有两部分: // 左边 国家名 // 右边 删除按钮 Row( modifier = Modifier .fillMaxWidth() .padding(8.dp) ) { // Text:显示当前这一行的国家名 // // countries[index]: // 取出第 index 个国家 // // weight(1f): // 让文字区域占主要空间,按钮留在右边 Text( text = countries[index], modifier = Modifier.weight(1f) ) // Button:删除按钮 // 点击后删除当前这一项 Button(onClick = { countries.removeAt(index) }) { // Icon:按钮里的删除图标 Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete" ) } } // HorizontalDivider:每一项下面画一条分隔线 HorizontalDivider( modifier = Modifier.fillMaxWidth(), thickness = 1.dp ) } } } } @Composable fun CountryListAddition5() { // countries:保存当前列表中的所有国家 // remember:把这份状态“记住” // mutableStateListOf:创建一个可变的、Compose 能观察到变化的列表 // // 最简单理解: // 这就是页面真正显示出来的国家清单 val countries = remember { mutableStateListOf( "USA" , "Canada" , "Australia" ) } // country:保存输入框里当前输入的文字 // var:因为输入内容会变,所以用 var // by remember { mutableStateOf("") }: // 把这个输入内容做成可观察的状态,初始值为空字符串 // // 最简单理解: // 这是“输入框当前内容”,不是整个列表 var country by remember { mutableStateOf( "" ) } // addCountry:把“新增国家”的逻辑单独提出来,做成一个小函数/小动作 // // 为什么这样写? // 因为现在有两个地方都要执行“新增国家”: // 1. 点击 Add 按钮 // 2. 键盘按 Done // // 所以把重复逻辑提出来,后面两个地方都可以直接调用 addCountry() val addCountry = { // isNotBlank():判断输入内容是不是不是空的、也不是只有空格 // 例如: // "" -> false // " " -> false // "Japan" -> true if (country.isNotBlank()) { // countries.add(country.trim()): // 把输入框里的内容加入列表 // // trim():去掉前后空格 // 比如 " Japan " 会变成 "Japan" countries.add(country.trim()) // country = "": // 新增后清空输入框,方便继续输入下一个国家 country = "" } } // Column:纵向排列容器 // 整个页面内容会从上往下摆放 Column(modifier = Modifier.padding(16.dp)) { // 顶部留一点空白,不让内容贴太上面 Spacer(modifier = Modifier.height(36.dp)) // 标题文字 Text( text = "List of Countries" , style = MaterialTheme.typography.bodyLarge ) // 标题和输入框之间的间距 Spacer(modifier = Modifier.height(16.dp)) // OutlinedTextField:带边框的输入框 OutlinedTextField( // 输入框里当前显示的内容 value = country, // 每次用户输入新内容时,更新 country onValueChange = { country = it }, // 输入框标签 label = { Text( "Country" ) }, // singleLine = true: // 表示这是单行输入框 // // 作用: // 按 Enter / Done 时,更像“提交动作” // 而不是在输入框里换行 singleLine = true, // 输入框占满整行宽度 modifier = Modifier.fillMaxWidth(), // keyboardOptions:配置键盘行为 keyboardOptions = KeyboardOptions.Default.copy( // imeAction = ImeAction.Done: // 告诉系统:这个输入框的键盘动作希望显示为 Done // 在真机或模拟器软键盘上,右下角通常会变成 Done / Enter / √ imeAction = ImeAction.Done ), // keyboardActions:定义按下键盘动作后的行为 keyboardActions = KeyboardActions( // onDone: // 当用户按下软键盘上的 Done 时,执行 addCountry() onDone = { addCountry() } ) ) // 输入框和按钮之间加一点距离 Spacer(modifier = Modifier.height(8.dp)) // Add 按钮 Button( modifier = Modifier.fillMaxWidth(), // 点击按钮时,也执行 addCountry() onClick = { addCountry() } ) { Text(text = "Add" ) } // 按钮和列表之间留一点距离 Spacer(modifier = Modifier.height(16.dp)) // LazyColumn:竖向列表 // 用来显示当前所有国家 LazyColumn { // 根据 countries 列表长度生成对应数量的列表项 items(countries.size) { index -> // Row:每一行横向排列 // 左边是国家名,右边是删除按钮 Row( modifier = Modifier .fillMaxWidth() .padding(8.dp) ) { // 显示当前这一行的国家名 Text( text = countries[index], modifier = Modifier.weight(1f) ) // 删除按钮:点击后删除当前这一项 Button(onClick = { countries.removeAt(index) }) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete" ) } } // 每一项下面画一条分隔线 HorizontalDivider( modifier = Modifier.fillMaxWidth(), thickness = 1.dp ) } } } } // data class:数据类 // 用来专门保存一组相关的数据 // // 这里的 Country 表示“一个国家对象” // 它不再只是一个简单字符串,而是包含 3 个信息: // 1. 国家名 name // 2. 首都 capital // 3. 国旗图片资源 id flag data class Country( val name: String, val capital: String, val flag: Int ) @Composable fun CountryListApp() { // countries:保存国家列表 // remember:把这份数据记住,避免界面重组时重新创建 // mutableStateListOf:创建一个可变的、Compose 能观察变化的列表 // // 和前面几版不同的是: // 这里列表里放的不是字符串,而是 Country 对象 // // 例如第一项: // Country("USA", "Washington D.C.", R.drawable.usa_flag) // 表示: // - name = USA // - capital = Washington D.C. // - flag = usa_flag 这张图片资源 val countries = remember { mutableStateListOf( Country( "USA" , "Washington D.C." , R.drawable.usa_flag), Country( "Canada" , "Ottawa" , R.drawable.ca_flag), Country( "Australia" , "Canberra" , R.drawable.au_flag) ) } // Column:纵向排列容器 // 整个页面内容从上往下摆 // // padding(16.dp):给整个页面留出边距 Column(modifier = Modifier.padding(16.dp)) { // Spacer:顶部留空白,不让内容贴太上面 Spacer(modifier = Modifier.height(36.dp)) // 标题文字 Text( text = "List of Countries" , style = MaterialTheme.typography.bodyLarge ) // 标题和列表之间留一点距离 Spacer(modifier = Modifier.height(16.dp)) // LazyColumn:竖向列表组件 // 用来把 countries 列表一项一项显示出来 LazyColumn { // items(countries.size): // 根据 countries 列表长度生成对应数量的列表项 items(countries.size) { index -> // Row:横向排列容器 // 每一行包含: // 1. 左边国旗图片 // 2. 中间国家名 + 首都 // 3. 右边删除按钮 Row( modifier = Modifier .fillMaxWidth() .padding(8.dp) ) { // Image:显示图片 // // painterResource(id = countries[index].flag): // 根据当前国家对象中的 flag 资源 id,加载对应图片 // // contentDescription: // 给无障碍功能使用的文字描述 // // size(40.dp):把图片大小设置为 40.dp // padding(end = 8.dp):让图片和右边文字之间留点空隙 Image( painter = painterResource(id = countries[index].flag), contentDescription = "Flag of ${ countries[index].name } " , modifier = Modifier .size(40.dp) .padding(end = 8.dp) ) // 中间这一块再用一个 Column // 作用:把“国家名”和“首都”竖着放 // // weight(1f): // 让中间文字区域占主要空间 // 这样右边删除按钮就会比较自然地靠右 Column(modifier = Modifier.weight(1f)) { // 显示国家名 Text( text = countries[index].name, style = MaterialTheme.typography.bodyMedium ) // 显示首都 Text( text = "Capital: ${ countries[index].capital } " , style = MaterialTheme.typography.bodySmall ) } // IconButton:只显示图标的按钮 // 比普通 Button 更适合这种“删除/编辑/收藏”类小操作 // // onClick = { countries.removeAt(index) }: // 点击后删除当前这一项 IconButton(onClick = { countries.removeAt(index) }) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete" ) } } // if (index < countries.size - 1): // 只有在“不是最后一项”的时候,才画分割线 // // 这样做的目的是: // 最后一项下面就不再多画一条线,看起来更自然 if (index < countries.size - 1) { HorizontalDivider( modifier = Modifier.fillMaxWidth(), thickness = 1.dp ) } } } } } // @OptIn(ExperimentalMaterial3Api::class) // 表示:这个函数里用到了 Material3 里“实验中的 API” // 也就是说,这些功能可以用,但官方还认为它们不是完全稳定版 @OptIn(ExperimentalMaterial3Api::class) @Composable fun TestMenu() { // states:保存下拉菜单里所有可选的州 // listOf(...):创建一个普通不可变列表 // // 这里一共有 8 个澳大利亚州/领地缩写: // VIC, QLD, NSW, SA, TAS, WA, ACT, NT // // 最简单理解: // 这就是“菜单选项列表” val states = listOf( "VIC" , "QLD" , "NSW" , "SA" , "TAS" , "WA" , "ACT" , "NT" ) // isExpanded:记录下拉菜单当前是不是展开状态 // false = 收起 // true = 展开 // // by remember { mutableStateOf(false) }: // 把它做成一个可观察状态,初始值为 false // // 最简单理解: // 它专门负责控制“菜单开没开” var isExpanded by remember { mutableStateOf(false) } // selectedState:记录当前选中的州 // 初始值 states[0] 就是 "VIC" // // 最简单理解: // 它专门负责保存“现在选中的是谁” var selectedState by remember { mutableStateOf(states[0]) } // Column:纵向排列容器 // 页面内容从上往下排 Column( modifier = Modifier .fillMaxSize() // 让整个 Column 尽量铺满屏幕 .padding(16.dp) // 给页面四周加边距 ) { // 顶部留一点空白 Spacer(modifier = Modifier.height(36.dp)) // ExposedDropdownMenuBox: // 这是一个“带输入框外观的下拉菜单容器” // // expanded = isExpanded: // 菜单是否展开,由 isExpanded 控制 // // onExpandedChange = { isExpanded = it }: // 当用户点击它想展开/收起时,更新 isExpanded ExposedDropdownMenuBox( expanded = isExpanded, onExpandedChange = { isExpanded = it }, modifier = Modifier.padding(10.dp) ) { // TextField:这里看起来像输入框 // 但其实它更像“下拉选择框的显示区域” TextField( // 当前输入框里显示的值,就是 selectedState value = selectedState, // onValueChange = {}: // 这里不给用户手动输入,所以什么都不做 onValueChange = {}, // readOnly = true: // 表示这个输入框只读,不能手动打字 // 用户只能通过下拉菜单来选值 readOnly = true, // 输入框标签 label = { Text( "State" ) }, // trailingIcon:右边的小箭头图标 // 会根据 isExpanded 自动显示“展开/收起”的状态 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) }, // modifier:控制这个 TextField 的布局行为 modifier = Modifier // menuAnchor(): // 把这个 TextField 作为下拉菜单的锚点 // 最简单理解:菜单会“挂”在这个控件下面 .menuAnchor() // 输入框占满整行宽度 .fillMaxWidth() // focusProperties { canFocus = false }: // 不让它获得普通输入焦点 // 因为它不是给你打字的,而是给你点开菜单选项的 .focusProperties { canFocus = false } // 下方留一点距离 .padding(bottom = 8.dp) ) // ExposedDropdownMenu:真正展开出来的菜单内容 ExposedDropdownMenu( // 菜单开关状态 expanded = isExpanded, // 点击菜单外面时,收起菜单 onDismissRequest = { isExpanded = false } ) { // states.forEach { selectionOption -> ... }: // 把 states 列表里的每一个州,都生成一个菜单项 states.forEach { selectionOption -> // DropdownMenuItem:菜单中的单个选项 DropdownMenuItem( // 这一项显示什么文字 text = { Text(selectionOption) }, // 点击某个选项时: onClick = { // 把当前选中的州改成这个选项 selectedState = selectionOption // 选完之后把菜单收起来 isExpanded = false }, // 菜单项的默认内边距 contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding ) } } } // 下面再显示一行文字,明确告诉用户当前选中了哪个州 Text( text = "Selected state: $selectedState" , style = MaterialTheme.typography.bodyLarge ) } }