Compose实现 CoordinatorLayout折叠效果

Posted LZ涸泽而渔

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Compose实现 CoordinatorLayout折叠效果相关的知识,希望对你有一定的参考价值。

前言:

compose没有CoordinatorLayout的组件,因此面对类似的需求时,难以下手,因此自我研究出可代替方案,能够很好的解决这一类的需求

1.测量与绘制

首先将布局分为两大类,header与content,使用layout进行测量与放置,初步实现界面效果,代码如下:

@Composable
private fun NestedScrollView(
    modifier: Modifier = Modifier,
    state: NestedScrollViewState,
    header: @Composable () -> Unit,
    content: @Composable () -> Unit,
) 
    Layout(
        modifier = modifier
            .scrollable(
                orientation = Orientation.Vertical,
                state = rememberScrollableState 
                    state.drag(it)
                
            )
            .nestedScroll(state.nestedScrollConnectionHolder),
        content = 
            Box 
                header.invoke()
            
            Box 
                content.invoke()
            
        ,
    )  measurables, constraints ->
        layout(constraints.maxWidth, constraints.maxHeight) 
            val headerPlaceable =
                measurables[0].measure(constraints.copy(maxHeight = Constraints.Infinity))
            headerPlaceable.place(0, state.offset.toInt())
            state.updateBounds(-(headerPlaceable.height.toFloat()))
            val contentPlaceable =
                measurables[1].measure(constraints.copy(maxHeight = constraints.maxHeight))
            contentPlaceable.place(
                0,
                state.offset.toInt() + headerPlaceable.height
            )
        
    

其中,关键点为,对header与content的宽高进行测量与放置,使用state.offset进行两者的联动控制,layout使用Modifier.scrollable进行header的滚动控制, 需要特别注意的是,content应该是可滚动组件 也就是lazyList 或者 Column使用verticalScroll()。 state.updateBounds()为控制动画的边界(也就是header的最大位移距离)

2.remember

 因为compose的重组特性,想要在重组过程中记住位置信息,需要使用到saver,代码如下:

@Composable
fun rememberNestedScrollViewState(): NestedScrollViewState 
    val scope = rememberCoroutineScope()
    val saver = remember 
        NestedScrollViewState.saver(scope = scope)
    
    return rememberSaveable(
        saver = saver
    ) 
        NestedScrollViewState(scope = scope)
    

代码中,获取到了携程作用域,以便于后续的state动画,saver中存储了offset 与maxoffset,也就是位移控制,其他位移可以交由LazyList的listState进行控制

companion object 
        fun saver(
            scope: CoroutineScope,
        ): Saver<NestedScrollViewState, *> = listSaver(
            save = 
                listOf(it.offset, it._maxOffset.value)
            ,
            restore = 
                NestedScrollViewState(
                    scope = scope,
                    initialOffset = it[0],
                    initialMaxOffset = it[1],
                )
            
        )
    

 3.前期准备

在测量与绘制时,调用了updateBounds ,意义在于为state动画设定一个范围以及确定最大位移值maxOffset,关键方法与成员函数如下

 private var _offset = Animatable(initialOffset)
 private val _maxOffset = mutableStateOf(initialMaxOffset)

 internal fun updateBounds(maxOffset: Float) 
        _maxOffset.value = maxOffset
        _offset.updateBounds(maxOffset, 0f)
    

 /**
   * The maximum value for [NestedScrollView] Content to translate
   */
 @get:FloatRange(from = 0.0)
 val maxOffset: Float
     get() = _maxOffset.value.absoluteValue

 /**
   * The current value for [NestedScrollView] Content translate
   */
  @get:FloatRange(from = 0.0)
  val offset: Float
      get() = _offset.value

其中,maxOffset与offset 便于提取和外部使用


4.嵌套滚动

这一块是核心,用于header和content的嵌套滚动,其中最重要的是使用NestedScrollConnection,此接口用于嵌套滚动,其中最重要的四个回调方法,后面解释,先放上代码:

    internal val nestedScrollConnectionHolder = object : NestedScrollConnection 
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset 
            return takeIf 
                available.y < 0 && source == NestedScrollSource.Drag
            ?.let 
                Offset(0f, drag(available.y))
             ?: Offset.Zero
        

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset 
            return takeIf 
                available.y > 0 && source == NestedScrollSource.Drag
            ?.let 
                Offset(0f, drag(available.y))
             ?: Offset.Zero
        

        override suspend fun onPreFling(available: Velocity): Velocity 
            return Velocity(0f, fling(available.y))
        

        override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity 
            return Velocity(0f, fling(available.y))
        
    

    suspend fun snapTo(value: Float) 
        _offset.snapTo(value)
    

    internal suspend fun fling(velocity: Float): Float 
        if (velocity > 0 && offset == 0f) 
            return velocity
        
        return if (velocity < 0) 
            if (offset <= _maxOffset.value) 
                0f
             else 
                _offset.animateDecay(velocity, exponentialDecay()).endState.velocity.let 
                    velocity - it
                
            
         else 
            0f
        
    

    internal fun drag(delta: Float): Float 
        return if (delta < 0 && offset > _maxOffset.value || delta > 0 && offset < 0f) 
            scope.launch 
                snapTo((offset + delta))
            
            delta
         else 
            0f
        
    

接着对接口实现进行解释:

一. onPreScroll与onPostScroll 发生在scroll过程中(手指贴着屏幕滑动),他们都有返回值,而返回值的意义在于告知消费的内容,例如 :滚动了100px,当返回值为Offset(0,0)时,意味着不消费任何内容,返回值为Offset(0,30)时,意味着当前触发滚动的组件外部消费30 ,而滚动组件只能获得70,具体表现就是滑动距离变短了。可以把onPreScroll的返回值过程理解为拦截部分事件,当直接返回Offset(0,available.y)时,意味着事件全拦截,也就是这时list不滚动,我们拿着滚动值对header的offset进行计算,即可实现滚动过程中的嵌套效果

关于scroll的解释就到这里,代码里面有许多是关于下拉上滑,事件源与边界条件的控制,不清楚 为什么这么写的,写一写日志就能明白

2.onPostFling与onPreFling ,这两个回调发生在Fling的过程中也就是手指滑动后离开屏幕的继续滑动过程,返回值为消费速度,同上方对于返回值的解释。

返回0时,指的是不消费任何速度,因此在许多边界情况进行了处理,其中的重中之重是在于向上滑动时,header与list速度的处理,我在这里做的是,满足一定条件时,使header按照当前速度进行运作,并在结束时,获取到结束的速度,消费掉这一部分损失的速度,那么content中的list就能够使用剩余的速度进行滚动,具体代码在上方的_offset.animateDecay()中

最后,附上完整代码:


import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.Constraints

/**
 * Define a [VerticalNestedScrollView].
 *
 * @param state the state object to be used to observe the [VerticalNestedScrollView] state.
 * @param modifier the modifier to apply to this layout.
 * @param content a block which describes the header.
 * @param content a block which describes the content.
 */
@Composable
fun VerticalNestedScrollView(
    modifier: Modifier = Modifier,
    state: NestedScrollViewState,
    header: @Composable () -> Unit = ,
    content: @Composable () -> Unit = ,
) 
    NestedScrollView(
        modifier = modifier,
        state = state,
        orientation = Orientation.Vertical,
        header = header,
        content = content,
    )


/**
 * Define a [HorizontalNestedScrollView].
 *
 * @param state the state object to be used to observe the [HorizontalNestedScrollView] state.
 * @param modifier the modifier to apply to this layout.
 * @param content a block which describes the header.
 * @param content a block which describes the content.
 */
@Composable
fun HorizontalNestedScrollView(
    modifier: Modifier = Modifier,
    state: NestedScrollViewState,
    header: @Composable () -> Unit = ,
    content: @Composable () -> Unit = ,
) 
    NestedScrollView(
        modifier = modifier,
        state = state,
        orientation = Orientation.Horizontal,
        header = header,
        content = content,
    )


@Composable
private fun NestedScrollView(
    modifier: Modifier = Modifier,
    state: NestedScrollViewState,
    orientation: Orientation,
    header: @Composable () -> Unit,
    content: @Composable () -> Unit,
) 
    Layout(
        modifier = modifier
            .scrollable(
                orientation = orientation,
                state = rememberScrollableState 
                    state.drag(it)
                
            )
            .nestedScroll(state.nestedScrollConnectionHolder),
        content = 
            Box 
                header.invoke()
            
            Box 
                content.invoke()
            
        ,
    )  measurables, constraints ->
        layout(constraints.maxWidth, constraints.maxHeight) 
            when (orientation) 
                Orientation.Vertical -> 
                    val headerPlaceable =
                        measurables[0].measure(constraints.copy(maxHeight = Constraints.Infinity))
                    headerPlaceable.place(0, state.offset.toInt())
                    state.updateBounds(-(headerPlaceable.height.toFloat()))
                    val contentPlaceable =
                        measurables[1].measure(constraints.copy(maxHeight = constraints.maxHeight))
                    contentPlaceable.place(
                        0,
                        state.offset.toInt() + headerPlaceable.height
                    )
                
                Orientation.Horizontal -> 
                    val headerPlaceable =
                        measurables[0].measure(constraints.copy(maxWidth = Constraints.Infinity))
                    headerPlaceable.place(state.offset.toInt(), 0)
                    state.updateBounds(-(headerPlaceable.width.toFloat()))
                    val contentPlaceable =
                        measurables[1].measure(constraints.copy(maxWidth = constraints.maxWidth))
                    contentPlaceable.place(
                        state.offset.toInt() + headerPlaceable.width,
                        0,
                    )
                
            
        
    

import android.util.Log
import androidx.annotation.FloatRange
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.animation.core.tween
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.withSign

/**
 * Create a [NestedScrollViewState] that is remembered across compositions.
 */
@Composable
fun rememberNestedScrollViewState(): NestedScrollViewState 
    val scope = rememberCoroutineScope()
    val saver = remember 
        NestedScrollViewState.saver(scope = scope)
    
    return rememberSaveable(
        saver = saver
    ) 
        NestedScrollViewState(scope = scope)
    


/**
 * A state object that can be hoisted to observe scale and translate for [NestedScrollView].
 *
 * In most cases, this will be created via [rememberNestedScrollViewState].
 */
@Stable
class NestedScrollViewState(
    private val scope: CoroutineScope,
    initialOffset: Float = 0f,
    initialMaxOffset: Float = 0f,
) 
    private var _offset = Animatable(initialOffset)
    private val _maxOffset = mutableStateOf(initialMaxOffset)

    companion object 
        fun saver(
            scope: CoroutineScope,
        ): Saver<NestedScrollViewState, *> = listSaver(
            save = 
                listOf(it.offset, it._maxOffset.value)
            ,
            restore = 
                NestedScrollViewState(
                    scope = scope,
                    initialOffset = it[0],
                    initialMaxOffset = it[1],
                )
            
        )
    

    /**
     * The maximum value for [NestedScrollView] Content to translate
     */
    @get:FloatRange(from = 0.0)
    val maxOffset: Float
        get() = _maxOffset.value.absoluteValue

    /**
     * The current value for [NestedScrollView] Content translate
     */
    @get:FloatRange(from = 0.0)
    val offset: Float
        get() = _offset.value

    internal val nestedScrollConnectionHolder = object : NestedScrollConnection 
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset 
            return takeIf 
                available.y < 0 && source == NestedScrollSource.Drag
            ?.let 
                Offset(0f, drag(available.y))
             ?: Offset.Zero
        

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset 
            return takeIf 
                available.y > 0 && source == NestedScrollSource.Drag
            ?.let 
                Offset(0f, drag(available.y))
             ?: Offset.Zero
        

        override suspend fun onPreFling(available: Velocity): Velocity 
            return Velocity(0f, fling(available.y))
        

        override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity 
            return Velocity(0f, fling(available.y))
        
    

    suspend fun snapTo(value: Float) 
        _offset.snapTo(value)
    

    internal suspend fun fling(velocity: Float): Float 
        if (velocity > 0 && offset == 0f) 
            return velocity
        
        return if (velocity < 0) 
            if (offset <= _maxOffset.value) 
                0f
             else 
                _offset.animateDecay(velocity, exponentialDecay()).endState.velocity.let 
                    velocity - it
                
            
         else 
            0f
        
    

    internal fun drag(delta: Float): Float 
        return if (delta < 0 && offset > _maxOffset.value || delta > 0 && offset < 0f) 
            scope.launch 
                snapTo((offset + delta))
            
            delta
         else 
            0f
        
    

    internal fun updateBounds(maxOffset: Float) 
        _maxOffset.value = maxOffset
        _offset.updateBounds(maxOffset, 0f)
    

最后,因为offset可以外部获取以及源码开放,所以 你可以随意定制滚动或者滑动行为,达到类似CoordinatorLayout的各种效果,这里就不再展开了

以上是关于Compose实现 CoordinatorLayout折叠效果的主要内容,如果未能解决你的问题,请参考以下文章

深入理解 Compose Navigation 实现原理

Docker Compose 部署Nginx服务实现负载均衡

一文看懂 Compose Navigation 实现原理

Compose 实现页面侧滑返回

Jetpack Compose 实现波浪加载效果

Compose实现 CoordinatorLayout折叠效果