在 Compose 中实现下拉刷新控件

1,004 阅读4分钟

android-nest-scroll-ptr

准备工作

在 android.com 了解 Compose 的基本概念

参考开源的下拉刷新实现

定义下拉刷新状态

在 Compose 中,状态是基础,所有的 UI 都是对当前状态的一种展示,状态改变驱动 UI 改变。根据之前使用 View 实现下拉刷新的经验,给出状态的定义

@Stable
class NSPtrState(
    val contentInitPosition: Dp = 0.dp,// 初始位置
    val contentRefreshPosition: Dp = 54.dp,// 刷新位置
    val pullFriction: Float = 0.56f,// 拖动的摩擦力参数
    coroutineScope: CoroutineScope,
    onRefresh: (suspend (NSPtrState) -> Unit)? = null, // 触发刷新的回调
) {
    ...
    // 当前 content view 所处的位置
    var contentPositionPx: Float by mutableStateOf(0f)

    // 最近一次状态转变的对象
    var lastTransition: StateMachine.Transition<State, Event, SideEffect>? = null

    // 内部用来处理状态转变逻辑的状态机
    private val _stateMachine = createNSPtrFSM {}

    // 当前的下拉刷新状态
    var state: State by mutableStateOf(_stateMachine.state)

    // 触发状态转变事件
    fun dispatchPtrEvent(event: Event) {
        _stateMachine.transition(event)
    }

    // content view 的位移方法
    private suspend fun animateContentTo(
        value: Float,
        animationSpec: AnimationSpec<Float> = SpringSpec()
    ) {
        // 动画实现
    }
    ...
}

实现控件的测量和布局

一般自定义 ViewGroup 控件在 View 中的实现步骤分为以下的几个步骤

  1. 继承 ViewGroup
  2. 重写 onMeasure 和 onLayout 方法

这两个步骤在 Compose 中都有如下的对应

  1. 创建 Composable 方法,并在方法中调用 Layout() 方法

参照 Android codelabs 的示例, 创建 NSPtrLayout 的 Composable 方法

@Composable
fun NSPtrLayout(
    modifier: Modifier,
    content: @Composable () -> Unit
) {
    ...
    Layout(
        modifier: Modifier = Modifier,
        measurePolicy: MeasurePolicy,
        content: @Composable () -> Unit,
    )
    ...
}

追踪 Layout 代码块的具体实现

@Composable inline fun Layout(
    content: @Composable () -> Unit, // 子控件代码块 
    modifier: Modifier = Modifier, // 布局修饰符
    measurePolicy: MeasurePolicy // 测量和布局策略符
) {
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = ComposeUiNode.Constructor,
        update = {
            set(measurePolicy, ComposeUiNode.SetMeasurePolicy)
            set(density, ComposeUiNode.SetDensity)
            set(layoutDirection, ComposeUiNode.SetLayoutDirection)
        },
        skippableUpdate = materializerOf(modifier),
        content = content
    )
}

获取了当前的像素密度,布局方向。最后用 LayoutNodes 去创建一个树形结构实现 UI。 忽略掉底层的实现细节,实现控件的测量和布局的模版方法。

  1. 实现测量和布局

在 View 的系统中,我们需要重写两个方法,onMeasure 和 onLayout 来实现这个过程。在 Compose 中,我们需要自定义 MeasurePolicy 。

internal fun ptrMeasurePolicy() = MeasurePolicy { measurables, constraints ->
    if (measurables.isEmpty()) {
        return@MeasurePolicy layout(constraints.minWidth, constraints.minHeight) {}
    } else {
        val layoutWidth: Int = constraints.maxWidth
        val layoutHeight: Int = constraints.maxHeight
        val placeables = arrayOfNulls<Placeable>(measurables.size)

        measurables.forEachIndexed { index, measurable ->
            // 测量逻辑
            placeables[index] = measurable.measure(constraints)
        }

        layout(layoutWidth, layoutHeight) {
            placeables.forEachIndexed { index, placeable ->
                // 布局逻辑
                placeable.place(x, y)
            }
        }
    }
}

measurables 是所有子节点的约束合集,通过 measure 确定具体的宽高,返回 Placeable。对应 View 的 onMeasure() 流程就执行完了。 MeasurePolicy 的 measure 方法需要一个 MeasureResult 的返回值,需要调用 layout 方法。 Layout 方法确定子节点的布局位置,返回 MeasureResult

fun layout(
    width: Int,
    height: Int,
    alignmentLines: Map<AlignmentLine, Int> = emptyMap(),
    placementBlock: Placeable.PlacementScope.() -> Unit
) = object : MeasureResult {
    override val width = width
    override val height = height
    override val alignmentLines = alignmentLines
    override fun placeChildren() {
        Placeable.PlacementScope.executeWithRtlMirroringValues(
            width,
            layoutDirection,
            placementBlock
        )
    }
}

我们需要自定义下拉控件的位置,需要实现这个 placementBlock 函数,可以拿到之前在 measure 中存储的所有 Placeable 对象,遍历进行布局。

layout(layoutWidth, layoutHeight) {
    placeables.forEachIndexed { index, placeable ->
        // 设置将当前的节点的 x, y
        placeable.place(x, y)
    }
}

基本的 Compose 中的自定义控件的实现流程就结束了。

在这里我们还需要一些自定的参数,比如确定下拉滑动的具体子节点。那我们就需要自己定一个布局的 Scope,模仿 Box 的实现方式。

// 定义接口
interface NSPtrScope {

    // 这样我们就可以在子节点上使用这个 modify 来标记它是主要的节点
    @Stable
    fun Modifier.ptrContent(): Modifier
}

// 具体实现
internal object NSPtrScopeInstance : NSPtrScope {

    override fun Modifier.ptrContent() = this.then(
        NSPtrChildData(PtrComponent.PtrContent)
    )

    override fun Modifier.ptrHeader() = this.then(
        NSPtrChildData(PtrComponent.PtrHeader)
    )
}

// 更新 Composable 方法参数, 定义为 NSPtrScope 的扩展函数,这样我们就可以在写子节点时调用了
@Composable
fun NSPtrLayout(
    ...
    content: @Composable NSPtrScope.() -> Unit
) {
    Layout(
        content = { NSPtrScopeInstance.content() },
        ...
    )
}

手势事件的分发和处理

首先明确目的,在这里我们只关心 down 事件和 up/cancel 事件,不对手势事件做拦截处理。

Compose 中的手势事件的源头是 AndroidComposeView 的 dispatchTouchEvent 方法,通过 PointerInputEventProcessor 桥接,最终调用 Modifier 中定义的 PointerInputScope 的 suspend 扩展方法。先不考虑实现原理,实现模版代码。

suspend fun PointerInputScope.detectDownAndUp(
    onDown: (Offset) -> Unit,
    onUpOrCancel: (Offset?) -> Unit
) {
    forEachGesture {
        awaitPointerEventScope {
            // 首次 down 事件触发
            awaitFirstDown(false).also {
                onDown(it.position)
            }

            val up = waitForUpOrCancel()
            // 所有的手指都离开屏幕,最后触发的 up 或者 cancel 事件
            onUpOrCancel.invoke(up?.position)
        }
    }
}

位移动画的实现

Compose 中的 SuspendAnimation.kt 提供了类似 ValueAnimtor 的实现,下面实现了 NSPtrState 中对 content view 位置属性的动画。

private suspend fun animateContentTo(
        value: Float,
        animationSpec: AnimationSpec<Float> = SpringSpec()
) {
    var prevValue = 0f // 存储上一个动画的值
    animate(
        0f,
        (value - contentPositionPx),
        animationSpec = animationSpec
    ) { currentValue, _ ->
        // 获取差值,加给目标值
        contentPositionPx += currentValue - prevValue
        prevValue = currentValue
    }
}

嵌套滑动

Compose 中嵌套滑动的 API 相比 View 中的简化了很多,原理都差不多,这里我们把 View 里实现的逻辑拷贝过来。

最后

本文简单的分段叙述了下拉刷新控件的实现步骤,后续还需要将这些步骤进行组合。具体的实现细节可以移步源码