前言

本文将会解说什么

  • 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永无止息

咱们看看其 类声明:

public class CoordinatorLayout
    extends ViewGroup 
    implements NestedScrollingParent2,
               NestedScrollingParent3  { ... }

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

不支持的东西你强行嵌套上去,你说:bug是不是能够预期的“永无止息”。

由于 nestedScroll 相关操作必须用匹配的 nestedScroll 系列接口去完成,只经过 scroll 等接口 + 定制手势 去测验兼容的话,往往会是耗费很多精力和代价后,bug 仍是修不完。

若完成、秃头于我何异

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

—— 用作例子的 behavior 当然是本文将完成的 BottomSheeBehavior

Compose实现更完美的BottomSheetBehavior效果
戋戋 2275 行罢了。

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

我没有

反正我是看都懒得看。

若修正、传参承继皆孽

假设咱们要修正一个默许行为 ——

/**
 * 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 naive !

咱们在 CoordinatorLayout 中能够找到 behavior的绑定流程:

@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);
}

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

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

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

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 为什么是神?

不讲废话,直接讲完成思路。

成神之道,就在其中

根本数据结构和声明

数据结构

// 三种状况,很好了解吧
enum class PageHeightState {
    Full, Half, Hide
}

中心声明

/**
 * 嵌套翻滚底栏
 * @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实现更完美的BottomSheetBehavior效果

compose 的最优胜之处就在于:咱们只需求关注规矩、然后编撰规矩。

值得留意的是:这儿的 4 个 Area 是均分的,这样做不一定符合用户的心思预期,可结合实践情况自行调整4块区域的掩盖规模。

全体头绪

根据上面收拾,咱们又能够进一步填充咱们的代码了——

// 稍后解说这个
@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 完成此作用
    • 则布局能够这么写:
@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

首要这个在啥时候用?

咱们当然能够看官方怎么做的:

// 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 的源码:

@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 ——

// 界说
@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的回弹作用到底有多简略?跟着我,不难!(中)

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 :

 // slideBar
Column(Modifier
    .fillMaxWidth()
    .verticalScroll(rememberScrollState(), flingBehavior = rememberNoneFlingBehavior())
    .padding(vertical = 16.dp),
    Arrangement.Center,
    Alignment.CenterHorizontally
) {
    Box(Modifier
        .width(60.dp)
        .height(6.dp)
        .background(color = Color.Gray, shape = CircleShape)
    )
}
// 子组件本身不需求翻滚,就用这个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 也在文中有详细描绘)

如有协助,请点赞、保藏、转发,谢谢~