你有没有想过 "我喜欢 ModalBottomSheet, 但该死的 API 太烦人了"? 不用再担心了! 因为我要给你看点东西.

修复 ModalBottomSheet API
现在, 第一段夸张的话已经说完, 我也引起了你的注意, 让我向你展示一下我所面临的问题. 或者应该说: 轻微的不便. 就像软件开发中的任何问题一样, 当UX设计师希望在整个应用中使用漂亮的功能(例如ModalBottomSheet)时, 问题就开始了. 现在, 在我们的使用案例中, 我们可以将Sheet作为一种可取消的通知形式, 或者作为一种屏幕叠加形式来做实际的事情, 因此我们确实在多个地方使用了它们.
技术设置
我们正在使用神奇的 Jetpack Compose 框架和 Material3 库. 大家可能知道, Material3 已经包含了 ModalBottomSheet, 所以我们显然希望使用它. 你可能也知道, 使用 Jetpack Compose, 我们基本上是早餐吃状态, 午餐吃状态, 晚餐吃状态, 最后再加上一个漂亮的状态作为甜点. 因此, 我自然也倾向于使用这些基于状态的BottomSheet:
kotlin
@Composable
fun OhMyGodIAmAComposable(viewModel: MyViewModel) {
val viewState by viewModel.viewState.collectAsState()
// <Box with button>
if (viewState.showSheet) {
ModalBottomSheet(
onDismissRequest = viewModel::closeSheet,
) {
Text("I am a sheet!")
Button(onClick = viewModel::closeSheet) {
Text("Close")
}
}
}
}
请注意, 这段代码经过了一些编辑, 只显示了基本部分! 但你可以看到的是, 当我们想要显示Sheet时, ModalBottomSheet 就会被添加到我们的组件中, 而当我们不想再显示Sheet时, 它就会被移除. 当我们运行它时, 可以看到以下行为:

Sheet很好地滑入
这显然会引发一个问题: 我的滑出动画在哪里? 为什么是滑入而不是滑出?
了解BottomSheet的内部结构
在代码的内部实现下, BottomSheet就是一个对话框. 这个对话框将使用系统默认的对话框动画. 当我们让对话框出现在组件中时, 对话框会弹出, 同时meterial 库也会触发滑入动画. 当我们将元素从组成中移出时, 自然就无法进行类似的操作并将其滑出: 一旦元素离开组合, 它就会 "再见", 不再有任何动画!
直白的解决方案
因此, 我们有一个BottomSheet, 它需要一个BottomSheet状态. 我们可以采用显而易见的方法, 在每次使用ModalBottomSheet组件时创建自己的BottomSheet状态. 然后, 我们可以手动隐藏Sheet, 并确保它在隐藏后离开我们的组件:
scss
@Composable
fun OhMyGodIAmAComposable(viewModel: MyViewModel) {
val viewState by viewModel.viewState.collectAsState()
val coroutineScope = rememberCoroutineScope()
val bottomSheetState = rememberModalBottomSheetState()
if (viewState.showSheet) {
ModalBottomSheet(
onDismissRequest = viewModel::closeSheet,
sheetState = bottomSheetState,
) {
Text("I am a sheet!")
Button(
onClick = {
coroutineScope.launch {
bottomSheetState.hide()
viewModel.closeSheet()
}
},
) {
Text("Close")
}
}
}
}
在这里, 我们可以看到滑出动画已经如预期般运行:

现在滑出效果很好
更好的解决方案
以现有组件为灵感
我们的组件应该以简单易懂的方式反映状态, 而不必担心SideEffect. 我们应该隐藏所有底部页面的动画逻辑, 因为我们的组件不应该因为这种行为而变得杂乱无章. 显然, 用漂亮的动画来显示和隐藏东西并不是什么新鲜事. 因此, 我们可以看看现有的Compose元素, 如 AnimatedVisibility
, 以及它的 API 是什么样的:

Compose AnimatedVisibility
我们可以看到, 它只是定义了一个 visible: Boolean
参数, 就可以完成所有工作! 现在作为一个实验, 让我们看看如果我们不使用Sheet, 而是将其放在 AnimatedVisibility
中, 我们虚构的Sheet会有什么表现:
scss
@Composable
fun OhMyGodIAmAComposable(viewModel: MyViewModel) {
val viewState by viewModel.viewState.collectAsState()
AnimatedVisibility(
viewState.showSheet,
Modifier.align(Alignment.BottomCenter),
enter = slideInVertically { fullHeight -> fullHeight },
exit = slideOutVertically { fullHeight -> fullHeight },
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = spacedBy(8.dp),
) {
Text("I am a sheet!")
Button(onClick = viewModel::closeSheet) {
Text("Hide me")
}
}
}
}
}
这不是一个真正的Sheet, 我们不会有我们期望的行为, 但是: 我们翻转布尔标志, 它就会以动画的形式出现和消失!

带有滑入和滑出的简单的AnimatedVisibility
把它放在一起
给事物命名是件麻烦事. 我不知道该给它起个什么好名字; 它是一个ModalBottomSheet, 拥有更简单的API, 同时还能确保适当的滑动动画. 我可以叫它 AnimatedModalBottomSheet
, 或者其他什么名字, 但那实在太拗口了. 因此, 在本文的其余部分, 我们将使用简单的AnimatedBottomSheet作为工作名称. 现在我们已经决定了这个重要的问题, 我们可以做以下工作:
- 创建一个
AnimatedBottomSheet
Compose文件, 该文件围绕着 ModalBottomSheet Compose文件. - 添加一个可见性参数:
isVisible
1. - 添加一些额外代码, 根据可见性标志显示/隐藏ModalBottomSheet.
- 敬畏我们新发现的解决方案
less
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnimatedBottomSheet(
isVisible: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = 0.dp,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
properties: ModalBottomSheetProperties = ModalBottomSheetDefaults.properties,
content: @Composable ColumnScope.() -> Unit,
) {
LaunchedEffect(isVisible) {
if (isVisible) {
sheetState.show()
} else {
sheetState.hide()
onDismissRequest()
}
}
// Make sure we dispose of the bottom sheet when it is no longer needed
if (!sheetState.isVisible && !isVisible) {
return
}
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
sheetState = sheetState,
sheetMaxWidth = sheetMaxWidth,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
scrimColor = scrimColor,
dragHandle = dragHandle,
contentWindowInsets = contentWindowInsets,
properties = properties,
content = content,
)
}
有了新的 AnimatedBottomSheet 之后, 我们就可以将其包含在原来的Compose代码中了:
scss
@Composable
fun OhMyGodIAmAComposable(viewModel: MyViewModel) {
val viewState by viewModel.viewState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Button(onClick = viewModel::showSheet) {
Text("Open sheet")
}
}
AnimatedBottomSheet(
isVisible = viewState.showSheet,
onDismissRequest = viewModel::closeSheet,
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = spacedBy(8.dp),
) {
Text("I am a sheet!")
Button(onClick = viewModel::closeSheet) {
Text("Close")
}
}
}
}
不, 我们可以切换打开和关闭, 就像我们在下面的 GIF 中看到的那样:

带有漂亮 API 的 AnimatedModalBottomSheet
进一步优化
比方说, 我们不仅想根据布尔标志显示BottomSheet, 还想动态添加内容? 那么, 我们可以创建一个通用的 AnimatedBottomSheet
, 它可以接受一个可为空的值, 而不是布尔标志. 如果值为空, 我们就会隐藏BottomSheet; 如果值为其他值, 我们就可以用它创建Sheet! 让我们来看看它是什么样子的:
less
@Composable
fun <T> AnimatedBottomSheet(
value: T?,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = 0.dp,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
properties: ModalBottomSheetProperties = ModalBottomSheetDefaults.properties,
content: @Composable ColumnScope.(T & Any) -> Unit,
) {
LaunchedEffect(value != null) {
if (value != null) {
sheetState.show()
} else {
sheetState.hide()
onDismissRequest()
}
}
if (!sheetState.isVisible && value == null) {
return
}
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
sheetState = sheetState,
sheetMaxWidth = sheetMaxWidth,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
scrimColor = scrimColor,
dragHandle = dragHandle,
contentWindowInsets = contentWindowInsets,
properties = properties,
) {
// Remember the last not null value: If our value becomes null and the sheet slides down,
// we still need to show the last content during the exit animation.
val notNullValue = lastNotNullValueOrNull(value) ?: return@ModalBottomSheet
content(notNullValue)
}
}
@Composable
fun <T> lastNotNullValueOrNull(value: T?): T? {
val lastNotNullValueOrNullRef = remember { Ref<T>() }
return value?.also {
lastNotNullValueOrNullRef.value = it
} ?: lastNotNullValueOrNullRef.value
}
现在我们可以做一些漂亮的事情了! 比方说, 我们有 2 种不同的类型:
kotlin
sealed interface SuperSpecialSheetContent {
data class Simple(
val title: String,
): SuperSpecialSheetContent
data class WithButton(
val buttonText: String,
): SuperSpecialSheetContent
}
现在, 我们可以使用这个神奇的密封界面来显示不同的Sheet. 我们甚至可以在不同的Sheet之间制作动画, 一旦内容无效, Sheet就会滑动消失.
scss
@Composable
fun OhMyGodIAmAComposable(viewModel: MyViewModel) {
val viewState by viewModel.viewState.collectAsState()
AnimatedBottomSheet(
value = viewState.sheetContent,
onDismissRequest = viewModel::closeSheet,
) { sheetContent ->
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = spacedBy(8.dp),
) {
AnimatedContent(sheetContent) { specialSheetContent ->
when (specialSheetContent) {
is SuperSpecialSheetContent.Simple ->
Text(specialSheetContent.title)
is SuperSpecialSheetContent.WithButton ->
Button(onClick = viewModel::onShowOtherSheet) {
Text(specialSheetContent.buttonText)
}
}
}
Button(onClick = viewModel::closeSheet) {
Text("Close")
}
}
}
}
这样, 一个简单易用, 基于状态的 AnimatedBottomSheet
就诞生了! 正如你在下面的 GIF 中看到的, 它的行为就像一个普通的 ModalBottomSheet. 它可以很好地滑入和滑出, 你还可以拖动它, 以你认为合适的方式关闭!

具有正确BottomSheet行为的AnimatedBottomSheet
最后的想法
我们自己的AnimatedBottomSheet
重复使用了大部分正常的 ModalBottomSheet API, 包括当Sheet应该被取消时的 onDismissRequest
. 所有的SideEffect和动画逻辑都被很好地整合到了我们的自定义Compose组件中, 这意味着我们在初始创建这个可重复使用的组件后就不必再费心处理这些问题了. 除此之外, 你还可以为参数添加自己的默认值, 或许还可以添加更多自定义参数. 这样, 你就拥有了一个标准化的ModalBottomSheet, 既能满足你的需求, 又非常易于使用!
好啦, 今天的内容就分享到这里啦!
一家之言, 欢迎拍砖!
Happy coding! Stay GOLDEN!