SuperAPI AI 工具集

custom-modal-sheet-family

前端与建站

查看项目

更新于 10-17 16:16

技能介绍

用途

这个技能用于在 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" .

随后手动验证:用三种方式关闭每个弹层——自身按钮、下滑、点击遮罩——确认三者都有动画且都能让弹层重新打开;在手势导航设备上打开一个弹层,检查最后一行是否避开导航栏;在弹层内部打开另一个弹层,观察遮罩是否出现双重变暗。