Compose 实现 CollapsableTopBarLayout 以及结合 MotionLayout 使用

尽管 Android 供给了 CollapsingToolbarLayout,但是 Compose 并没有这个组件,好在 Compose 完结起来并不困难,凭借 Compose 嵌套翻滚的 Api 能够轻易完结,先看下效果图。

Compose 实现 CollapsableTopBarLayout 以及结合 MotionLayout 使用

在开端完结之前,需求先了解下 NestedScrollConnection

NestedScrollConnection

这是 Compose 嵌套翻滚体系供给的 Api,经过它能够参加嵌套翻滚事件的处理。

首要包括了以下几个办法:

/**
 * Pre scroll event chain. Called by children to allow parents to consume a portion of a drag
 * event beforehand
 *
 * @param available the delta available to consume for pre scroll
 * @param source the source of the scroll event
 *
 * @see NestedScrollSource
 *
 * @return the amount this connection consumed
 */
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
/**
 * Post scroll event pass. This pass occurs when the dispatching (scrolling) descendant made
 * their consumption and notifies ancestors with what's left for them to consume.
 *
 * @param consumed the amount that was consumed by all nested scroll nodes below the hierarchy
 * @param available the amount of delta available for this connection to consume
 * @param source source of the scroll
 *
 * @see NestedScrollSource
 *
 * @return the amount that was consumed by this connection
 */
fun onPostScroll(
    consumed: Offset,
    available: Offset,
    source: NestedScrollSource
): Offset = Offset.Zero
/**
 * Pre fling event chain. Called by children when they are about to perform fling to
 * allow parents to intercept and consume part of the initial velocity
 *
 * @param available the velocity which is available to pre consume and with which the child
 * is about to fling
 *
 * @return the amount this connection wants to consume and take from the child
 */
suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
/**
 * Post fling event chain. Called by the child when it is finished flinging (and sending
 * [onPreScroll] & [onPostScroll] events)
 *
 * @param consumed the amount of velocity consumed by the child
 * @param available the amount of velocity left for a parent to fling after the child (if
 * desired)
 * @return the amount of velocity consumed by the fling operation in this connection
 */
suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
    return Velocity.Zero
}

上面是直接 copy 的代码,注释啥的都写的很清楚了。总的来说便是当你给一个 Modifier 设置了 NestedScrollConnection 之后,这个节点就会参加到嵌套翻滚的分发流程中去,而且会回调上面的几个办法来交给你操控。

  • 入参 available 表明此次可翻滚的增量值,包括 x 和 y 轴的值,可经过正负判别垂直方向。
  • source 表明是用户拖动仍是惯性翻滚。
  • onPreXxx 办法表明翻滚之前,onPostXxx 办法表明翻滚之后。
  • 函数返回值表明此次需求消费的值,不消费则返回 Offset.Zero,不然返回消费的数值,剩下未消费的值将会持续交给正在翻滚的节点处理。

需求分析

具体到咱们这个需求,只需求重视 onPreScroll 办法即可。

观察上面的需求,页面由两部分组成,上面可折叠的头部分以及下面的可翻滚部分。可折叠头默认打开。

向上翻滚时先折叠头部,折叠到最小值时中止折叠,下面开端翻滚。

向下翻滚时先翻滚下面的内容部分,直到翻滚完结,变为不可翻滚状况时再开端打开头部

此外,对于运用方来说,可折叠部分未必是单纯的操控高度,也或许包括其他需求,例如根据折叠份额操控色彩,或者移动某些节点方位等,那么咱们需求将折叠份额值露出到运用方。

接口设计

首要会有一个名为 CollapsableTopBarLayout 的 composable 函数。

除了 modifier 之外,该函数至少还应该包括两个 composable 函数作为入参。

  • topBar: *@Composable* (collapsableProgress: Float) -> Unit: 顶部可折叠区域
  • scrollableContent: *@Composable* () -> Unit : 底部可翻滚区域

还需求一个表明可折叠区域最小高度的入参,可翻滚区域是否能够向前翻滚也是需求作为入参传入的。

  • minTopBarHeight: Dp
  • contentCanScrollBackward: State<Boolean>

本着实用性考虑,可翻滚区域的最大值就不作为入参传入了,咱们将初次 measure 出来的高度作为最大高度。

那么这个函数应该长这样。

@Composable
fun CollapsableTopBarLayout(
    modifier: Modifier = Modifier,
    minTopBarHeight: Dp,
    contentCanScrollBackward: State<Boolean>,
    topBar: @Composable (collapsableProgress: Float) -> Unit,
    scrollableContent: @Composable () -> Unit,
)

具体完结

咱们核心逻辑首要是在 onPreScroll 办法内,在产生翻滚前咱们需求判别此次滑动是应该折叠头部,仍是翻滚底部。

override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
    val height = topBarHeight
    if (height == minPx) {
        if (available.y > 0F) {
            return if (contentCanScrollBackward.value) {
                Offset.Zero
            } else {
                topBarHeight += available.y
                Offset(0F, available.y)
            }
        }
    }
    if (height + available.y > maxPx) {
        topBarHeight = maxPx
        return Offset(0f, maxPx - height)
    }
    if (height + available.y < minPx) {
        topBarHeight = minPx
        return Offset(0f, minPx - height)
    }
    topBarHeight += available.y
    return Offset(0f, available.y)
}

上面的逻辑也比较简单,首要判别当前的头部是否是现已折叠状况,这里是经过当前头部高度和最小高度对比得到的,然后接着判别假如是向下滑动且境地翻滚区域能够向前滑动就不消费,不然表明境地现已滑到顶了,则开端打开顶部区域。

下半部分的逻辑便是判别假如高度能够持续打开就持续打开,而且消费打开的部分。

上面说的消费都是经过设置 topBarHeight 来完结的,在更新 topBarHeight 时同步更新 progress 的 值。

private vartopBarHeight: Float = maxPx
	set(value) {
					field= value
	        progress = 1 - (topBarHeight - minPx) / (maxPx - minPx)
	    }
var progress: Float by mutableStateOf(0F)
    private set

progress 作为一个 state 会露出出去。

这部分代码都在 CollapsableTopBarLayoutConnection 中。

class CollapsableTopBarLayoutConnection(
    private val contentCanScrollBackward: State<Boolean>,
    private val maxPx: Float,
    private val minPx: Float,
) : NestedScrollConnection {
    private var topBarHeight: Float = maxPx
        set(value) {
            field = value
            progress = 1 - (topBarHeight - minPx) / (maxPx - minPx)
        }
    var progress: Float by mutableStateOf(0F)
        private set
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val height = topBarHeight
        if (height == minPx) {
            if (available.y > 0F) {
                return if (contentCanScrollBackward.value) {
                    Offset.Zero
                } else {
                    topBarHeight += available.y
                    Offset(0F, available.y)
                }
            }
        }
        if (height + available.y > maxPx) {
            topBarHeight = maxPx
            return Offset(0f, maxPx - height)
        }
        if (height + available.y < minPx) {
            topBarHeight = minPx
            return Offset(0f, minPx - height)
        }
        topBarHeight += available.y
        return Offset(0f, available.y)
    }
}

以及对应的 remember 函数

@Composable
fun rememberCollapsableTopBarLayoutConnection(
    contentCanScrollBackward: State<Boolean>,
    maxPx: Float,
    minPx: Float,
): CollapsableTopBarLayoutConnection {
    return remember(contentCanScrollBackward, maxPx, minPx) {
        CollapsableTopBarLayoutConnection(contentCanScrollBackward, maxPx, minPx)
    }
}

然后便是 CollapsableTopBarLayout 部分,这部分代码比较简单,没啥好说的,直接看全部的代码吧。

@Composable
fun CollapsableTopBarLayout(
    modifier: Modifier = Modifier,
    minTopBarHeight: Dp,
    contentCanScrollBackward: State<Boolean>,
    topBar: @Composable (collapsableProgress: Float) -> Unit,
    scrollableContent: @Composable () -> Unit,
) {
    val density = LocalDensity.current
    val minTopBarHeightPx = with(density) { minTopBarHeight.toPx() }
    var maxTopBarHeightPx: Float? by remember {
        mutableStateOf(null)
    }
    var progress: Float by remember {
        mutableStateOf(0F)
    }
    val finalModifier = if (maxTopBarHeightPx == null) {
        Modifier.then(modifier)
    } else {
        val connection = rememberCollapsableTopBarLayoutConnection(
            contentCanScrollBackward = contentCanScrollBackward,
            maxPx = maxTopBarHeightPx!!,
            minPx = minTopBarHeightPx,
        )
        progress = connection.progress
        Modifier
            .then(modifier)
            .nestedScroll(connection)
    }
    Column(modifier = finalModifier) {
        Box(
            modifier = Modifier.onGloballyPositioned {
                if (maxTopBarHeightPx == null) {
                    maxTopBarHeightPx = it.size.height.toFloat()
                }
            }
        ) {
            topBar(progress)
        }
        Box(
            modifier = Modifier.scrollable(rememberScrollState(), Orientation.Vertical)
        ) {
            scrollableContent()
        }
    }
}

我给上下两个区域都包了一个 Box,上面是因为需求核算高度,下面区域是因为需求设置 scrollable ,不然会呈现一些奇怪的小问题。

趁便说下,假如结合 MotionLayout 运用的话,能够完结许多炫酷的交互,例如这种。

Compose 实现 CollapsableTopBarLayout 以及结合 MotionLayout 使用

MotionLayoutConstrainLayout 供给的控件,用于完结交互动画,具体运用这里就不多介绍了,跟 ConstrainLayout 比较类似。这就直接放上上面这种布局动画的代码。

@OptIn(ExperimentalMotionApi::class)
@Composable
fun CollapsableTopBarPage() {
    val listState = rememberLazyListState()
    val contentCanScrollBackward: State<Boolean> = remember {
        derivedStateOf {
            !(listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0)
        }
    }
    val toolbarHeight = 64.dp
    val bannerHeight = 180.dp
    val motionScene = MotionScene {
        val backIcon = createRefFor("backIcon")
        val toolbarPlaceholder = createRefFor("toolbarPlaceholder")
        val banner = createRefFor("banner")
        val toolbarTitle = createRefFor("toolbarTitle")
        val start1 = constraintSet {
            constrain(backIcon) {
                start.linkTo(parent.start)
                top.linkTo(parent.top)
                customColor("color", Color(0xffffffff))
            }
            constrain(toolbarPlaceholder) {
                start.linkTo(parent.start)
                top.linkTo(parent.top)
                alpha = 0F
            }
            constrain(banner) {
                width = Dimension.fillToConstraints
                height = Dimension.value(bannerHeight)
                start.linkTo(parent.start)
                top.linkTo(parent.top)
            }
            constrain(toolbarTitle) {
                start.linkTo(parent.start, 16.dp)
                bottom.linkTo(parent.bottom, 16.dp)
                customColor("color", Color(0xffffffff))
            }
        }
        val end1 = constraintSet {
            constrain(backIcon) {
                start.linkTo(parent.start)
                top.linkTo(parent.top)
                customColor("color", Color(0xFF000000))
            }
            constrain(toolbarPlaceholder) {
                start.linkTo(parent.start)
                top.linkTo(parent.top)
                alpha = 1F
            }
            constrain(banner) {
                width = Dimension.fillToConstraints
                height = Dimension.value(bannerHeight)
                start.linkTo(parent.start)
                top.linkTo(parent.top, toolbarHeight - bannerHeight)
            }
            constrain(toolbarTitle) {
                start.linkTo(backIcon.end, 16.dp)
                top.linkTo(toolbarPlaceholder.top)
                bottom.linkTo(toolbarPlaceholder.bottom)
                customColor("color", Color(0xFF000000))
            }
        }
        transition("default", start1, end1) {}
    }
    CollapsableTopBarLayout(
        minTopBarHeight = 48.dp,
        contentCanScrollBackward = contentCanScrollBackward,
        topBar = { collapsableProgress ->
            MotionLayout(
                modifier = Modifier.fillMaxWidth(),
                motionScene = motionScene,
                progress = collapsableProgress,
            ) {
                Image(
                    modifier = Modifier
                        .layoutId("banner")
                        .fillMaxWidth(),
                    painter = painterResource(id = R.drawable.banner),
                    contentScale = ContentScale.FillBounds,
                    contentDescription = "Thumbnail",
                )
                Surface(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(toolbarHeight)
                        .background(Color.White)
                        .layoutId("toolbarPlaceholder"),
                    shadowElevation = 2.dp,
                ) {}
                val backIconProperties = motionProperties(id = "backIcon")
                Box(
                    modifier = Modifier
                        .height(toolbarHeight)
                        .padding(start = 4.dp)
                        .layoutId("backIcon"),
                    contentAlignment = Alignment.Center,
                ) {
                    IconButton(onClick = {}) {
                        Icon(
                            modifier = Modifier.size(24.dp),
                            painter = rememberVectorPainter(Icons.Default.ArrowBack),
                            contentDescription = "back",
                            tint = backIconProperties.value.color("color"),
                        )
                    }
                }
                val toolbarTitleProperties = motionProperties(id = "toolbarTitle")
                val fontColor = toolbarTitleProperties.value.color("color")
                Box(
                    modifier = Modifier
                        .layoutId("toolbarTitle")
                ) {
                    Text(
                        text = "CollapsableTopBarLayout",
                        color = fontColor,
                        fontWeight = FontWeight.Bold,
                        fontSize = 18.sp,
                    )
                }
            }
        },
    ) {
        LazyColumn(state = listState) {
            items(60) {
                Surface(
                    modifier = Modifier
                        .padding(vertical = 10.dp, horizontal = 10.dp)
                        .fillMaxWidth()
                        .height(48.dp),
                    shadowElevation = 4.dp,
                ) {
                    Text(
                        modifier = Modifier.fillMaxSize(),
                        textAlign = TextAlign.Center,
                        text = "$it item",
                    )
                }
            }
        }
    }
}

代码量稍微有点多,但不复杂,都是布局相关的。

MotionScene 是新版本供给 DSL,用来创建束缚布局信息,其间包括动画开端前的布局以及结束后的布局,然后经过不同的 progress 驱动 UI 变化。也能够经过 KeyFrame 设置关键帧。

然后在下面的 MotionLayout 中运用 layoutId 与上面的绑定即可。