手撸一个Compose三段式BottomSheetLayout

前言

Compose太香了!!虽然我说不出来具体怎么香,但是不妨碍我吹它!但是有的东西在做需求的时候就有点不得劲。这不PM要求项目里的BottomSheet得是三段式的,也就是折叠、半开、全开三个状态。自信如我抱着对谷歌爸爸的信任,翻阅Compose的控件(实际上就是一个个Composable,为了叙述方便,都统一描述为控件啦)。皇天不负......嗯?有是有,只有两段式的?不会吧?不会是真的吧?不相信,我赌肯定能设置多段的!好吧事实证明并没有。转身报告给我的技术顶头,Compose不能实现三段式喔怎么办?大佬自信一笑,小case!大佬做好了之后我迫不及待拉下来观摩。好家伙!外面套了一个传统View的BottomSheetBehavior的layout,甚至还写了滑动冲突的适配代码。大佬就是大佬,总能让我心悦诚服。但是吧,我总感觉这个实现不够纯洁。Compose:这是赤裸裸的NTR情节!于是乎我怀着满腔热情,走向了不归路:自己造。

技术路线

swipeable + offset

假如各位看官对Compose有点了解的话,也许知道有这么一个内置的控件------SwipeToDismiss。首先从我脑海蹦出来正是这个东西。形式上跟我的BottomSheet需求是一个路子,自然而然地就去参考一下实现的方法。以下贴出SwipeToDismiss的精简后的代码:

kotlin 复制代码
@Composable
@ExperimentalMaterialApi
fun SwipeToDismiss(
    state: DismissState,
    modifier: Modifier = Modifier,
    directions: Set<DismissDirection> = setOf(EndToStart, StartToEnd),
    dismissThresholds: (DismissDirection) -> ThresholdConfig = {
        FixedThreshold(DISMISS_THRESHOLD)
    },
    background: @Composable RowScope.() -> Unit,
    dismissContent: @Composable RowScope.() -> Unit
) = BoxWithConstraints(modifier) {
    //省略。。。
  
    //重点1
    Box(
        Modifier.swipeable(
            state = state,
            anchors = anchors,
            thresholds = thresholds,
            orientation = Orientation.Horizontal,
            enabled = state.currentValue == Default,
            reverseDirection = isRtl,
            resistance = ResistanceConfig(
                basis = width,
                factorAtMin = minFactor,
                factorAtMax = maxFactor
            )
        )
    ) {
        //重点2
        Row(
            content = background,
            modifier = Modifier.matchParentSize()
        )
        //重点3
        Row(
            content = dismissContent,
            modifier = Modifier.offset { IntOffset(state.offset.value.roundToInt(), 0) }
        )
    }
}

好,我们先来看看它的参数:

  • DismissState:用于管理这个控件的状态,简单来说就是可以观察和控制这个控件的类。它的本质是一个SwipeableState。不知道的同学自己补一下吧,这里就不说了。
  • Modifier:跳过跳过,这都不懂就别看这文章了,看点基础的先!
  • DismissDirection:也不细说了,就是滑动的方向。
  • background:接受一个Composable。顾名思义,这个就是描述背景层的。
  • dismissContent:同样也是接受一个Composable。顾名思义......emmm,这个就不顾名思义了,总感觉有点别扭这个名字,明明是最主要显示的东西,结果落得个"dismiss"的名称。反正就是位于前面的那一层。

好了,看完了参数,对这个控件的使用大概了解清楚了。传两层画面,一层是主要显示的画面------dismissContent提供滑动的效果;一层是显示背景的画面------background。

当手指滑动dissmissContent,就会露出后面的background,并且滑动到某个阈值就响应Dissmiss(这个并不是本文重点,有兴趣的同学自行学习)。

接下来就看具体这个滑动的效果是怎么实现的。

首先看"重点1",这里放置了一个Box控件,并且使用了swipeable这个Modifier。由于传入的DismissState师承SwipeableState,所以可以直接把它传给swipeable。

然后是"重点2",放置了一个Row作为background的父布局,这是因为需要用到侧滑删除的画面一般都是水平排列的。

最后是"重点3 ",同样放置了一个Row作为dissmissContent的父布局,理由同上。另外还给这个Row设置了一个offset的Modifier。稍微介绍一下这个offset,就是控制画面在X和Y方向上的偏移量。记住这个SAM式的offset,算是一个比较重要的小细节。那么这里的做法就是将DissmissState的偏移量作为dismissContent的X方向上的偏移量。

总结一下SwipeToDismiss是基于swipeable+offset的技术路线来实现的。

那么我就可以抄过来自己实现一下,呸!不是抄,读书人的事怎么能说抄呢?是借鉴!

具体的改动只是将swipeable的方向改成垂直的,State的状态设成三个(折叠、半开、全开)等等。这些具体的做法下面再详细介绍,这里先略过。

正当我欣喜若狂的时候,我却发现,这样的实现方法有一个我无法解决的问题(希望有懂王可以告诉我原因):即便当background露出来,手指也无法点击到background。我尝试为容器Box,background以及sheetContent(也就是SwipeToDismiss里的dismissContent)都设置clickable并打印。我发现background无论如何也无法响应点击事件。sheetContent偏移开,露出background的那部分的点击事件是由容器Box响应。鉴于我对Compose的点击事件传递机制不是很了解,我也不妄加评论了。

由于项目需要background也是可交互的,我不得不放弃这个简单明了的路线。

swipeable + Layout

一路不通再寻一路!既然上一个技术路线不行,那我就找根正苗红的Compose BottomSheet借鉴!

其实Compose里没有专门的BottomSheet控件,而是集成到BottomSheetScaffold里面。Scaffold是Compose Material里面很强大的控件,与其说它是一个控件,不然说它是一个"框架"。它里面提供了很多"常用"的UI槽,例如:SnackBar、TopBar、BottomSheet、fab等等。但为什么我在常用二字上用了双引号呢?因为我觉得在国内的App设计中,其实并不是那么地通用。

Anyway,这个不是重点,我们还是回归到这个BottomSheetScaffold是怎么实现BottomSheet的功能上去。这里同样是贴出精简后的代码:

kotlin 复制代码
fun BottomSheetScaffold(
    sheetContent: @Composable ColumnScope.() -> Unit,
    modifier: Modifier = Modifier,
    scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
    //省略一些无关参数
    content: @Composable (PaddingValues) -> Unit
) {
    //省略。。。
    BoxWithConstraints(modifier) {
        val fullHeight = constraints.maxHeight.toFloat()
        val peekHeightPx = with(LocalDensity.current) { sheetPeekHeight.toPx() }
        var bottomSheetHeight by remember { mutableStateOf(fullHeight) }
​
        val swipeable = Modifier
            .nestedScroll(scaffoldState.bottomSheetState.nestedScrollConnection)
            .swipeable(
                state = scaffoldState.bottomSheetState,
                anchors = mapOf(
                    fullHeight - peekHeightPx to BottomSheetValue.Collapsed,
                    fullHeight - bottomSheetHeight to Expanded
                ),
                orientation = Orientation.Vertical,
                enabled = sheetGesturesEnabled,
                resistance = null
            )
            //省略。。。
​
        val child = @Composable {
            BottomSheetScaffoldStack(
                body = {
                    Surface(
                        color = backgroundColor,
                        contentColor = contentColor
                    ) {
                        Column(Modifier.fillMaxSize()) {
                            topBar?.invoke()
                            content(PaddingValues(bottom = sheetPeekHeight))
                        }
                    }
                },
                bottomSheet = {
                    Surface(
                        swipeable
                            .fillMaxWidth()
                            .requiredHeightIn(min = sheetPeekHeight)
                            .onGloballyPositioned {
                                bottomSheetHeight = it.size.height.toFloat()
                            },
                        shape = sheetShape,
                        elevation = sheetElevation,
                        color = sheetBackgroundColor,
                        contentColor = sheetContentColor,
                        content = { Column(content = sheetContent) }
                    )
                },
                //省略。。。
                bottomSheetOffset = scaffoldState.bottomSheetState.offset,
                //省略。。。
            )
        }
        //省略。。。
    }
}

我们先来看看参数,好吧不看了,跟上面的SwipeableToDismiss别无二致。

主要看看实现。

首先定义好了全开的高度、peek的高度。接着定义了一个swipeable的Modifier。这其中有几点我觉得需要稍微解释一下的:

  • nestedScroll:用于处理嵌套滑动的,需要传一个NestedScrollConnection接口。这个我也不会,我也是直接CV的。
  • anchors:直译是锚,其实非常形象。用于标定不同状态对应的SwipeableState的offset。以官方的这个为例子,只有折叠和展开两个状态,分别对应"peekHeightPx"以及"bottomSheetHeight"。
  • orientation:这个就是滑动的方向,BottomSheet自然就是垂直方向了。

接下来的就是重点的地方了,主体使用了一个叫"BottomSheetScaffoldStack"的Composable进行包裹,并把content传给了body,sheetContent传给了bottomSheet。下面放上精简后的BottomSheetScaffoldStack的代码:

kotlin 复制代码
@Composable
private fun BottomSheetScaffoldStack(
    body: @Composable () -> Unit,
    bottomSheet: @Composable () -> Unit,
    //省略
    bottomSheetOffset: State<Float>,
    //省略
) {
    Layout(
        content = {
            body()
            bottomSheet()
            //省略
        }
    ) { measurables, constraints ->
        val placeable = measurables.first().measure(constraints)
​
        layout(placeable.width, placeable.height) {
            placeable.placeRelative(0, 0)
​
            val (sheetPlaceable, fabPlaceable, snackbarPlaceable) =
                measurables.drop(1).map {
                    it.measure(constraints.copy(minWidth = 0, minHeight = 0))
                }
​
            val sheetOffsetY = bottomSheetOffset.value.roundToInt()
​
            sheetPlaceable.placeRelative(0, sheetOffsetY)
​
            //省略
        }
    }
}

这里就是最关键的地方了,用到了Layout这个Composable。不知道的同学也没关系,这个是Compose路上必定需要接触到的东西。Layout是用于自定义布局的,承担了测量与放置子控件的责任。常见的使用场景其实就如同这个BottomSheetScaffoldStack一样,是个典型的例子,借助这个例子梳理一下Layout的用法。

kotlin 复制代码
@Composable inline fun Layout(
    content: @Composable @UiComposable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
)

首先当然还是参数,必传的两个参数,一个是content 另一个是名为MeasurePolicy的接口。

conetnt自然就是放置子控件的了,正如上面所写,把body和bottomSheet按顺序调用。

而MeasurePolicy则可以使用SAM式的写法(据说还有一个Compose的"固有特征测量"会用另外的写法,但这里不展开了,其实就是我也没学会)。两个入参数: measurables------一个Measurable的List;constraints------用于表示父布局的约束条件。需要返回一个MeasureResult,而这个MeasureResult通常使用layout方法返回。

接下来是MeasurePolicy里面的实现顺序。

measurables是待测量的子控件List,所以第一件事是把所有子控件都测量一遍。另外需要注意的是,measurables的顺序跟content里调用子控件的顺序是一一对应的。BottomSheetScaffoldStack中,首先测量了第一个子控件(也即body),得到了该子控件的placeable。Placeable指代待放置的子控件。同样地,剩下的子控件也同样被一一测量(BottomSheetScaffoldStack里面使用的是Kotlin中的解构声明)。

既然所有子控件都测量完毕,我们也就得到了它们各自的Placeable,下一步自然就是放置它们了。上面说了,MeasureResult通常使用layout方法来间接返回。layout方法提供了一个PlacementScope,用于放置子控件。可以看到BottomSheetScaffoldStack首先放置了body,由于body是撑满父布局的,所以它不需要额外的处理,直接放置到原点就可以了。紧接着就是处理bottomSheet子控件的放置。以bottomSheetOffset作为其Y方向上的偏移量放置(因为滑动只是垂直方向的,所以X方向并不需要偏移,固定传0即可)。还记得bottomSheetOffset实际上是什么吗?不记得不要紧,我们往上翻就知道了。

kotlin 复制代码
bottomSheetOffset = scaffoldState.bottomSheetState.offset

就是BottomSheetState的offset。这个offset是继承自SwipeableState的,用于传递滑动的偏移量。

自此,整体的思路非常明显了:利用swipeable的offset,在Layout中不断调整sheetContent的放置坐标,从而实现BottomSheet的效果。

根据上面所说总结一下Layout的实现流程:

  1. 按顺序在content中调用子控件
  2. 在MeasurePolicy中测量所有子控件
  3. 在layout中放置所有子控件

实现

既然官方能实现,我也能!

与官方版本的差异有以下两点:三段式以及动态调节peekHeight。

根据上面关于anchors的解释,三段式其实只需要为三种状态设置三个锚点:

kotlin 复制代码
//定义三种状态
enum class TriSectionBottomSheetState {
    EXPAND, HALF, COLLAPSE
}
​
Modifier
  .nestedScroll(bottomSheetState.PreUpPostDownNestedScrollConnection)
  .swipeable(
      bottomSheetState,
      //设置三个锚点
      anchors = mapOf(
          fullHeight - collapseHeight to TriSectionBottomSheetState.COLLAPSE,
          fullHeight - halfExpandHeight to TriSectionBottomSheetState.HALF,
          0f to TriSectionBottomSheetState.EXPAND,
      ),
      thresholds = { _, _ -> FractionalThreshold(0.5f) },
      orientation = Orientation.Vertical
  ),

其中关于各个锚点对应的偏移值的计算,可以按照自己的需求去改变,我这里是用了BottomSheet的顶部到父布局顶部的距离这个计算方法。

接下来的整体结构跟官方版本的区别不大,直接贴出代码:

kotlin 复制代码
@Composable
fun TriSectionBottomSheet(
    modifier: Modifier = Modifier,
    bottomSheetState: MyBottomSheetState,
    peekHeight:()->Int,
    sheetContent: @Composable () -> Unit,
    content: @Composable () -> Unit
) {
    BoxWithConstraints(modifier = modifier.fillMaxSize()) {
        val fullHeight = constraints.maxHeight.toFloat()
        val collapseHeight =
            with(LocalDensity.current) { peekHeight().dp.toPx() }
        val halfExpandHeight = fullHeight * 0.6f
        Layout(content = {
            content()
            Box(
                Modifier
                    .nestedScroll(bottomSheetState.PreUpPostDownNestedScrollConnection)
                    .swipeable(
                        bottomSheetState,
                        anchors = mapOf(
                            fullHeight - collapseHeight to TriSectionBottomSheetState.COLLAPSE,
                            fullHeight - halfExpandHeight to TriSectionBottomSheetState.HALF,
                            0f to TriSectionBottomSheetState.EXPAND,
                        ),
                        thresholds = { _, _ -> FractionalThreshold(0.5f) },
                        orientation = Orientation.Vertical
                    ),
                contentAlignment = Alignment.BottomCenter
            ) {
                sheetContent()
            }
        }) { measurables, constraints ->
            val containerSize = measurables.first().measure(constraints)
            val sheetSize = measurables[1].measure(constraints)
            layout(containerSize.width, containerSize.height) {
                containerSize.placeRelative(0, 0)
                val sheetOffset = bottomSheetState.offset.value.roundToInt().let {
                    if (it <= 0)
                        0
                    else it
                }
                sheetSize.placeRelative(0, sheetOffset)
            }
        }
    }
}

如果官方的代码能看懂的话,我这个的代码自然而然也能看懂,主要的东西就不赘述了,但是还有一点点小细节需要多加解释:

参数"peekHeight"为什么是需要一个返回值为Int的函数而不是直接是一个Int呢?原因其实是如果直接使用Int的话,当peekHeight发生变化的时候,整个sheetContent会由于Compose的重组机制而发生重组,这并不是我希望看到的效果。受到了offset的启发,我也使用了类似的处理方式避免了不必要的重组。这就是为什么在上面我说要留意那个offset。其实offset这个Modifier是有多个重载的,为什么SwipeToDismiss里面用了这种形式的offset呢?

kotlin 复制代码
fun Modifier.offset(offset: Density.() -> IntOffset) = this.then(
    OffsetPxModifier(
        offset = offset,
        rtlAware = true,
        inspectorInfo = debugInspectorInfo {
            name = "offset"
            properties["offset"] = offset
        }
    )
)

以一个返回IntOffset的函数作为参数,是不是跟我的peekHeight很像呢?我猜这个offset也是为了解决一些非期望的重组。当然我对这个猜测不负责,因为我没有真的去测试过,只是我的猜测而已。但我的peekHeight参数使用这样的形式确确实实不会发生重组。背后的原理是由Compose的重组原理所决定的。对于Compose来说,我的peekHeight一直没变,还是那个函数对象,所以便不会触发重组。

还有就是放置步骤中对sheetOffset的计算,我设定了如果滑动的偏移值小于零则bottomSheet的偏移恒等于零。原因是我发现直接设置offset会存在OverScroll的情况,而我的需求不允许出现,所以如此设定。

完整代码

贴出完整的代码,供其他同学抄......呸!借鉴!

kotlin 复制代码
enum class TriSectionBottomSheetState {
    EXPAND, HALF, COLLAPSE
}

class MyBottomSheetState(
    initialValue: TriSectionBottomSheetState = TriSectionBottomSheetState.COLLAPSE
) : SwipeableState<TriSectionBottomSheetState>(initialValue = initialValue) {
    companion object {
        fun Saver(): Saver<MyBottomSheetState, *> = Saver(
            save = { it.currentValue },
            restore = {
                MyBottomSheetState(
                    initialValue = it,
                )
            }
        )
    }

    suspend fun expand() {
        animateTo(TriSectionBottomSheetState.EXPAND)
    }

    suspend fun half() {
        animateTo(TriSectionBottomSheetState.HALF)
    }

    suspend fun collapse() {
        animateTo(TriSectionBottomSheetState.COLLAPSE)
    }
}

@Composable
fun TriSectionBottomSheet(
    modifier: Modifier = Modifier,
    bottomSheetState: MyBottomSheetState,
    peekHeight:()->Int,
    sheetContent: @Composable () -> Unit,
    content: @Composable () -> Unit
) {
    BoxWithConstraints(modifier = modifier.fillMaxSize()) {
        val fullHeight = constraints.maxHeight.toFloat()
        val collapseHeight =
            with(LocalDensity.current) { peekHeight().dp.toPx() }
        val halfExpandHeight = fullHeight * 0.6f
        Layout(content = {
            content()
            Box(
                Modifier
                    .nestedScroll(bottomSheetState.PreUpPostDownNestedScrollConnection)
                    .swipeable(
                        bottomSheetState,
                        anchors = mapOf(
                            fullHeight - collapseHeight to TriSectionBottomSheetState.COLLAPSE,
                            fullHeight - halfExpandHeight to TriSectionBottomSheetState.HALF,
                            0f to TriSectionBottomSheetState.EXPAND,
                        ),
                        thresholds = { _, _ -> FractionalThreshold(0.5f) },
                        orientation = Orientation.Vertical
                    ),
                contentAlignment = Alignment.BottomCenter
            ) {
                sheetContent()
            }
        }) { measurables, constraints ->
            val containerSize = measurables.first().measure(constraints)
            val sheetSize = measurables[1].measure(constraints)
            layout(containerSize.width, containerSize.height) {
                containerSize.placeRelative(0, 0)
                val sheetOffset = bottomSheetState.offset.value.roundToInt().let {
                    if (it <= 0)
                        0
                    else it
                }
                sheetSize.placeRelative(0, sheetOffset)
            }
        }
    }
}

@Composable
fun rememberTriSectionBottomSheetState(initialValue: TriSectionBottomSheetState = TriSectionBottomSheetState.COLLAPSE): MyBottomSheetState =
    rememberSaveable(saver = MyBottomSheetState.Saver()) {
        MyBottomSheetState(initialValue)
    }

val <T> SwipeableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection
    get() = object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            val delta = available.toFloat()
            return if (delta < 0 && source == NestedScrollSource.Drag) {
                performDrag(delta).toOffset()
            } else {
                Offset.Zero
            }
        }

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            return if (source == NestedScrollSource.Drag) {
                performDrag(available.toFloat()).toOffset()
            } else {
                Offset.Zero
            }
        }

        override suspend fun onPreFling(available: Velocity): Velocity {
            val toFling = Offset(available.x, available.y).toFloat()
            return if (toFling < 0 && offset.value > Float.NEGATIVE_INFINITY) {
                performFling(velocity = toFling)
                // since we go to the anchor with tween settling, consume all for the best UX
                available
            } else {
                Velocity.Zero
            }
        }

        override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
            performFling(velocity = Offset(available.x, available.y).toFloat())
            return available
        }

        private fun Float.toOffset(): Offset = Offset(0f, this)

        private fun Offset.toFloat(): Float = this.y
    }

后记

虽然在实现的过程中走了一点弯路,但是弯路不一定就不是好路。Compose的出现可以说是对Android的开发造成了革命性的影响。在学习和使用的过程中,我同样感受到了它的先进和便利,在接下来的日子里希望我能更加深入了解Compose,也希望各位同学不吝赐教。

相关推荐
喜欢踢足球的老罗7 小时前
自动化模型管理:MediaPipe Android SDK 中的模型文件下载与加载机制
android·运维·自动化
AgilityBaby9 小时前
Untiy打包安卓踩坑
android·笔记·学习·unity·游戏引擎
硬件学长森哥10 小时前
Android音视频多媒体开源框架基础大全
android·图像处理·音视频
二流小码农11 小时前
鸿蒙开发:CodeGenie万能卡片生成
android·ios·harmonyos
没有了遇见11 小时前
Android 直播间动画动画队列实现
android
月山知了11 小时前
Android有的命令不需要root权限,有的命令需要root权限是如何实现的
android
科技道人12 小时前
Android 实体键盘 设置默认布局
android·实体键盘·设置默认键盘语言
SHUIPING_YANG13 小时前
tp3.1临时连接指定数据库,切片分类in查询,带过滤需要的数据
android·数据库
前端呆猿13 小时前
Vuex:Vue.js 应用程序的状态管理模式
android·vue.js·flutter
望佑13 小时前
Jetpack Compose 入门:从默认工程到实战开发
android