|
| 1 | +# 使用 Compose 实现下拉刷新控件 |
| 2 | + |
| 3 | +### 定义下拉刷新状态 |
| 4 | +在 Compose 中,状态是基础,所有的 UI 都是对当前状态的一种展示,状态改变驱动 UI 改变。根据之前使用 View 实现下拉刷新的经验,给出状态的定义 |
| 5 | + |
| 6 | +```kotlin |
| 7 | +@Stable |
| 8 | +class NSPtrState( |
| 9 | + val contentInitPosition: Dp = 0.dp,// 初始位置 |
| 10 | + val contentRefreshPosition: Dp = 54.dp,// 刷新位置 |
| 11 | + val pullFriction: Float = 0.56f,// 拖动的摩擦力参数 |
| 12 | + coroutineScope: CoroutineScope, |
| 13 | + onRefresh: (suspend (NSPtrState) -> Unit)? = null, // 触发刷新的回调 |
| 14 | +) { |
| 15 | + ... |
| 16 | + // 当前 content view 所处的位置 |
| 17 | + var contentPositionPx: Float by mutableStateOf(0f) |
| 18 | + |
| 19 | + // 最近一次状态转变的对象 |
| 20 | + var lastTransition: StateMachine.Transition<State, Event, SideEffect>? = null |
| 21 | + |
| 22 | + // 内部用来处理状态转变逻辑的状态机 |
| 23 | + private val _stateMachine = createNSPtrFSM {} |
| 24 | + |
| 25 | + // 当前的下拉刷新状态 |
| 26 | + var state: State by mutableStateOf(_stateMachine.state) |
| 27 | + |
| 28 | + // 触发状态转变事件 |
| 29 | + fun dispatchPtrEvent(event: Event) { |
| 30 | + _stateMachine.transition(event) |
| 31 | + } |
| 32 | + |
| 33 | + // content view 的位移方法 |
| 34 | + private suspend fun animateContentTo( |
| 35 | + value: Float, |
| 36 | + animationSpec: AnimationSpec<Float> = SpringSpec() |
| 37 | + ) { |
| 38 | + // 动画实现 |
| 39 | + } |
| 40 | + ... |
| 41 | +} |
| 42 | + |
| 43 | +``` |
| 44 | + |
| 45 | + |
| 46 | +### 了解 Compose 中实现自定义控件的流程 |
| 47 | +一般自定义 ViewGroup 控件在 View 中的实现步骤分为以下的几个步骤 |
| 48 | + |
| 49 | +1. 继承 ViewGroup |
| 50 | +2. 重写 onMeasure 和 onLayout 方法 |
| 51 | + |
| 52 | +这两个步骤在 Compose 中都有如下的对应 |
| 53 | + |
| 54 | +1. 创建 Composable 方法,并在方法中调用 Layout() 方法 |
| 55 | + |
| 56 | +参照 Android codelabs 的[示例](https://developer.android.com/codelabs/jetpack-compose-layouts#6), 创建 NSPtrLayout 的 Composable 方法 |
| 57 | + |
| 58 | +```kotlin |
| 59 | +@Composable |
| 60 | +fun NSPtrLayout( |
| 61 | + modifier: Modifier, |
| 62 | + content: @Composable () -> Unit |
| 63 | +) { |
| 64 | + ... |
| 65 | + Layout( |
| 66 | + modifier: Modifier = Modifier, |
| 67 | + measurePolicy: MeasurePolicy, |
| 68 | + content: @Composable () -> Unit, |
| 69 | + ) |
| 70 | + ... |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +追踪 Layout 代码块的具体实现 |
| 75 | + |
| 76 | +```kotlin |
| 77 | +@Composable inline fun Layout( |
| 78 | + content: @Composable () -> Unit, // 子控件代码块 |
| 79 | + modifier: Modifier = Modifier, // 布局修饰符 |
| 80 | + measurePolicy: MeasurePolicy // 测量和布局策略符 |
| 81 | +) { |
| 82 | + val density = LocalDensity.current |
| 83 | + val layoutDirection = LocalLayoutDirection.current |
| 84 | + ReusableComposeNode<ComposeUiNode, Applier<Any>>( |
| 85 | + factory = ComposeUiNode.Constructor, |
| 86 | + update = { |
| 87 | + set(measurePolicy, ComposeUiNode.SetMeasurePolicy) |
| 88 | + set(density, ComposeUiNode.SetDensity) |
| 89 | + set(layoutDirection, ComposeUiNode.SetLayoutDirection) |
| 90 | + }, |
| 91 | + skippableUpdate = materializerOf(modifier), |
| 92 | + content = content |
| 93 | + ) |
| 94 | +} |
| 95 | +``` |
| 96 | +获取了当前的像素密度,布局方向。最后用 LayoutNodes 去创建一个树形结构实现 UI。 |
| 97 | +忽略掉底层的实现细节,实现控件的测量和布局的模版方法。 |
| 98 | + |
| 99 | +2. 实现测量和布局 |
| 100 | + |
| 101 | +在 View 的系统中,我们需要重写两个方法,onMeasure 和 onLayout 来实现这个过程。在 Compose 中,我们需要自定义 MeasurePolicy 。 |
| 102 | + |
| 103 | +```kotlin |
| 104 | +internal fun ptrMeasurePolicy() = MeasurePolicy { measurables, constraints -> |
| 105 | + if (measurables.isEmpty()) { |
| 106 | + return@MeasurePolicy layout(constraints.minWidth, constraints.minHeight) {} |
| 107 | + } else { |
| 108 | + val layoutWidth: Int = constraints.maxWidth |
| 109 | + val layoutHeight: Int = constraints.maxHeight |
| 110 | + val placeables = arrayOfNulls<Placeable>(measurables.size) |
| 111 | + |
| 112 | + measurables.forEachIndexed { index, measurable -> |
| 113 | + placeables[index] = measurable.measure(constraints) |
| 114 | + // 测量逻辑 |
| 115 | + } |
| 116 | + |
| 117 | + layout(layoutWidth, layoutHeight) { |
| 118 | + placeables.forEachIndexed { index, placeable -> |
| 119 | + // layout 逻辑 |
| 120 | + placeable.place(x, y) |
| 121 | + } |
| 122 | + } |
| 123 | + } |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +measurables 是所有子节点的约束合集,通过 measure 确定具体的宽高,返回 Placeable。对应 View 的 onMeasure() 流程就执行完了。 |
| 128 | +MeasurePolicy 的 measure 方法需要一个 MeasureResult 的返回值,需要调用 layout 方法。 |
| 129 | +Layout 方法确定子节点的布局位置,返回 MeasureResult |
| 130 | + |
| 131 | +```kotlin |
| 132 | +fun layout( |
| 133 | + width: Int, |
| 134 | + height: Int, |
| 135 | + alignmentLines: Map<AlignmentLine, Int> = emptyMap(), |
| 136 | + placementBlock: Placeable.PlacementScope.() -> Unit |
| 137 | +) = object : MeasureResult { |
| 138 | + override val width = width |
| 139 | + override val height = height |
| 140 | + override val alignmentLines = alignmentLines |
| 141 | + override fun placeChildren() { |
| 142 | + Placeable.PlacementScope.executeWithRtlMirroringValues( |
| 143 | + width, |
| 144 | + layoutDirection, |
| 145 | + placementBlock |
| 146 | + ) |
| 147 | + } |
| 148 | +} |
| 149 | +``` |
| 150 | + |
| 151 | +我们需要自定义下拉控件的位置,需要实现这个 placementBlock 函数,可以拿到之前在 measure 中存储的所有 Placeable 对象,遍历进行布局。 |
| 152 | + |
| 153 | +```kotlin |
| 154 | +layout(layoutWidth, layoutHeight) { |
| 155 | + placeables.forEachIndexed { index, placeable -> |
| 156 | + placeable.place(x, y) |
| 157 | + } |
| 158 | +} |
| 159 | +``` |
| 160 | + |
| 161 | +到这里,基本的 Compose 中的自定义 Layout 的流程就结束了。 |
| 162 | + |
| 163 | +### 手势事件的分发和处理 |
| 164 | + |
| 165 | +首先明确目的,在这里我们只关心 down 事件和 up/cancel 事件,不对手势事件做拦截处理。 |
| 166 | + |
| 167 | +Compose 中的手势事件的源头是 AndroidComposeView 的 dispatchTouchEvent 方法,通过 PointerInputEventProcessor 桥接,最终调用 Modifier 中定义的 PointerInputScope 的 suspend 扩展方法。先不考虑实现原理,实现模版代码。 |
| 168 | + |
| 169 | +```kotlin |
| 170 | +suspend fun PointerInputScope.detectDownAndUp( |
| 171 | + onDown: (Offset) -> Unit, |
| 172 | + onUpOrCancel: (Offset?) -> Unit |
| 173 | +) { |
| 174 | + forEachGesture { |
| 175 | + awaitPointerEventScope { |
| 176 | + // 首次 down 事件触发 |
| 177 | + awaitFirstDown(false).also { |
| 178 | + onDown(it.position) |
| 179 | + } |
| 180 | + |
| 181 | + val up = waitForUpOrCancel() |
| 182 | + // 所有的手指都离开屏幕,最后触发的 up 或者 cancel 事件 |
| 183 | + onUpOrCancel.invoke(up?.position) |
| 184 | + } |
| 185 | + } |
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +这样,我们下拉刷新所需要处理的手势事件就结束了 |
| 190 | + |
| 191 | +### 位移动画的实现 |
| 192 | +Compose 中的 SuspendAnimation.kt 提供了类似 ValueAnimtor 的实现,下面实现了 NSPtrState 中对 content view 位置属性的动画 |
| 193 | + |
| 194 | +```kotlin |
| 195 | +private suspend fun animateContentTo( |
| 196 | + value: Float, |
| 197 | + animationSpec: AnimationSpec<Float> = SpringSpec() |
| 198 | +) { |
| 199 | + var prevValue = 0f // 存储上一个动画的值 |
| 200 | + animate( |
| 201 | + 0f, |
| 202 | + (value - contentPositionPx), |
| 203 | + animationSpec = animationSpec |
| 204 | + ) { currentValue, _ -> |
| 205 | + // 获取差值,加给目标值 |
| 206 | + contentPositionPx += currentValue - prevValue |
| 207 | + prevValue = currentValue |
| 208 | + } |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +本文简单的叙述了下拉刷新控件的实现步骤,后续还需要将这些步骤进行组合,在布局中响应状态中的字段。具体的实现细节可以移步[源码](https://github.com/s1rius/android-nest-scroll-ptr)。 |
0 commit comments