Android Compose如何构建阶梯式抽屉

2,428 阅读2分钟

背景

近期有个需求,需要做一个三段式抽屉组件,有3个关键状态:折叠、半展开、展开,交互效果类似:

meituan_map_bottom_sheet.gif

官方Bottom Sheet组件(View版)

m3.material.io/components/… 中提到的两种BottomSheet组件,效果如下:

Standard bottom sheetsModal bottom sheets
standard_bottom_sheet.gifmodal_bottom_sheet.gif

半展开状态:SheetBehavior#setHalfExpandedRatio

详情参考官方Demo(view版本):github.com/material-co…

可以满足需求,但项目基本都迁移到Compose了,所以暂时不考虑View版本实现。

官方Bottom Sheet组件(Compose版)

Demo源码: androidx.compose.material3.samples.SimpleBottomSheetScaffoldSample

Standard bottom sheetsModal bottom sheets
compose_bottom_sheet.gifmodal_compose_bottom_sheet.gif

Compose版本支持3种状态:

状态描述
Hiddensheet不可见
Expandedsheet完全展开
PartiallyExpandedsheet半展开,展示高度对应下图sheetPeekHeight
image.png

折叠状态下可见高度为0,看起来并不满足我们的需求。

破局之路

控制Hidden状态显示高度的代码是私有的,代码如下:

@Composable
@ExperimentalMaterial3Api
fun BottomSheetScaffold(
    sheetContent: @Composable ColumnScope.() -> Unit,
    modifier: Modifier = Modifier,
    scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
    sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
    ...
) {
    val peekHeightPx = with(LocalDensity.current) {
        sheetPeekHeight.roundToPx()
    }
    BottomSheetScaffoldLayout(
        ...,
        bottomSheet = { layoutHeight ->
            StandardBottomSheet(
                ...,
                calculateAnchors = { sheetSize ->
                    val sheetHeight = sheetSize.height
                    // 关键代码
                    // 1. layoutHeight: sheet content的总高度;
                    // 2. peekHeightPx: 半展开状态的展示高度
                    DraggableAnchors {
                        if (!scaffoldState.bottomSheetState.skipPartiallyExpanded) {
                            PartiallyExpanded at (layoutHeight - peekHeightPx).toFloat()
                        }
                        if (sheetHeight != peekHeightPx) {
                            Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat()
                        }
                        if (!scaffoldState.bottomSheetState.skipHiddenState) {
                            // Hidden下偏移量等于layoutHeight,完全不可见
                            Hidden at layoutHeight.toFloat()
                        }
                    }
                },
                ...
            )
        }
    )
}

BottomSheetScaffold源码中用到了一个类DraggableAnchors,我们可以基于此自定义BottomSheet

参考官方文档:developer.android.com/develop/ui/…

自定义抽屉效果

my_bottom_sheet.gif

代码如下:

@Composable
@Preview
fun MyBottomSheet(
    modifier: Modifier = Modifier,
) {
    val anchoredDraggableState = rememberAnchoredDraggableState()
    Box(
        modifier
            .offset {
                IntOffset(
                    x = 0,
                    y = anchoredDraggableState
                        .requireOffset()
                        .roundToInt(),
                )
            }
            .onSizeChanged {
                val anchors = DraggableAnchors {
                    DragValue.Expanded at 0.dpToPx
                    DragValue.PartiallyExpanded at it.height * 0.33f // 修改
                    DragValue.Collapsed at it.height - 200.dpToPx // 修改
                }
                anchoredDraggableState.updateAnchors(anchors)
            },
    ) {
        Column(
            modifier = Modifier
                .anchoredDraggable(
                    state = anchoredDraggableState,
                    orientation = Orientation.Vertical,
                )
                .fillMaxSize()
                .background(Color.Gray),
        ) {
            Text(text = "Hello")
        }
    }
}

private enum class DragValue { // 3阶抽屉状态,可以根据实际场景定义更多
    Collapsed,
    Expanded,
    PartiallyExpanded,
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun rememberAnchoredDraggableState(
    initialState: DragValue = DragValue.PartiallyExpanded,
): AnchoredDraggableState<DragValue> = remember(initialState) {
    AnchoredDraggableState(
        initialValue = DragValue.PartiallyExpanded,
        animationSpec = SpringSpec(),
        positionalThreshold = { 56.dpToPx },
        velocityThreshold = { 56.dpToPx },
    )
}

总结

本文从需求开始探索Compose版本的阶梯式抽屉效果实现,最后从BottomSheetScaffold中找到关键突破口,基于AnchoredDraggable定制出所需的抽屉效果,可以发现,在Compose体系下,定制UI变得更加容易!