21、对话框与底部弹窗:AlertDialog、Dialog、ModalBottomSheet
对话框和底部弹窗,说白了就是App里跟用户“临时对话”的窗口。你想想看,用户点了个删除按钮,你总得确认一下“你确定要删吗?”——这时候弹个对话框出来,最自然不过了。
在Compose里,官方提供了三个主力组件:AlertDialog、Dialog、ModalBottomSheet。我这些年做项目,几乎每个App都会用到它们。今天咱们就把它们彻底聊透。
21.1 AlertDialog:最常用的确认框
AlertDialog是官方封装好的“确认对话框”。它自带标题、内容、按钮区域,你只需要填数据就行。我个人习惯用它来处理“确认/取消”这类简单场景。
@Composable
fun DeleteConfirmDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("确认删除") },
text = { Text("删除后数据无法恢复,确定要删除吗?") },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("删除", color = Color.Red)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}
嗯,这里要注意:onDismissRequest是用户点击对话框外部或按返回键时触发的。我曾经见过有人把onDismissRequest和dismissButton的点击事件搞混,结果用户点取消时没反应——排查了半天才发现是回调写错了。
dismissButton参数传null,或者干脆用Dialog自己拼布局。别硬塞一个“取消”按钮进去,用户体验反而不好。
21.2 Dialog:完全自定义的弹窗
当AlertDialog满足不了你时——比如你要弹一个带输入框、带图片、甚至带列表的复杂弹窗——就该Dialog上场了。
Dialog只提供一个空白窗口,里面的内容完全由你掌控。说白了,它就是一个“带遮罩层的自由画布”。
@Composable
fun InputDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var text by remember { mutableStateOf("") }
Dialog(onDismissRequest = onDismiss) {
// 这里你可以放任何Compose组件
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface
) {
Column(modifier = Modifier.padding(24.dp)) {
Text("请输入昵称", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("昵称") }
)
Spacer(modifier = Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) { Text("取消") }
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { onConfirm(text) }) { Text("确定") }
}
}
}
}
}
我在项目中遇到过一个问题:用Dialog时,如果不包一层Surface,对话框的背景是透明的,内容直接浮在遮罩层上,看起来非常奇怪。所以记住——Dialog里面一定要加Surface或Card来垫底。
Dialog里放了一个LazyColumn,结果列表滚动时,对话框也跟着抖。后来发现是Dialog默认的窗口尺寸没限制,导致内部可滚动组件和外部窗口滚动冲突。解决方案是给Dialog的内容设置固定高度,比如Modifier.heightIn(max = 400.dp)。
21.3 ModalBottomSheet:底部弹窗
底部弹窗在移动端非常流行。它从屏幕底部滑出,通常用于分享、菜单选择、或者展示额外信息。Compose Material3 提供了ModalBottomSheet,用起来很顺手。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareBottomSheet(
sheetState: SheetState,
onDismiss: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(modifier = Modifier.padding(24.dp)) {
Text("分享到", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth()) {
ShareItem("微信", Icons.Default.Share)
ShareItem("朋友圈", Icons.Default.Share)
ShareItem("QQ", Icons.Default.Share)
ShareItem("微博", Icons.Default.Share)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
这里有个关键点:SheetState需要由外部传入,并且用rememberModalBottomSheetState()创建。为什么?因为你需要控制它的展开/收起状态。比如用户拖拽到一半松手,是弹回去还是继续展开?这些逻辑都封装在SheetState里了。
rememberModalBottomSheetState(skipPartiallyExpanded = false)。我个人习惯保留半展开状态,因为全屏底部弹窗看起来跟新页面似的,失去了“弹窗”的轻量感。
21.4 三者的选择策略
你可能会问:什么时候用AlertDialog,什么时候用Dialog,什么时候用ModalBottomSheet?我总结了一个简单的判断逻辑:
| 场景 | 推荐组件 | 原因 |
|---|---|---|
| 简单确认/警告 | AlertDialog | 开箱即用,代码最少 |
| 复杂表单/自定义布局 | Dialog | 完全自由,不受限制 |
| 选项列表/分享菜单 | ModalBottomSheet | 符合移动端操作习惯 |
| 需要用户输入文本 | Dialog | AlertDialog的输入框布局太受限 |
说白了,AlertDialog是“快餐”,Dialog是“自助餐”,ModalBottomSheet是“特色菜”。选哪个,取决于你今天的“胃口”。
21.5 状态管理与生命周期
对话框的显示/隐藏,本质上是一个布尔状态。我习惯用remember + mutableStateOf来控制:
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
DeleteConfirmDialog(
onConfirm = {
// 执行删除
showDialog = false
},
onDismiss = { showDialog = false }
)
}
嗯,这里有个坑:如果你在Dialog内部修改了某个状态,而这个状态又被Dialog外部的组件观察,可能会导致重组异常。我曾经遇到过——对话框里改了一个列表项的数据,结果对话框关闭时列表闪了一下。解决方案是把对话框内部的状态独立出来,用remember局部管理,不要直接操作外部的大状态。
remember;如果对话框的状态需要持久化(比如用户勾选了“不再提示”),那就提升到ViewModel里。
21.6 动画与自定义
Compose的对话框默认自带淡入淡出动画。但如果你想要更炫酷的效果——比如从某个按钮位置展开——那就得自己动手了。
我一般用AnimatedVisibility配合Modifier.graphicsLayer来实现自定义动画。不过说实话,大多数场景下默认动画就够了。过度设计动画反而会让用户觉得“卡顿”。
// 如果你真的需要自定义动画,可以这样:
AnimatedVisibility(
visible = showDialog,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
// 你的对话框内容
}
但注意:用AnimatedVisibility包裹对话框时,遮罩层需要你自己实现。我建议直接用Dialog组件,然后修改它的DialogProperties里的动画参数——这样更省事。
21.7 避坑总结
最后,我把这些年踩过的坑汇总一下:
- 对话框重叠:不要同时弹出两个对话框。我曾经在
onDismissRequest里又触发了一个新对话框,结果两个叠在一起,用户关都关不掉。解决方案是用一个状态变量控制当前显示的对话框类型。 - 内存泄漏:对话框里如果持有大量图片或列表数据,记得在关闭时清理。Compose虽然会自动回收,但如果你在对话框里开了协程或监听器,一定要在
DisposableEffect里取消。 - 屏幕旋转:对话框的状态默认会随着Activity重建而丢失。如果你需要保持对话框在旋转后仍然显示,把状态提升到ViewModel里,或者用
rememberSaveable。 - 无障碍:对话框弹出时,焦点应该自动移到对话框内的第一个可交互组件上。Compose默认会处理,但如果你自定义了布局,记得用
FocusRequester手动请求焦点。
对话框虽小,但用好了能极大提升用户体验。你想想看,用户最怕的就是“误操作”——一个清晰的确认对话框,比任何撤销功能都管用。嗯,今天就聊到这儿,希望这些经验能帮你少走弯路。