用途
这个技能用于在 Kotlin / Jetpack Compose 项目中,为 Material3 的 ModalBottomSheet 建立一套统一的“自绘”底部弹层样式。它的核心思路是:保留 ModalBottomSheet 在屏幕之外提供的行为——拖拽关闭、遮罩(scrim)、返回键处理、保持在键盘上方——但整体替换它绘制出来的外观。当代码库里出现十几个弹层时,替换方案必须是一种统一形状,而不是十二种各写各的。
主要能力
- 透明容器:把
containerColor与contentColor设为Color.Transparent,让弹层本身不绘制任何东西。 - 自绘表面:由调用方在内容里放一个
Card,自行决定形状、颜色与高度(elevation)。 - 去掉拖拽手柄:
dragHandle = null,改由内容里自己画一个装饰性手柄(例如宽 60.dp、高 4.dp 的圆角 Card)。 - 清零窗口内边距:
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },把内边距的处理责任交给调用方。 - 显式补回底部内边距:通过
EndOfModalBottomSheet()组件,在内容末尾补上导航栏底部内边距再加 8.dp。 - 全高变体:同样的调用,改用
Modifier.fillMaxHeight()与RectangleShape,并从WindowInsets.systemBars.getTop(density)取显式顶部内边距,让内容避开状态栏。 - 统一关闭路径:提供
hideThen辅助函数,先sheetState.hide()动画收起,再onDismiss()让调用方把弹层移出组合,最后执行action()(导航或打开下一个弹层)。
使用方式
基本调用形态如下(保留原始代码内容):
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss, // scrim tap and swipe land here
sheetState = sheetState,
containerColor = Color.Transparent, // the sheet paints nothing
contentColor = Color.Transparent,
dragHandle = null, // ... and no handle either
scrimColor = Color.Black.copy(alpha = .5f),
contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, // insets are now your problem
) {
Card( // your surface: shape, colour, elevation
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp),
colors = CardDefaults.cardColors().copy(containerColor = colors.container),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(5.dp))
Card( // your handle
modifier = Modifier.width(60.dp).height(4.dp),
colors = CardDefaults.cardColors().copy(containerColor = colors.handle),
shape = RoundedCornerShape(50),
) {}
// ... rows ...
EndOfModalBottomSheet() // the inset you zeroed, paid back
}
}
}
底部内边距组件:
@Composable
fun EndOfModalBottomSheet() {
Box(
Modifier
.fillMaxWidth()
.height(WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 8.dp),
)
}
关闭辅助函数:
val hideThen: (() -> Unit) -> Unit = { action ->
coroutineScope.launch {
sheetState.hide() // animate closed ...
onDismiss() // ... then let the caller drop it from composition
action() // ... and only then navigate, or open the next sheet
}
}
适用场景
- 项目中存在多个底部弹层,需要统一视觉与交互形状。
- 希望保留系统弹层的拖拽关闭、遮罩、返回键与键盘避让行为,但完全自定义外观。
- 需要在弹层内打开另一个弹层,并避免双重遮罩叠加。
- 需要处理手势导航设备上的底部导航栏内边距,以及全高弹层的状态栏内边距。
限制与注意事项
- 关闭与隐藏不是同一个事件:内容里的按钮走
hideThen,遮罩点击与下滑走onDismissRequest,两者必须翻转同一个标志位,否则弹层会弹回或无法再次打开。 hide()是可取消的挂起动画:作用域消失或动画中途被重新显示时,其后的代码不会执行,标志位会保持为 true。必要时应在动画后检查弹层自身的可见性。- 透明的
contentColor会被内部所有内容继承:放在弹层与内层表面之间的分隔线、单独图标、标题等会画不出颜色。要么给弹层一个真实的内容颜色,要么让内层表面必须提供内容颜色。 - 清零内边距是一笔“债”:底部内边距应作为滚动容器的最后一项放在内容末尾,让列表能滚过导航栏;若作为容器 padding 则会吃掉滚动范围。顶部内边距只在全高变体上出现。
- 删除拖拽手柄会同时删除该插槽上附着的内容:某些 Material3 版本把展开/折叠能力挂在这个插槽上,替换前需确认当前版本的行为,并确保
dragHandle = null的弹层仍可不用手势关闭。 - 从弹层打开弹层需要先移除第一个:两个弹层同时存在会有两层遮罩,第二个会继承第一个的变暗效果。
- 不设置
skipPartiallyExpanded时,高弹层会半开,与自绘的全高表面冲突,看起来像布局 bug 而非状态问题。 - 十几个弹层应封装成一个组件而非约定:
containerColor、contentColor、dragHandle、scrimColor、contentWindowInsets若在每个调用点手写,总会漏掉一个,通常是contentWindowInsets,其症状(导航栏下出现一行)只在部分设备上出现。
验证方式
# every sheet that zeroed its insets — each one owes an end spacer
grep -rln "contentWindowInsets" --include="*.kt" .
# every close path: the line after hide() must be the one that drops the sheet
grep -rn -A1 "\.hide()$" --include="*.kt" .
# every transparent content colour: the next surface inside must re-provide one
grep -rn "contentColor = Color.Transparent" --include="*.kt" .
随后手动验证:用三种方式关闭每个弹层——自身按钮、下滑、点击遮罩——确认三者都有动画且都能让弹层重新打开;在手势导航设备上打开一个弹层,检查最后一行是否避开导航栏;在弹层内部打开另一个弹层,观察遮罩是否出现双重变暗。