Compose实现更完美的BottomSheetBehavior效果

前言

本文将会讲解什么

  • Modifier 封装演练
  • Modifier.nestedScroll 的又一次具体应用(找找我此前的文章?)
  • Composable 撰写过程中,怎样尽可能避免不必要的对象创建
    • rememberUpdatedState 使用场景
    • derivedStateOf 使用场景
  • 使用 扩展函数 简化调用
  • Modifier.layout 的简单使用

本文核心实现什么

本文将实现的是一个以 nestedScroll 为核心的定制化 Modifier,其可以正确响应 child 和 parent 的 nestedScroll 行为。

具体有以下特性:

  1. 组件分为 Hide、Half、Full 三种状态,可代码或者通过手势切换。
  2. 存在 verticalScroll 行为的child,都可和它互相响应。
  3. 可打断的动画,状态间随便切换。
  4. child 通过拖拽 drag 滚动到尽头时,将带动本组件进行"滚动"。
  5. child 通过投掷 fling 滚动到尽头时,也能用正确的速度带动本组件进行滚动。
  6. 投掷速度(即松手时的速度)低于"切换状态要求的速度"时,组件将运动到最近的一个位置状态。

空口无凭,看效果:

艹,你这效果千万别被我们产品看到了!

CoordinatorLayout 是神?

要我说,放屁!

若嵌套、bug永无止息

若实现、秃头于我何异

若修改、传参继承皆孽

若定制、梦幻泡影空虚

咳咳抖个机灵

咱们先来说说 CoordinatorLayout 为什么不是神。

若嵌套、bug永无止息

咱们看看其 类声明:

java 复制代码
public class CoordinatorLayout 
    extends ViewGroup 
    implements NestedScrollingParent2,
               NestedScrollingParent3  { ... }

看到了吧,压根不支持你作为 嵌套子项 ( nestedChild )

不支持的东西你强行嵌套上去,你说:bug是不是可以预期的"永无止息"。

因为 nestedScroll 相关操作必须用匹配的 nestedScroll 系列接口去实现,只通过 scroll 等接口 + 定制手势 去尝试兼容的话,往往会是耗费无数精力和代价后,bug 还是修不完。

若实现、秃头于我何异

CoordinatorLayout 的 child 需要 behavior 才能联动起来。

------ 用作例子的 behavior 当然是本文将实现的 BottomSheeBehavior

区区 2275 行而已。

我相信各位一定有强大的精力、毅力、耐力、意志力,去消化它、吸收它!

我没有

反正我是看都懒得看。

若修改、传参继承皆孽

假设我们要修改一个默认行为 ------

java 复制代码
/**
 * Checks weather half expended state should be skipped when drag is ended. If {@code true}, the
 * bottomSheet will go to the next closest state.
 *
 * @hide
 */
@RestrictTo(LIBRARY_GROUP)
public boolean shouldSkipHalfExpandedStateWhenDragging() {
  return false;
}

根据注释内容:我期望特定情况下跳过"HalfExpandedState"。

人家接口都提供出来了,但不让我们修改行为,行,你真行。

  • 那我继承一下不就能修改掉了?

Too young too simple ,sometimes native !

我们在 CoordinatorLayout 中可以找到 behavior的绑定流程:

java 复制代码
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);
}

这个LayoutParams是内部类,再跟进去:

java 复制代码
LayoutParams(@NonNull Context context, @Nullable AttributeSet attrs) {
    // 省略无关内容...
    if (mBehaviorResolved) {
        mBehavior = parseBehavior(context, attrs, a.getString(
                R.styleable.CoordinatorLayout_Layout_layout_behavior));
    }
    // ...
}

这个parseBehavior命名太规范了,一看就知道它要干啥是吧,我跟:

java 复制代码
static Behavior parseBehavior(Context context, AttributeSet attrs, String name) {
   // ...
    try {
      // 这try块的内容我都懒得看...
    } catch (Exception e) {
// 看到没:"Could not inflate Behavior subclass"
        throw new RuntimeException("Could not inflate Behavior subclass " + fullName, e);
    }
}

看到没:无法 inflate Behavior 的子类

你若继承,我必崩溃。

很简单,我们可以复制整个behavior的两千行代码,生成一个新class,然后自行修改里面的内容就完事了嘛。

↑↑↑ 上面这想法太幽默了对不对 ↑↑↑

吃透这两千行代码再改------造孽啊,干嘛跟自己过不去啊。

但不吃透代码就乱修改------造孽啊,你这是堂而皇之地拉屎啊!

若定制、梦幻泡影空虚

你自己定制几乎是做梦!

不如看看我这篇文章: Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!

(compose的嵌套滚动效果实现这么简单,干嘛非在一堆坑的view里面玩呢?)

(反正嵌套滚动都是要学,干嘛不学更简单、更直观的呢?)

Compose 为什么是神?

不讲废话,直接讲实现思路。

成神之道,就在其中

基本数据结构和声明

数据结构

kotlin 复制代码
// 三种状态,很好理解吧
enum class PageHeightState {
    Full, Half, Hide
}

核心声明

kotlin 复制代码
/**
 * 嵌套滚动底栏
 * @param targetState 底栏的目标状态
 * @param onStateChange 底栏状态改变时的回调
 * @param minVelocityDp 投掷速度大于多少dp/s就能切换到对应方向的下一个状态
 * @param maxHeightPx 最大高度,px,传值<=0,将自动获取最大可用高度。默认传0
 */
fun Modifier.nestedAsBottomSheet(
    targetState: PageHeightState = PageHeightState.Half,
    onStateChange: (PageHeightState) -> Unit = {},
    minVelocityDp: Dp = 800.dp,
    maxHeightPx: Int = 0,
): Modifier = composed {
    // 必声明,除非你完全不想和 parent 互动
    val dispatcher = remember { NestedScrollDispatcher() }
    // ...
}

实现思路

核心规则

我们根据场景,很容易整理出这样一个状态变化的【规则】:

  • v:松手时的速度
  • minV:即上面声明的 minVelocityDp

compose 的最优越之处就在于:我们只需要关注规则、然后撰写规则。

值得注意的是:这里的 4 个 Area 是均分的,这样做不一定符合用户的心理预期,可结合实际情况自行调整4块区域的覆盖范围。

整体脉络

根据上面整理,我们又可以进一步填充我们的代码了------

kotlin 复制代码
// 稍后解释这个
@Composable
fun <T> rememberUpdatedMutableState(newValue: T): MutableState<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }

/**
 * 嵌套滚动底栏
 * @param targetState 底栏的目标状态
 * @param onStateChange 底栏状态改变时的回调
 * @param minVelocityDp 投掷速度大于多少dp/s就能切换到对应方向的下一个状态
 * @param maxHeightPx 最大高度,px,传值<=0,将自动获取最大可用高度。默认传0
 */
fun Modifier.nestedAsBottomSheet(
    targetState: PageHeightState = PageHeightState.Half,
    onStateChange: (PageHeightState) -> Unit = {},
    minVelocityDp: Dp = 800.dp,
    maxHeightPx: Int = 0,
): Modifier = composed {
    // 这个不必多说
    val dispatcher = remember { NestedScrollDispatcher() }
    val height = remember { Animatable(0f) }
    val density = LocalDensity.current

    // 最大高度的指定,和兜底赋值
    // 想想看,为啥要用rememberUpdatedState?
    val maxHeightFloat by rememberUpdatedState((if (maxHeightPx <= 0f) LocalConfiguration.current.screenHeightDp * density.density else maxHeightPx).toFloat())
    // 以下4个定义都是派生定义,所以用 derivedStateOf
    val threeQuartersHeight by remember { derivedStateOf { maxHeightFloat * 3 / 4f } }
    val halfHeight by remember { derivedStateOf { maxHeightFloat / 2f } }
    val quarterHeight by remember { derivedStateOf { maxHeightFloat / 4f } }
    val transStateToHeight: (PageHeightState) -> Float by remember {
        derivedStateOf {
            // 注意:
            // 此处省略了 `return` ,返回的是一个 lambda
            { pageState ->
                // 注意:
                // 此 lambda 也省略了 `return`
                // 返回的是一个 float
                when (pageState) {
                    PageHeightState.Full -> maxHeightFloat
                    PageHeightState.Half -> halfHeight
                    PageHeightState.Hide -> 0f
                }
            }
        }
    }
    
    val minVelocity by rememberUpdatedState(minVelocityDp)
    val springAnim = remember { spring(1f, 200f, 1f) }

    // 注意:
    // 这里使用的是上面新定义的一个 Composable
    var lastNotifiedState by rememberUpdatedMutableState(targetState)
    // 想想这里为啥是一个 rememberUpdatedState
    val notifyStateChange: State<(PageHeightState) -> Unit> = rememberUpdatedState { newState ->
        if (lastNotifiedState != newState) {
            dispatcher.coroutineScope.launch { onStateChange(newState) }
            lastNotifiedState = newState
        }
    }

    // 注意:
    // 这个 remember{} 未带任何key
    // 但它内部仍能取得上面最新的变量/回调
    val nestedConnection = remember {
        object : NestedScrollConnection {
            // 调用这个,不比function调用来得优雅?!
            private val Float.isInBalance: Boolean
                get() = this == maxHeightFloat || this == halfHeight || this == 0f
            private val Float.isNotInBalance: Boolean
                get() = !isInBalance
            // 注意:
            // 这其实是一个变量,每次调用都会重新计算
            private val minSwitchVelocity
                get() = density.density * minVelocity.value
            
            // 这就是"我该去哪儿"的核心判断方法:
            // 根据当前高度和速度判断我的目标状态
            private fun computePageHeightState(curHeight: Float, v: Float): PageHeightState {
                val pageState: PageHeightState = when (curHeight) {
                    in threeQuartersHeight..maxHeightFloat -> if (v > minSwitchVelocity) PageHeightState.Half else PageHeightState.Full
                    in halfHeight..threeQuartersHeight     -> if (v < -minSwitchVelocity) PageHeightState.Full else PageHeightState.Half
                    in quarterHeight..halfHeight           -> if (v > minSwitchVelocity) PageHeightState.Hide else PageHeightState.Half
                    in 0f..quarterHeight                   -> if (v < -minSwitchVelocity) PageHeightState.Half else PageHeightState.Hide
                    else                                   -> PageHeightState.Full
                }
                return pageState
            }
            
            // 这几个老熟人肯定不着急讲
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {}
            override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {}
            override suspend fun onPreFling(available: Velocity): Velocity {}
            override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {}
        }
    }
    // 传参state一旦改变,立马animate到对应新状态!
    LaunchedEffect(targetState) {
        height.animateTo(transStateToHeight(targetState), springAnim)
    }

    this
        .nestedScroll(nestedConnection, dispatcher)
        .layout { measurable, constraints ->
            val targetHeight = height.value.roundToInt().coerceAtLeast(0)
            // 用自己定义的constrainsts去measure
            // 使得child、parent能正确布局
            
            val placeable = measurable.measure(
                constraints.copy(
                    minHeight = targetHeight,
                    maxHeight = targetHeight)
            )
            layout(placeable.width, placeable.height) {
                placeable.place(0, 0)
            }
        }
}

脉络拆分之:Modifier.layout

本文实现该效果并没有参照 BottomSheetBehavior 做 offset。 而是实现的 重新layout 。

因为我喜欢啃硬骨头。

------ 因为 compose 这个 layout ,大家刚接触肯定有点懵,可能简单两行、但半天达不到想要的效果。我就做个好人,帮大家顺便讲了。

------ 况且,滑动时改变布局大小,而不是简单位移布局内容,也必然是大家的需求之一。

  • 如果使用 offset 实现此效果,则必然要面对"底栏得另行布局"的问题。
    • 这时候就会存在整体高度需要重新设置,要减去底栏高度
      • 那......底栏高度如果是动态的呢?
        • 哦豁,md,想想就麻烦。
          • 什么辣鸡需求,劳资不干了!
  • 如果使用 layout 实现此效果
    • 则布局可以这么写:
kotlin 复制代码
@Composable
private fun HalfPage(modifier: Modifier, minVelocityDp: Dp, pageHeightState: () -> PageHeightState, onStateChange: (PageHeightState) -> Unit) {
    // 半屏页面
    Box(modifier
        .fillMaxWidth()
        .nestedAsBottomSheet(pageHeightState(), onStateChange, minVelocityDp)
    ) {
        Column(Modifier.fillMaxWidth().align(Alignment.BottomCenter)) {
        val lazyListState = rememberLazyListState()
        LazyColumn(Modifier.fillMaxWidth()
            // 看到没,动态高度,尽可能占满
            .weight(1f)
        ) {
                    // ...
            }
            // 充当底部栏,底部栏不用的空间留给上面的weight(1f)的控件
            Column(Modifier.fillMaxWidth().padding(vertical = 15.dp)) {
               // balabalabalh
            }
        }
    }
}

综上,layout 的实现、优势讲解完毕。


脉络拆分之:rememberUpdatedState

首先这个在啥时候用?

我们当然可以看官方怎么做的:

kotlin 复制代码
// androidx.compose.material3:material3:1.1.1@aar
// Slider.kt

@Composable
private fun SliderImpl(
    // ...
    onValueChange: (Float) -> Unit,
    // ...
) {
    val onValueChangeState = rememberUpdatedState<(Float) -> Unit> {
        if (it != value) {
            onValueChange(it)
        }
    }
    // ...
    
    val draggableState = remember(valueRange) {
        SliderDraggableState {
            // ...
            onValueChangeState.value.invoke(scaleToUserValue(minPx, maxPx, offsetInTrack))
        }
    }

可以看到:

下方 draggableState 对象的 rememeber key 只有一个"valueRange"。

也就是说:仅 valueRange 改变了,才会导致此对象重新创建;

也就是说:它内部拿到的 onValueChangeState 持有了 "onValueChange" 最新的引用;

也就是说:onValueChangeState 只会创建一次,但每次都能够更新最新的引用。

所以我们可以看看 rememberUpdatedstate 的源码:

kotlin 复制代码
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }
  • 这是一个composable ,意味着只有唯一传参 newValue 改变,才会导致此 composable执行。
  • 它返回了一个 state 对象,且remember没填key,意味着它将在composable中长久存在。
    • 且返回的state不可以被修改
  • 它每次执行时只做了一件事:重新给state的value赋值。

源码表明我们上方的理解完全正确。


脉络拆分之:rememberUpdatedMutableState

这是一个我参照上方源码自行实现的 Composable ------

kotlin 复制代码
// 定义
@Composable
fun <T> rememberUpdatedMutableState(newValue: T): MutableState<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }

// 使用:
var lastNotifiedState by rememberUpdatedMutableState(targetState)

// 这个lambda将在preFling和postFling中调用
val notifyStateChange: State<(PageHeightState) -> Unit> = rememberUpdatedState { newState ->
    if (lastNotifiedState != newState) {
        dispatcher.coroutineScope.launch { onStateChange(newState) }
        lastNotifiedState = newState
    }
}

除了返回的对象是一个 MutableState 之外,完全没有区别。

很显然,本文场景下,我们的 页面状态 来自于外部传参 ,但也会受到手势的影响而自行改变。

为了不重复回调 onStateChange 、且不回调传进来的改变,我们需要一个标记。

  • 这个标记会随着传参改变取值。
  • 也会随着手势结束而更新取值

脉络拆分之:derivedStateOf

它和上文 rememberUpdatedState 类似。

但前者观察的是传参,derivedStateOf 观察的是 state 对象。

------ 它帮你建立监听,当你需要 remember ( key1 ,key2 ) { ... } 时,如果所有的 key 都是 state 对象的话,就用 derivedStateOf 。

为什么要这样做?

因为:

  • remember ( key1 ,key2 ) { ... } 的形式会生成新的对象
  • 如果 B 引用了这个对象,当这个对象重新生成时,B也需要重新更新引用。
  • B 的"重新更新引用"的做法很可能是:自己也 remember 同样多的、甚至更多的key,来保证可以按需创建新的B
  • 如果 B 的创建代价很大......
  • 如果 B 的关联项目很多......

nestedScroll 核心部分

剩下的就是核心代码了,老规矩,直接上源码+海量的注释:

建议初次看 nestedScroll 实战的童鞋优先看我这篇文章:

Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(中)

kotlin 复制代码
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
    // child 在 preXxx 中先问我们
    // 我们也是我们 parent 的 child,也应规范操作
    val availableV = available.y - dispatcher.dispatchPreScroll(available, source).y
    // 当不平衡时------不在3种预定义状态之一时,才介入处理(*注0)
    // 当是drag------也就是用户拖拽时,才介入处理
    // 当height动画未在运行时,才介入处理 (*注1)
    if (height.value.isNotInBalance && source == NestedScrollSource.Drag && !height.isRunning) {
        // 得到当前高度 lastHeight
        val lastHeight = height.value
        // 得到如果完全消耗此次滚动量时的目标位置 oriTarget
        val oriTargetHeight = lastHeight - availableV
        // 剩余滚动量 leftOffset
        val leftOffset: Float
        // 实际最终滚动位置(修正后的滚动位置)
        var finalTargetHeight = oriTargetHeight
        
        // 下面都是使得高度正确消耗、优先回到平衡态的逻辑
        if (lastHeight > maxHeightFloat) {
            if (oriTargetHeight <= maxHeightFloat) {
                finalTargetHeight = maxHeightFloat
            }
        } else if (lastHeight in halfHeight..maxHeightFloat) {
            if (oriTargetHeight <= halfHeight) {
                finalTargetHeight = halfHeight
            } else if (oriTargetHeight >= maxHeightFloat) {
                finalTargetHeight = maxHeightFloat
            }
        } else if (lastHeight in 0f..halfHeight) {
            if (oriTargetHeight >= halfHeight) {
                finalTargetHeight = halfHeight
            } else if (oriTargetHeight < 0f) {
                finalTargetHeight = 0f
            }
        } else {
            finalTargetHeight = 0f
        }
        dispatcher.coroutineScope.launch {
            height.snapTo(finalTargetHeight)
        }
        // 回到平衡态后计算出实际消费的 offset
        leftOffset = lastHeight - finalTargetHeight
        return Offset(0f, leftOffset)
    }
    return Offset.Zero.copy(y = available.y - availableV)
}

override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
    return if (source == NestedScrollSource.Drag) {
        // child 交过来的滚动量 + 来源是拖拽,就直接消费掉
        dispatcher.coroutineScope.launch {
            height.snapTo(
                (height.value - available.y).coerceAtLeast(0f)
            )
        }
        available
    } else {
        dispatcher.dispatchPostScroll(consumed, available, source)
    }
}

override suspend fun onPreFling(available: Velocity): Velocity {
    // 老规矩
    val availableV = available.y - dispatcher.dispatchPreFling(available).y
    // 仅非平衡态才接过 child 的速度进行处理
    if (height.value.isNotInBalance) {
        val pageState: PageHeightState = computePageHeightState(height.value, availableV)
        // 通知状态改变
        notifyStateChange.value(pageState)
        val target = transStateToHeight(pageState)
        val velocityLeft = height.animateTo(target, springAnim, -available.y)
            .endState
            .velocity
        return Velocity.Zero.copy(y = available.y - velocityLeft)
    }
    return Velocity.Zero.copy(y = available.y - availableV)
}

override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
    // 没啥好说的,计算出目标值后滚动过去即可
    val availableV = available.y
    val pageState: PageHeightState = computePageHeightState(height.value, availableV)
    // 通知状态改变
    notifyStateChange.value(pageState)
    val target = transStateToHeight(pageState)
    val velocityLeft = height.animateTo(target, springAnim, -availableV)
        .endState
        .velocity
    // 最后问一下 parent ,守规矩
    val parentConsumed = dispatcher.dispatchPostFling(
        consumed.copy(y = consumed.y + available.y - velocityLeft),
        available.copy(y = velocityLeft)
    )
    return Velocity.Zero.copy(y = available.y - velocityLeft) + parentConsumed
}

注0:child 滚动到尽头时,会回调 postScroll ,让我们继续消耗滚动量------

  • 使得我们进行滚动
  • 使得我们立即进入不平衡状态
  • 下一次 preScroll 将立即接过所有拖拽导致的滚动事件进行消耗

注1:如果当前正在动画状态------

  • 说明 postScroll 未接管操作
  • 进一步说明 child 正在滚动
    • 但本 modifier 所修饰的内容也正在动画归位ing
  • 所以不应此时进行处理

全文完毕

后日谈

实现滚动条、滚动区域,可以这样实现一个子 Composable :

kotlin 复制代码
 // slideBar
val scrollableState = rememberScrollState()
Column(Modifier.fillMaxWidth()
    .verticalScroll(scrollableState, flingBehavior = rememberNoneFlingBehavior())
    .padding(vertical = 16.dp),
    Arrangement
        .Center, Alignment
        .CenterHorizontally) {
    Box(Modifier
        .width(60.dp)
        .height(6.dp)
        .background(color = Color(0xFFD9D9D9), shape = RoundedCornerShape(size = 3.5.dp))
    )
}

// 子组件本身不需要滚动,就用这个behavior
@Composable
fun rememberNoneFlingBehavior(
): FlingBehavior = remember {
    object : FlingBehavior {
        override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
            return initialVelocity
        }
    }
}

如果子组件需要滚动,比如是个 LazyList 。

在谷歌将bug修复前,请参照这篇文章所述,使用其中的:rememberOverscrollFlingBehavior

Compose 用 nestedScroll 实现iOS的回弹效果,还要帮谷歌修bug?

( bug 的信息和对应 issue 也在文中有具体描述)

如有帮助,请点赞、收藏、转发,谢谢~

相关推荐
sweetying2 小时前
30了,人生按部就班
android·程序员
用户2018792831672 小时前
Binder驱动缓冲区的工作机制答疑
android
真夜2 小时前
关于rngh手势与Slider组件手势与事件冲突解决问题记录
android·javascript·app
少女续续念2 小时前
AI 不再是 “旁观者”!Gitee MCP Server 让智能助手接管代码仓库管理
git·开源
用户2018792831673 小时前
浅析Binder通信的三种调用方式
android
用户093 小时前
深入了解 Android 16KB内存页面
android·kotlin
火车叼位4 小时前
Android Studio与命令行Gradle表现不一致问题分析
android
前行的小黑炭6 小时前
【Android】 Context使用不当,存在内存泄漏,语言不生效等等
android·kotlin·app