16、对话框与弹窗:AlertDialog、Dialog、BottomSheet、Popup、DropdownMenu

对话框和弹窗,说白了就是App里那些「突然冒出来」的UI元素。它们用来打断用户当前操作,让用户做选择、填信息,或者单纯展示一条消息。

我在项目中见过不少新手把对话框用成「灾难现场」——弹窗叠弹窗,用户点都点不完。嗯,今天我们就来把这些组件捋清楚,看看它们各自该在什么场景下登场。

核心原则:对话框是「打断」,不是「骚扰」。能用轻量级弹窗(如Toast、Snackbar)解决的问题,别上AlertDialog。

16.1 AlertDialog:最经典的确认框

AlertDialog是Android里最老牌的对话框。在Jetpack Compose里,它的用法比传统View时代清爽太多了。

@Composable
fun MyAlertDialog(
    onConfirm: () -> Unit,
    onDismiss: () -> Unit
) {
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("确认删除?") },
        text = { Text("删除后数据无法恢复,确定要继续吗?") },
        confirmButton = {
            TextButton(onClick = onConfirm) {
                Text("确认")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("取消")
            }
        }
    )
}

这里有个细节:onDismissRequest 是用户点击对话框外部或按返回键时触发的。我习惯把「取消」逻辑也放这里,但注意别和 dismissButton 重复触发。

我的习惯:如果对话框只有「确认」按钮(比如展示一条重要通知),我会把 dismissButton 设为 null,但保留 onDismissRequest 让用户能关闭。

16.2 Dialog:更自由的容器

AlertDialog的布局是固定的——标题、内容、按钮。如果你想要自定义布局,比如放一个表单、一张图片,那就用 Dialog

@Composable
fun CustomDialog(show: Boolean, onDismiss: () -> Unit) {
    if (show) {
        Dialog(onDismissRequest = onDismiss) {
            Surface(
                shape = RoundedCornerShape(16.dp),
                color = MaterialTheme.colorScheme.surface
            ) {
                Column(modifier = Modifier.padding(24.dp)) {
                    Text("请输入昵称", style = MaterialTheme.typography.headlineSmall)
                    Spacer(modifier = Modifier.height(16.dp))
                    OutlinedTextField(value = "", onValueChange = {})
                    Spacer(modifier = Modifier.height(16.dp))
                    Button(onClick = onDismiss) {
                        Text("保存")
                    }
                }
            }
        }
    }
}

注意:Dialog本身没有背景,你需要用 SurfaceCard 包裹内容。我曾经踩过坑——忘了加Surface,结果对话框背景是透明的,文字直接浮在底层UI上,丑得没法看。

16.3 BottomSheet:从底部滑出的面板

BottomSheet适合展示「辅助性」内容,比如分享菜单、筛选选项。它不会完全遮挡主界面,用户能感知到上下文。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyBottomSheet() {
    val sheetState = rememberModalBottomSheetState()
    var showSheet by remember { mutableStateOf(false) }

    Column {
        Button(onClick = { showSheet = true }) {
            Text("打开底部面板")
        }
    }

    if (showSheet) {
        ModalBottomSheet(
            onDismissRequest = { showSheet = false },
            sheetState = sheetState
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Text("分享到...", style = MaterialTheme.typography.titleMedium)
                Spacer(modifier = Modifier.height(8.dp))
                Text("微信")
                Text("朋友圈")
                Text("QQ")
            }
        }
    }
}

这里有个坑:ModalBottomSheet 默认会有一个拖拽手柄,但如果你内容太少,用户拖不动。我建议内容高度至少占屏幕的30%,否则用Popup更合适。

16.4 Popup:轻量级浮层

Popup是最轻量的弹窗——它没有遮罩层,不会阻断用户操作。适合做工具提示、小菜单。

@Composable
fun MyPopup() {
    var showPopup by remember { mutableStateOf(false) }

    Box {
        Button(onClick = { showPopup = true }) {
            Text("更多操作")
        }

        DropdownMenu(
            expanded = showPopup,
            onDismissRequest = { showPopup = false }
        ) {
            DropdownMenuItem(
                text = { Text("编辑") },
                onClick = { showPopup = false }
            )
            DropdownMenuItem(
                text = { Text("删除") },
                onClick = { showPopup = false }
            )
        }
    }
}

注意:Popup的位置是相对于它的父容器。如果你把Button放在一个窄的Column里,Popup可能会被裁剪。我一般用 Box 包裹,并给Box设置 clipToPadding = false

16.5 DropdownMenu:下拉菜单

DropdownMenu本质上是Popup的一种特殊形式,专门用来做菜单列表。它和 ExposedDropdownMenuBox 配合,可以实现类似「选择器」的效果。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyDropdownMenu() {
    val options = listOf("选项A", "选项B", "选项C")
    var expanded by remember { mutableStateOf(false) }
    var selectedText by remember { mutableStateOf(options[0]) }

    ExposedDropdownMenuBox(
        expanded = expanded,
        onExpandedChange = { expanded = !expanded }
    ) {
        OutlinedTextField(
            value = selectedText,
            onValueChange = {},
            readOnly = true,
            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
            modifier = Modifier.menuAnchor()
        )

        ExposedDropdownMenu(
            expanded = expanded,
            onDismissRequest = { expanded = false }
        ) {
            options.forEach { option ->
                DropdownMenuItem(
                    text = { Text(option) },
                    onClick = {
                        selectedText = option
                        expanded = false
                    }
                )
            }
        }
    }
}

这里有个关键点:Modifier.menuAnchor() 必须加在TextField上,否则菜单不知道锚定在哪里。我见过有人漏掉这个,结果菜单飘到屏幕左上角去了。

16.6 如何选择?一张图说清楚

对话框与弹窗选择指南 需要弹窗? 需要阻断用户操作? 不需要阻断操作? AlertDialog Dialog(自定义) BottomSheet Popup/DropdownMenu 提示:BottomSheet适合展示较多选项(5个以上) Popup适合轻量级浮层,DropdownMenu适合列表选择 Dialog适合需要自定义布局的场景

16.7 避坑指南

我曾经踩过的坑:

  • 弹窗叠弹窗:在AlertDialog的确认按钮里又弹一个AlertDialog,用户得关两次。解决方案:用状态机管理弹窗层级,同一时间只显示一个。
  • BottomSheet内容被键盘遮挡:如果BottomSheet里有输入框,键盘弹起时内容可能被挡住。记得给BottomSheet加 imePadding()
  • DropdownMenu位置偏移:忘了加 menuAnchor(),菜单跑到屏幕外面。检查锚点绑定。
  • Dialog背景透明:没包Surface,文字直接浮在底层UI上。记住Dialog本身没有背景。

16.8 总结

对话框和弹窗是UI交互中「打断」与「辅助」的平衡艺术。我个人习惯遵循一个原则:

  • 需要用户做决定 → AlertDialog
  • 需要自定义布局 → Dialog
  • 需要展示较多选项 → BottomSheet
  • 需要轻量浮层 → Popup
  • 需要列表选择 → DropdownMenu

你想想看,如果每个弹窗都上AlertDialog,用户得多烦?选对组件,体验能提升一大截。


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