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 也在文中有具体描述)

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

相关推荐
落落落sss2 小时前
MybatisPlus
android·java·开发语言·spring·tomcat·rabbitmq·mybatis
代码敲上天.3 小时前
数据库语句优化
android·数据库·adb
AAI机器之心3 小时前
LLM大模型:开源RAG框架汇总
人工智能·chatgpt·开源·大模型·llm·大语言模型·rag
杨荧4 小时前
【JAVA开源】基于Vue和SpringBoot的洗衣店订单管理系统
java·开发语言·vue.js·spring boot·spring cloud·开源
GEEKVIP5 小时前
手机使用技巧:8 个 Android 锁屏移除工具 [解锁 Android]
android·macos·ios·智能手机·电脑·手机·iphone
model20057 小时前
android + tflite 分类APP开发-2
android·分类·tflite
彭于晏6897 小时前
Android广播
android·java·开发语言
与衫8 小时前
掌握嵌套子查询:复杂 SQL 中 * 列的准确表列关系
android·javascript·sql
FIT2CLOUD飞致云9 小时前
测试管理新增视图与高级搜索功能,测试计划支持一键生成缺陷详情,MeterSphere开源持续测试工具v3.3版本发布
开源·接口测试·metersphere·团队协作·持续测试·测试管理
杨荧11 小时前
【JAVA开源】基于Vue和SpringBoot的旅游管理系统
java·vue.js·spring boot·spring cloud·开源·旅游