21、对话框与底部弹窗:AlertDialog、Dialog、ModalBottomSheet

对话框和底部弹窗,说白了就是App里跟用户“临时对话”的窗口。你想想看,用户点了个删除按钮,你总得确认一下“你确定要删吗?”——这时候弹个对话框出来,最自然不过了。

在Compose里,官方提供了三个主力组件:AlertDialogDialogModalBottomSheet。我这些年做项目,几乎每个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是用户点击对话框外部或按返回键时触发的。我曾经见过有人把onDismissRequestdismissButton的点击事件搞混,结果用户点取消时没反应——排查了半天才发现是回调写错了。

我的习惯:如果对话框只有“确定”按钮(没有取消),我会把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里面一定要加SurfaceCard来垫底。

避坑指南:我曾经在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手动请求焦点。

对话框虽小,但用好了能极大提升用户体验。你想想看,用户最怕的就是“误操作”——一个清晰的确认对话框,比任何撤销功能都管用。嗯,今天就聊到这儿,希望这些经验能帮你少走弯路。

公众号:蓝海资料掘金营,微信 deep3321