Jetpack Compose中的手势操作和事件处理

高级事件处理 API

Compose 中的手势操作或事件处理全部都是以 Modifier 修饰符的形式提供的,事件处理按照层次可以划分为高级事件处理API和低级事件处理API。

其中高级事件处理API是位于更上层的API,它们都是基于更底层的低级事件处理API实现的,也是开发中比较常用的。高级事件处理API的分类大概如下图所示:

Jetpack Compose中的手势操作和事件处理_第1张图片

点击事件

监听点击事件非常简单,使用 clickablecombinedClickable 修饰符即可满足需求:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableExample() {
    Column{
        Box(Modifier
            .clickable { println("clickable") }
            .size(30.dp)
            .background(Color.Red))
        Box(Modifier
            .size(50.dp)
            .background(Color.Blue)
            .combinedClickable(
                onLongClick = { println("combinedClickable --> onLongClick") },
                onDoubleClick = { println("combinedClickable --> onDoubleClick") },
                onClick = { println("combinedClickable --> onClick") }
            ))
    }
}

当点击事件发生时会为被点击的组件施加一个水波纹效果动画的蒙层,这是Material Design中的默认效果,如果不希望点击时有这个效果,可以使用低级别的Api detectTapGestures

另外, clickablecombinedClickable 可以传入一个 enable 参数作为一个可变状态,可以通过该状态来动态控制是否启用点击监听。

Draggable拖动

Draggable可以监听拖动手势偏移量,然后可以根据偏移量定制UI拖动交换效果。但是值得注意的是,Draggable修饰符只支持监听水平方向或垂直方向的偏移,如希望监听任意方向,则可以使用detectDragGestures方法。

使用Draggable至少需要传入2个参数 draggableStateorientation

  • draggableState: 通过它可以获取到拖动手势的偏移量,并且也允许我们动态控制发生偏移行为
  • orientation:监听拖动的方向,只能是水平或垂直

如下代码实现一个简单的滑块拖动效果

@Composable
fun DraggableExample() {
    var offsetX by remember { mutableStateOf(0f) }
    val boxSlideSize = 50.dp
    val maxLengthPx = with(LocalContext.current) {
        resources.displayMetrics.widthPixels - boxSlideSize.toPx()
    }
    // 创建并获取一个DraggableState实例
    val draggableState = rememberDraggableState {
        // 使用回调方法回传的参数对状态偏移量进行累加,并限制范围
        offsetX = (offsetX + it).coerceIn(0f, maxLengthPx)
    }
    Box(
        Modifier
            .fillMaxWidth()
            .height(boxSlideSize)
            .background(Color.LightGray)
    ) {
        Box(
            Modifier
                .size(boxSlideSize)
                .offset { IntOffset(offsetX.roundToInt(), 0) }
                .draggable(
                    orientation = Orientation.Horizontal,
                    state = draggableState
                )
                .background(Color.Red)
        )
    }
}

由于Modifier是链式执行的,因此这里offset修饰符应该放在draggablebackground之前。

运行效果:

Jetpack Compose中的手势操作和事件处理_第2张图片

错误示例1(draggable在offset前面):第二次拖动时UI控件拖动只能拖动初始位置才生效,不会跟随UI控件而移动监听,原因是每次拖动时draggable都监听的都是初始位置,不是偏移后位置。
错误示例2(background在offset前面):UI控件不会跟手,原因在于每次绘制时background都在初始位置绘制,不是偏移后位置。

另外,draggable 还有几个参数:

  • enabled:是否启用拖拽,方便动态控制,默认值是true。
  • interactionSource:可以用来收集拖拽的状态,默认值是null。
  • startDragImmediately:是否立即开始拖动,默认值是false,如果设为true可防止其他手势检测器对“down”事件做出反应。这是为了允许最终用户通过按下操作来“捕获”动画组件。当你拖动的值正在设定/动画时,设置它很有用。
  • onDragStarted:一个挂起函数,开始拖动时回调。
  • onDragStopped:一个挂起函数,停止拖动时回调。
  • reverseDirection:是否反方向执行拖拽效果,默认值是false。

其中interactionSource 可以这样使用:

val interactionSource = remember{ MutableInteractionSource() }
Box(
    Modifier 
        .draggable(
            orientation = Orientation.Horizontal,
            state = draggableState,
            interactionSource = interactionSource, 
        ).background(Color.Red)
)
val isDragged by interactionSource.collectIsDraggedAsState()
Text(if(isDragged) "正在拖动" else "静止")

Swipeable滑动

使用方式跟Draggable差不多,但是Swipeable可以通过锚点设置吸附效果。

使用Swipeable至少需要传入4个参数:

  • State: 手势状态,通过它可以实时获取当前手势的偏移信息
  • Anchors: 锚点,用于记录不同状态对应数值的映射关系
  • Orientation: 手势方向,只支持水平或垂直
  • thresholds: 不同锚点之间吸附效果的临界阈值,常用的阈值有FixedThreshold(Dp)FractionalThreshold(Float)两种

以下代码使用Swipeable创建一个简单的开关效果:

enum class Status{ CLOSE, OPEN } // 定义两个枚举项表示开关状态

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeableDemo() {
    val blockSize = 48.dp
    val blockSizePx = blockSize.toPx()
    // 创建并获取一个SwipeableState实例
    val swipeableState = rememberSwipeableState(initialValue = Status.CLOSE)
    // 定义锚点,锚点以Pair表示,每个状态对应一个锚点
    val anchors = mapOf(
        0f to Status.CLOSE,
        blockSizePx*2 to Status.OPEN
    )
    Box(
        Modifier
            .size(height = blockSize, width = blockSize * 3)
            .clip(RoundedCornerShape(50))
            .background(Color.Gray)
    ) {
        Box(
            Modifier
                .offset { IntOffset(swipeableState.offset.value.toInt(), 0) }
                .swipeable(
                    state = swipeableState,
                    anchors = anchors,
                    thresholds = { from, to ->
                    	// 从关闭到开启状态时,滑块移动超过30%距离自动吸附到开启状态
                        if (from == Status.CLOSE) { 
                            FractionalThreshold(0.3f)
                        } else { // 从开启状态到关闭状态时,滑块移动超过50%才会自动吸附到关闭状态
                            FractionalThreshold(0.5f)
                        }
                    },
                    orientation = Orientation.Horizontal
                )
                .size(blockSize)
                .clip(RoundedCornerShape(50))
                .background(Color.Red)
        )
    }
}

由于Modifier是链式执行的,因此这里swipeable修饰符应该放在draggablebackground之前。

运行效果:

Jetpack Compose中的手势操作和事件处理_第3张图片

注意:Modifier.swipeable 这个修饰符在compose.material中可以正常使用,而在compose.material3库中被隐藏了。但是Cmpose提供了一个 SwipeToDismiss 这个Composable组件来专门做滑动删除的效果。

transformable多点触控

transformable修饰符可以监听双指拖动、缩放或旋转手势

@Composable
fun TransformableExample() {
    val boxSize = 200.dp
    var offset by remember { mutableStateOf(Offset.Zero) }
    var rotationAngle by remember { mutableStateOf(0f) }
    var scale by remember { mutableStateOf(1f) }
    // 创建并获取一个TransformableState实例
    val transformableState = rememberTransformableState { 
    	zoomChange: Float, panChange: Offset, rotationChange: Float ->
        scale *= zoomChange
        offset += panChange
        rotationAngle += rotationChange
    }
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Image(
            painter = painterResource(id = R.drawable.ic_sky),
            contentScale = ContentScale.Crop,
            contentDescription = null,
            modifier = Modifier
                .size(boxSize)
                .rotate(rotationAngle) // 注意rotate的顺序应该先于offset
                .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
                .scale(scale)
                .transformable(
                    state = transformableState,
                    // 该值为true时,发生双指拖动或缩放时,不会同时监听旋转手势信息
                    lockRotationOnZoomPan = true 
                )
        )
    }
}

这里注意rotate的顺序应该先于offset,如果先调用了offset再调用rotate, 则组件会先偏移再旋转,这会导致组件最终位置不可预期。

运行效果:

Scrollable滚动

主要用于列表场景,结合LazyColumnLazyRow来使用

horizontalScroll水平滚动

horizontalScroll主要结合Row组件来使用,使其支持水平滚动,horizontalScroll只需要传入一个 scrollState 即可。我们可以使用 rememberScrollState 快速创建一个 scrollState 实例并传入即可。

@Composable
fun HorizontalScrollExample() {
    val scrollState = rememberScrollState()
    Row(
        Modifier
            .padding(10.dp)
            .border(BorderStroke(1.dp, Color.Blue))
            .height(50.dp)
            .horizontalScroll(scrollState)
    ) {
        repeat(50) {
            Text("item $it", Modifier.padding(10.dp))
            Divider(Modifier.width(1.dp).fillMaxHeight())
        }
    }
}

verticalScrollhorizontalScroll使用类似,主要结合Column组件使用。

@Composable
fun VerticalScrollExample() {
    val scrollState = rememberScrollState()
    Column(
        Modifier
            .height(300.dp)
            .verticalScroll(scrollState)
    ) {
        repeat(50) {
            Text("item $it", Modifier.padding(10.dp))
            Divider()
        }
    }
}

verticalScrollhorizontalScroll 这两个修饰符除了在RowColumn组件上使用外,在其他组件上也可以应用,如 Box 组件等。

低级别scrollable修饰符

horizontalScrollverticalScroll都是基于scrollable实现的, scrollable修饰符除了传入一个scrollState外,还需要传入Orientation(水平或垂直)

以下代码通过 scrollable 修饰符的滚动监听能力,自己来定制实现类似 horizontalScroll 修饰符的功能:

@Composable
fun ScrollableExample1() {
    Column(Modifier.padding(10.dp)) {
        val scrollState = rememberScrollState()
        Row(
            Modifier
                .border(BorderStroke(1.dp, Color.Blue))
                .height(50.dp)
                .offset(x = -scrollState.value.toDp()) // 滚动位置增大时应该向左偏移,所以这里设为负数
                .scrollable(scrollState, Orientation.Horizontal, reverseDirection = true)
        ) {
            repeat(50) {
                Text("item $it", Modifier.padding(10.dp))
                Divider(Modifier.width(1.dp).fillMaxHeight())
            }
        }
        Text(text = "scrollState.value: ${scrollState.value}")
    }
}

注意: scrollable的滚动位置范围为0~MAX_VALUE, 默认当手指在组件上向右滑动时,滚动位置会增大向左滑动时,滚动位置会减小,直到减小到0。 由于滚动位置默认初始值为0,所以默认我们只能向右滑来增大滚动位置。如果将reverseDirection参数设置为true时,那么此时手指向左滑滚动位置会增大,向右滑滚动位置会减小。

因此这里将reverseDirection设为true允许我们从初始位置向左滑以查看Row组件右侧超出屏幕的内容部分。

补充提示: 在使用 rememberScrollState 创建 ScrollState 实例时我们是可以通过 initial 参数来指定组件初始滚动位置的

class ScrollState(initial: Int) : ScrollableState {
  var value: Int by mutableStateOf(initial, structuralEqualityPolicy())
  	private set
  suspend fun animateScrollTo(...)
  suspend fun scrollTo(...)
  ...
}

上面的代码运行后我们会发现,当进行左滑时,原本位于屏幕外的内容进入屏幕时右边出现一片空白,这是因为Row组件的默认测量策略导致超出屏幕的子组件宽度测量结果为零。

Jetpack Compose中的手势操作和事件处理_第4张图片

此时需要使用layout修饰符来自定义布局,我们需要创建一个新的约束,用于测量组件的真实宽度,主动设置组件应有的宽高尺寸,并根据组件的滚动偏移量来摆放组件内容。

@Composable
fun ScrollableExample2() {
    Column(Modifier.padding(10.dp)) {
        val scrollState = rememberScrollState()
        Row(
            Modifier
                .border(BorderStroke(1.dp, Color.Blue))
                .height(50.dp)
                .clipScrollableContainer(Orientation.Horizontal) // 留出父组件设置的padding空间
                .scrollable(scrollState, Orientation.Horizontal, reverseDirection = true)
                .layout { measurable, constraints ->
                    println("constraints: $constraints")
                    // 约束中默认最大宽度为父组件所允许的最大宽度,此处为屏幕宽度
                    // 将最大宽度设置为无限大
                    val childConstraints = constraints.copy(
                        maxWidth = Constraints.Infinity
                    )
                    println("childConstraints: $childConstraints")
                    val placeable = measurable.measure(childConstraints) // 使用新的约束进行组件测量
                    // 计算 当前组件宽度 与 父组件所允许的最大宽度 中取一个最小值
                    // 如果组件超出屏幕,此时width为屏幕宽度。如果没有超出,则为组件本身宽度
                    val width = placeable.width.coerceAtMost(constraints.maxWidth)
                    // 计算 当前组件高度 与 父组件所允许的最大高度 中取一个最小值
                    val height = placeable.height.coerceAtMost(constraints.maxHeight)
                    val scrollDistance = placeable.width - width // 计算可滚动的最大距离
                    layout(width, height) { // 主动设置组件的宽高
                        // 根据可滚动的最大距离来计算当前的滚动位置
                        val scroll = scrollState.value.coerceIn(0, scrollDistance)
                        val offsetX = -scroll // 根据滚动位置向左偏移
                        placeable.placeRelativeWithLayer(offsetX, 0) // 摆放组件内容
                    }

                }
        ) {
            repeat(50) {
                Text("item $it", Modifier.padding(10.dp))
                Divider(Modifier.width(1.dp).fillMaxHeight())
            }
        }
        Text(text = "scrollState.value: ${scrollState.value}")
    }
}

运行效果:

Jetpack Compose中的手势操作和事件处理_第5张图片

nestedScroll 嵌套滑动

nestedScroll 修饰符对标Android传统原生View体系中的NestedScrollView组件,主要用于处理嵌套滑动的场景,为父布局劫持消费子布局滑动手势提供了可能。

使用 nestedScroll 参数列表中有一个必选参数 connection 和一个可选参数 dispatcher

  • connection:嵌套滑动手势处理的核心逻辑,内部回调可以在子布局获得滑动事件前预先消费掉部分或全部手势偏移量,也可以获取子布局消费后剩下的手势偏移量。
  • dispatcher:调度器,内部包含用于父布局的 NestedScrollConnection , 可以调用 dispatch* 系列方法来通知父布局发生滑动
fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null
)

NestedScrollConnection提供了四个回调方法。

方法 说明 参数 返回值
onPreScroll 可以预先劫持滑动事件,消费后再交由子布局 available:当前可用的滑动事件偏移量
source:滑动事件的类型
当前组件消费的滑动事件偏移量,如果不想消费可返回Offset.Zero
onPostScroll 可以获取子布局处理后剩下的滑动事件 consumed:之前被消费的所有滑动事件偏移量
available:当前剩下还可用的滑动事件偏移量
source:滑动事件的类型
当前组件消费的滑动事件偏移量,如果不想消费可返回 Offset.Zero ,则剩下偏移量会继续交由当前布局的父布局进行处理
onPreFling 获取 Fling 动作开始时的速度 available:Fling 开始时的速度 当前组件消费的速度,如果不想消费可返回 Velocity.Zero
onPostFling 获取 Fling 动作结束时的速度 consumed:之前消费的所有速度
available:当前剩下还可用的速度
当前组件消费的速度,如果不想消费可返回Velocity.Zero,剩下速度会继续交由当前布局的父布局进行处理

Fling含义:当我们手指在滑动列表时,如果是快速滑动并抬起,则列表会根据惯性继续飘一段距离后停下,这个行为就是 Fling 惯性滑动,onPreFling 在你手指刚抬起时便会回调,而 onPostFling 会在飘一段距离停下后回调。

使用nestedScroll实现下拉刷新

效果:
Jetpack Compose中的手势操作和事件处理_第6张图片

在这个示例中存在着加载动画和列表数据。当我们手指向下滑时,此时如果列表顶部没有数据则会逐渐出现加载动画。与之相反,当我们手指向上滑时,此时如果加载动画还在,则加载动画逐渐向上消失,直到加载动画完全消失后,列表才会被向上滑动。

为实现这个滑动刷新的需求,我们可以设计如下方案。我们首先需要将加载动画和列表数据放到一个父布局中统一管理。

  1. 当我们手指向下滑时,我们希望滑动手势首先交给子布局中的列表进行处理,如果列表已经滑到顶部说明此时滑动手势事件没有被消费,此时再交由父布局进行消费。父布局可以消费列表消费剩下的滑动手势事件(增大加载指示器的偏移量)。
  2. 当我们手指向上滑时,我们希望滑动手势首先被父布局消费(为加载动画减小偏移),如果加载动画本身仍未出现时,则不进行消费。然后将剩下的滑动手势交给子布局列表进行消费。

使用 nestedScroll 修饰符最重要的就是根据自己的业务场景来定制 NestedScrollConnection 的实现,接下来我们就逐个分析 NestedScrollConnection 中的每个接口该如何进行实现。

实现 onPostScroll

当我们手指向下滑时,我们希望滑动手势首先交给子布局中的列表进行处理,如果列表已经滑到顶部说明此时滑动手势事件没有被消费,此时再交由父布局进行消费。 onPostScroll 回调时机正好符合当前的需求。

首先需要判断该滑动事件是不是拖动事件,通过 available.y > 0 判断是否是下滑手势,如果都没问题时,通知加载动画增加偏移量。返回值 Offset(x = 0f, y = available.y) 意味着将剩下的所有偏移量全部消费调,不再向外层父布局继续传播了。

override fun onPostScroll(
    consumed: Offset,
    available: Offset,
    source: NestedScrollSource
): Offset {
    if (source == NestedScrollSource.Drag && available.y > 0) {
        state.updateOffsetDelta(available.y)
        return Offset(x = 0f, y = available.y)
    } else {
        return Offset.Zero
    }
}

实现 onPreScroll

与上面相反,此时我们希望上滑收回加载动画,当我们手指向上滑时,我们希望滑动手势首先被父布局消费(减小加载指示器的偏移量),如果加载指示器还未出现,则不需要进行消费。剩余的滑动手势事件会交给子布局列表继续进行消费。onPreScroll 回调时机正好符合这个需求。

首先仍需要判断该滑动事件是不是拖动事件,通过 available.y < 0 判断是否是上滑手势。此时可能加载指示器还未出现,所以需要额外进行判断。如果未出现,则返回 Offset.Zero 不消费,如果出现了则返回 Offset(x = 0f, y = available.y) 进行消费。

override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
    if (source == NestedScrollSource.Drag && available.y < 0) {
        state.updateOffsetDelta(available.y)
        return if (state.isSwipeInProgress) Offset(x = 0f, y = available.y) else Offset.Zero
    } else {
        return Offset.Zero
    }
}

实现 onPreFling

接下来,我们需要一个松手时的吸附效果。如果加载指示器已经被拖动并超过一半,则应该吸附到加载状态,否则就收缩回初始状态。onPreFling 会在松手时发生惯性滑动前回调,符合我们当前这个的场景需求。

注意:即使你松手时速度很慢或静止,onPreFling 与 onPostFling都会回调,只是速度数值很小。

这里我们只需要吸引效果,并不希望消费速度,所以返回 Velocity.Zero 即可

override suspend fun onPreFling(available: Velocity): Velocity {
    if (state.indicatorOffset > height / 2) {
        state.animateToOffset(height)
        state.isRefreshing = true
    } else {
        state.animateToOffset(0.dp)
    }
    return Velocity.Zero
}

完整源码:

import android.util.Log
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow

class SmartSwipeRefreshState {
    private val mutatorMutex = MutatorMutex()

    private val indicatorOffsetAnimatable = Animatable(0.dp, Dp.VectorConverter)
    val indicatorOffset get() = indicatorOffsetAnimatable.value

    private val _indicatorOffsetFlow  = MutableStateFlow(0f)
    val indicatorOffsetFlow: Flow<Float> get() = _indicatorOffsetFlow

    val isSwipeInProgress by derivedStateOf { indicatorOffset != 0.dp }

    var isRefreshing: Boolean by mutableStateOf(false)

    fun updateOffsetDelta(value: Float) {
        _indicatorOffsetFlow.value = value
    }

    suspend fun snapToOffset(value: Dp) {
        mutatorMutex.mutate(MutatePriority.UserInput) {
            indicatorOffsetAnimatable.snapTo(value)
        }
    }

    suspend fun animateToOffset(value: Dp) {
        mutatorMutex.mutate {
            indicatorOffsetAnimatable.animateTo(value, tween(1000))
        }
    }
}

private class SwipeRefreshNestedScrollConnection(
    val state: SmartSwipeRefreshState,
    val height: Dp
): NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        Log.d("NestedScrollConnection", "onPreScroll")
        // 上滑时,我们希望滑动手势首先被父布局消费(减小加载指示器的偏移量),
        // 如果加载指示器还未出现,则不需要进行消费。剩余的滑动手势事件会交给子布局列表继续进行消费。
        return if (source == NestedScrollSource.Drag && available.y < 0) {
            state.updateOffsetDelta(available.y)
            if (state.isSwipeInProgress) Offset(x = 0f, y = available.y) else Offset.Zero
        } else {
            Offset.Zero
        }
    }

    override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
        Log.d("NestedScrollConnection", "onPostScroll")
        // 指向下滑时,我们希望滑动手势首先交给子布局中的列表进行处理,
        // 如果列表已经滑到顶部说明此时滑动手势事件没有被消费,此时再交由父布局进行消费。
        return if (source == NestedScrollSource.Drag && available.y > 0) {
            state.updateOffsetDelta(available.y)
            Offset(x = 0f, y = available.y)
        } else {
            Offset.Zero
        }
    }

    override suspend fun onPreFling(available: Velocity): Velocity {
        Log.d("NestedScrollConnection", "onPreFling")
        // 如果加载指示器已经被拖动并超过一半,则应该吸附到加载状态,否则就收缩回初始状态。
        if (state.indicatorOffset > height / 2) {
            state.animateToOffset(height)
            state.isRefreshing = true
        } else {
            state.animateToOffset(0.dp)
            state.isRefreshing = false
        }
        return super.onPreFling(available)
    }

    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        Log.d("NestedScrollConnection", "onPostFling")
        if (available.y <= 0 && state.indicatorOffset <= 0.dp && state.isRefreshing) {
            state.isRefreshing = false
        }
        return super.onPostFling(consumed, available)
    }
}

@Composable
private fun SubcomposeSmartSwipeRefresh(
    indicator: @Composable () -> Unit,
    content: @Composable (Dp) -> Unit
) {
    SubcomposeLayout { constraints: Constraints ->
        val indicatorPlaceable = subcompose("indicator", indicator).first().measure(constraints)
        val contentPlaceable = subcompose("content") {
            content(indicatorPlaceable.height.toDp())
        }.map {
            it.measure(constraints)
        }.first()
        Log.d("SmartSwipeRefresh","dp: ${indicatorPlaceable.height.toDp()}")
        layout(contentPlaceable.width, contentPlaceable.height) {
            contentPlaceable.placeRelative(0, 0)
        }
    }
}

/**
 * A smart refresh component can customize your slide refresh animation component.
 * @param onRefresh: Refreshing behavior of data when sliding down.
 * @param state: The state contains some refresh state info.
 * @param loadingIndicator: Specify the refresh animation component.
 * @param content: Some slidable components need to be included here.
 */
@Composable
fun SmartSwipeRefresh(
    onRefresh: suspend () -> Unit,
    state: SmartSwipeRefreshState = remember { SmartSwipeRefreshState() },
    loadingIndicator: @Composable () -> Unit = { CircularProgressIndicator() },
    content: @Composable () -> Unit
) {
    SubcomposeSmartSwipeRefresh(indicator = loadingIndicator) { height ->
        val connection = remember(state, height) {
            SwipeRefreshNestedScrollConnection(state, height)
        }
        Box(
            Modifier.nestedScroll(connection),
            contentAlignment = Alignment.TopCenter
        ) {
            Box(Modifier.offset(y = -height + state.indicatorOffset)) {
                loadingIndicator()
            }
            Box(Modifier.offset(y = state.indicatorOffset)) {
                content()
            }
        }
        val density = LocalDensity.current
        LaunchedEffect(Unit) {
            state.indicatorOffsetFlow.collect {
                val currentOffset = with(density) { state.indicatorOffset + it.toDp() }
                state.snapToOffset(currentOffset.coerceAtLeast(0.dp).coerceAtMost(height))
            }
        }
        LaunchedEffect(state.isRefreshing) {
            if (state.isRefreshing) {
                onRefresh()
                state.animateToOffset(0.dp)
                state.isRefreshing = false
            }
        }
    }
}

使用:

@Composable
fun NestedScrollExample() {
    var list by remember { mutableStateOf(('A'..'Z').toList()) }
    val lazyListState =  rememberLazyListState()
    SmartSwipeRefresh(
        onRefresh = {
            delay(1000)
            list = list.shuffled()
            lazyListState.scrollToItem(0)
        }
    ) {
        LazyColumn(
            contentPadding = PaddingValues(15.dp),
            verticalArrangement = Arrangement.spacedBy(10.dp),
            state = lazyListState
        ) {
            items(list, key = { it }) { item ->
                Card(
                    elevation = 8.dp,
                    modifier = Modifier.fillMaxWidth().height(100.dp),
                ) {
                    Box(
                        Modifier.fillMaxSize().padding(15.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(text = "Item $item", fontSize = 20.sp)
                    }
                }
            }

        }
    }
}

低级事件处理 API

以上介绍的所有高级事件处理 API 都是基于低级别的事件处理 API 实现的,下面是 Compose 中的低级别的事件处理 API 的分类概图:

Jetpack Compose中的手势操作和事件处理_第7张图片

PointerInput

前面 Draggable 修饰符、Swipeable 修饰符、Transformable 修饰符以及 NestedScroll 修饰符都是基于低级别的 PointerInput 修饰符进行封装实现的,所以弄清楚 PointerInput 修饰符的使用方法,有助于我们对高级别手势处理修饰符的理解,并且能够帮助我们更好的完成上层开发实现各种复杂的手势需求。

fun Modifier.pointerInput(
    vararg keys: Any?,
    block: suspend PointerInputScope.() -> Unit
): Modifier = composed(
    ...
) {
    ...
    remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.apply {
        LaunchedEffect(this, *keys) {
            block()
        }
    }
}

使用 PointerInput 修饰符时我们需要传入两个参数,keysblock

  • keys:当 Composable 组件发生重组时,如果传入的 keys 发生了变化,则手势事件处理过程会被中断。
  • block:在这个 PointerInputScope 类型作用域代码块中,便可以声明手势事件处理逻辑了。通过 suspend 关键字可知这是一个挂起函数,这意味着在 Compose 中手势处理最终都发生在协程中。

我们在 PointerInputScope 接口声明中能够找到所有可用的手势处理方法,我们可以通过这些方法获取到更加详细的手势信息以及更加细粒度的手势事件处理。

拖动类型基础 API

API名称 作用
detectDragGestures 监听拖动手势
detectDragGesturesAfterLongPress 监听长按后的拖动手势
detectHorizontalDragGestures 监听水平拖动手势
detectVerticalDragGestures 监听垂直拖动手势

这类拖动监听 API 功能上相类似,使用时需要传入参数也比较相近。我们可以根据实际情况来选用不同 API。在使用这些 API 时,我们可以定制在不同时机的处理回调,以 detectDragGestures 为例:

suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)

这里提供了4个回调时机,onDragStart 会在拖动开始时回调,onDragEnd 会在拖动结束时回调,onDragCancel 会在拖动取消时回调,而 onDrag 则会在拖动真正发生时回调。

注意:onDragCancel 触发时机多发生于滑动冲突的场景,子组件可能最开始是可以获取到拖动事件的,当拖动手势事件达到莫个指定条件时可能会被父组件劫持消费,这种场景下便会执行 onDragCancel 回调。所以 onDragCancel 回调主要依赖于实际业务逻辑。

我们可以利用 detectDragGestures 轻松的实现拖动手势监听:

@Composable
fun DragGestureDemo() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    var dragState by remember { mutableStateOf("") }
    Box(contentAlignment = Alignment.Center,
        modifier = Modifier.fillMaxSize()
    ) {
        Box(Modifier
            .size(100.dp)
            .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
            .background(Color.Green)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { offset ->
                        dragState = "拖动开始了~"
                    },
                    onDragEnd = {
                        dragState = "拖动结束了~"
                    },
                    onDragCancel = {
                        dragState = "拖动取消了~"
                    },
                    onDrag = { change: PointerInputChange, dragAmount: Offset ->
                        dragState = "拖动中~$dragAmount"
                        offset += dragAmount
                    }
                )
            }
        )
        Text(text = dragState,
            Modifier.align(Alignment.TopCenter).padding(top = 35.dp),
            fontSize = 22.sp
        )
    }
}

运行效果:
Jetpack Compose中的手势操作和事件处理_第8张图片

点击类型基础 API

API名称 作用
detectTapGestures 监听点击手势

PointerInputScope 中,我们可以使用 detectTapGestures 设置更细粒度的点击监听回调。作为低级别点击监听 API,在发生点击时不会带有Clickable 修饰符与 CombinedClickable 修饰符那样会为所修饰的组件施加一个涟漪波纹效果动画的蒙层,我们能够根据需要进行更灵活的上层定制。

detectTapGestures 提供了四个可选事件回调,可以根据需求来设置不同点击事件回调。

suspend fun PointerInputScope.detectTapGestures(
  onDoubleTap: ((Offset) -> Unit)? = null, // 双击时回调
  onLongPress: ((Offset) -> Unit)? = null, // 长按时回调
  onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture, // 按下时回调
  onTap: ((Offset) -> Unit)? = null // 轻触时回调
)

这几种点击事件回调存在着先后次序的,并不是每次只会执行其中一个。onPress 是最普通的 ACTION_DOWN 事件,你的手指一旦按下便会回调。如果你连着按了两下,则会在执行两次 onPress 后执行 onDoubleTap。如果你的手指按下后不抬起,当达到长按的判定阈值 (400ms) 会执行 onLongPress。如果你的手指按下后快速抬起,在轻触的判定阈值内(100ms)会执行 onTap 回调。

总的来说, onDoubleTap 回调前必定会先回调 2onPress,而 onLongPressonTap 回调前必定会回调 1onPress

detectTapGestures 使用起来非常简单,我们根据需求来设置不同点击事件回调即可。

@Composable
fun TapGestureDemo() {
    var dragState by remember { mutableStateOf("") }
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Box(Modifier
            .size(100.dp)
            .background(Color.Green)
            .pointerInput(Unit) {
                detectTapGestures(
                    onDoubleTap = { offset: Offset ->
                        dragState = "发生双击操作了~"
                    },
                    onLongPress = { offset: Offset ->
                        dragState = "发生长按操作了~"
                    },
                    onPress = {  offset: Offset ->
                        dragState = "发生按下操作了~"
                    },
                    onTap = {  offset: Offset ->
                        dragState = "发生轻触操作了~"
                    }
                )
            }
        )
        Text(text = dragState,
            Modifier.align(Alignment.TopCenter).padding(top = 35.dp),
            fontSize = 22.sp
        )
    }
}

Jetpack Compose中的手势操作和事件处理_第9张图片

变换类型基础 API

API名称 作用
detectTransformGestures 监听拖动、缩放与旋转手势

使用 detectTransformGestures 可以获取到双指拖动、缩放与旋转手势操作中更具体的手势信息,例如重心。

Tranformable 修饰符一样,detectTransformGestures 方法提供了两个参数。

  • panZoomLock(可选): 当拖动或缩放手势发生时是否支持旋转
  • onGesture(必须):当拖动、缩放或旋转手势发生时回调
suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)

Tranformable 修饰符不同的是,通过这个 API 可以监听单指的拖动手势,和拖动类型基础 API所提供的功能一样,除此之外还支持监听双指缩放与旋转手势。反观 Tranformable 修饰符只能监听到双指拖动手势,不知设计成这样的行为不一致是否是官方有意为之。

使用起来十分简单,我们仅需根据手势信息来更新状态就可以了。当我们处理旋转、缩放与拖动这类手势时,需要格外的注意 Modifier 调用次序,因为这会影响最终呈现效果。

@Composable
fun TransformGestureDemo() {
    val boxSize = 200.dp
    var offset by remember { mutableStateOf(Offset.Zero) }
    var rotationAngle by remember { mutableStateOf(0f) }
    var scale by remember { mutableStateOf(1f) }
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Image(
            painter = painterResource(id = R.drawable.ic_sky),
            contentScale = ContentScale.Crop,
            contentDescription = null,
            modifier = Modifier
                .size(boxSize)
                .rotate(rotationAngle) // 注意rotate的顺序应该先于offset
                .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
                .scale(scale)
                .pointerInput(Unit) {
                    detectTransformGestures(
                        panZoomLock = true, // 平移或放大时是否可以旋转
                        onGesture = { centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->
                            offset += pan
                            scale *= zoom
                            rotationAngle += rotation
                        }
                    )
                }
        )
    }
}

运行效果:

注意:

  • 关于panZoomLock参数,如果值为 true ,则仅当在平移或缩放运动之前检测到旋转时才允许旋转。如果没有,平移和缩放手势将被检测,但旋转手势将不会被检测。如果值为 false ,一旦触摸slop到达,所有三个手势都将被检测到。
  • onGesture 方法的几个参数中,centroid 是多指触摸的中心点,而 panzoomrotation都是针对上一次状态的增量,而不是相对原始状态的。另外,在一次回调中这几个参数是有可能同时发生变化的,即在缩放的同时可以平移旋转。

手势事件作用域 awaitPointerEventScope

我们前面介绍的 GestureDetector 系列 API 本质上仍然是一种封装,既然手势处理是在协程中完成的,所以手势监听必然是通过协程的挂起恢复实现的,以取代传统的回调监听方式。要想深入理解 Compose 手势处理,就需要学习更为底层的手势处理挂起方法。

PointerInputScope 中我们使用 awaitPointerEventScope 方法获得 AwaitPointerEventScope 作用域,在 AwaitPointerEventScope 作用域中我们可以使用 Compose 中所有低级别的手势处理挂起方法。当 awaitPointerEventScope 内所有手势事件都处理完成后 awaitPointerEventScope 便会恢复执行将 Lambda 中最后一行表达式的数值作为返回值返回。

suspend fun <R> awaitPointerEventScope(
    block: suspend AwaitPointerEventScope.() -> R
): R

我们在 AwaitPointerEventScope 中发现了以下这些基础手势方法,可以发现这些 API 均是挂起函数

API名称 作用
awaitPointerEvent 手势事件
awaitFirstDown 第一根手指的按下事件
drag 拖动事件
horizontalDrag 水平拖动事件
verticalDrag 垂直拖动事件
awaitDragOrCancellation 单次拖动事件
awaitHorizontalDragOrCancellation 单次水平拖动事件
awaitVerticalDragOrCancellation 单次垂直拖动事件
awaitTouchSlopOrCancellation 有效拖动事件
awaitHorizontalTouchSlopOrCancellation 有效水平拖动事件
awaitVerticalTouchSlopOrCancellation 有效垂直拖动事件

事件之源 awaitPointerEvent

之所以称这个 API 为事件之源,因为上层所有手势监听 API 都是基于这个 API 实现的,他的作用类似于传统 View 中的 onTouchEvent() 。无论用户是按下、移动或抬起都将视作一次手势事件,当手势事件发生时 awaitPointerEvent 便会恢复返回监听到的屏幕上所有手指的交互信息。

forEachGesture {
    awaitPointerEventScope {
        var event = awaitPointerEvent()
        println("x: ${event.changes[0].position.x}, y: ${event.changes[0].position.y}, type: ${event.type}")
    }
}

event.type返回事件的类型,例如PointerEventType.PressPointerEventType.MovePointerEventType.Release它们分别对应传统 View 中的ACTION_DOWNACTION_MOVEACTION_UP

awaitPointerEventScope {
     val event = awaitPointerEvent()
     if (event.type == PointerEventType.Press) {
         println("按下")
     } else if (event.type == PointerEventType.Move) {
         println("移动")
     } else if (event.type == PointerEventType.Release) {
         println("抬起")
     } 
 }

由于 awaitPointerEvent() 是挂起函数,因此可以在 awaitPointerEventScope 作用域内,每次调用完上一次的等待之后,继续等待下一次的手势到来:

awaitPointerEventScope {
     var event = awaitPointerEvent()
     println("当前事件类型type: ${event.type}")
     event = awaitPointerEvent()
     println("当前事件类型type: ${event.type}")
}

事件分发

实际上 awaitPointerEvent 存在着一个可选参数 PointerEventPass,这个参数实际上是用来定制手势事件分发顺序的。

suspend fun awaitPointerEvent(
    pass: PointerEventPass = PointerEventPass.Main
): PointerEvent

PointerEventPass 有 3 个枚举值,可以让我们来决定手势的处理阶段。在 Compose 中,手势处理共有3个阶段:

  • Initial 阶段自上而下的分发手势事件
  • Main 阶段自下而上的分发手势事件
  • Final 阶段自上而下的分发手势事件

awaitPointerEventpass参数默认值是Main,也就是该方法默认是捕获自下而上分发的手势事件。

Jetpack Compose中的手势操作和事件处理_第10张图片
(注:这里的上下其实是指嵌套的父子组件的关系,可以理解为UI组件树中的上下游)

Inital 阶段,手势事件会在所有使用 Inital 参数的组件间自上而下的完成首次分发。利用 Inital 可以使父组件能够预先劫持消费手势事件,这类似于传统 ViewonInterceptTouchEvent 的作用。

Main 阶段,手势事件会在所有使用 Main 参数的组件间自下而上的完成第二次分发。利用 Main 可以使子组件能先于父组件完成手势事件的处理,这有些类似于传统 ViewonTouchEvent 的作用。

Final 阶段,手势事件会在所有使用 Final 参数的组件间自上而下的完成最后一次分发。Final 阶段一般用来让组件了解经历过前面几个阶段后的手势事件消费情况,从而确定自身行为。例如按钮组件可以不用手指从按钮上移动开的事件,因为这个事件可能已被父组件滚动器用于滚动消费了。

接下来我们通过一个嵌套组件的手势监听来演示事件的分发过程。

当所有组件的手势监听均默认使用 Main 时,点击中间的Box,事件分发顺序为:第三层 --> 第二层 --> 第一层

Jetpack Compose中的手势操作和事件处理_第11张图片

而如果第一层组件使用 Inital,第二层组件使用 Final ,第三层组件使用 Main,事件分发顺序为:第一层 --> 第三层 --> 第二层

Jetpack Compose中的手势操作和事件处理_第12张图片

接下来,我们换作四层嵌套来观察手势事件的分发,其中第一层与第三层使用 Initial,第二层使用 Final,第四层使用 Main,事件分发顺序为:第一层 --> 第三层 --> 第四层 --> 第二层

Jetpack Compose中的手势操作和事件处理_第13张图片

测试代码:

@Composable
fun NestedBoxDemo() {
    var result by remember { mutableStateOf("执行顺序:\n") }
    Box(Modifier.myBox(300.dp, Color.Red, PointerEventPass.Initial) {
            result += "第一层\n"
        },
        contentAlignment = Alignment.Center
    ) {
        Box(Modifier.myBox(250.dp, Color.Blue, PointerEventPass.Final) {
                result += "第二层\n"
            },
            contentAlignment = Alignment.Center
        ) {
            Box(Modifier.myBox(200.dp, Color.Green, PointerEventPass.Initial) {
                    result += "第三层\n"
                },
                contentAlignment = Alignment.Center
            ) {
                Box(Modifier.myBox(150.dp, Color.Yellow, PointerEventPass.Main) {
                        result += "第四层\n"
                    },
                    contentAlignment = Alignment.Center
                ) {
                    Text(text = result)
                }
            }
        }
    }
}
fun Modifier.myBox(size: Dp, color: Color, pass: PointerEventPass, block: ()->Unit): Modifier {
    return this.size(size).background(color).pointerInput(Unit) {
            awaitPointerEventScope {
                awaitPointerEvent(pass)
                block()
            }
        }
}

Jetpack Compose中的手势操作和事件处理_第14张图片

事件消费

我们看到 awaitPointerEvent 返回了一个 PointerEvent 实例。

actual data class PointerEvent internal constructor(
    actual val changes: List<PointerInputChange>,
    internal val motionEvent: MotionEvent?
)

PointerEvent 类的声明中可以看到包含了两个属性 changesmotionEvent

  • motionEvent:实际上就是传统 View 系统中的 MotionEvent,由于被声明 internal ,说明官方并不希望我们直接拿来使用。
  • changes:其中包含了一次手势交互中所有手指的交互信息。在多指操作时,利用 changes 可以轻松定制多指手势处理。

可以看出单指交互的完整信息被封装在了一个 PointerInputChange 实例中,接下来看看 PointerInputChange 提供了哪些手势信息。

class PointerInputChange(
    val id: PointerId, // 手指Id
    val uptimeMillis: Long, // 当前手势事件的时间戳
    val position: Offset, // 当前手势事件相对组件左上角的位置
    val pressed: Boolean, // 当前手势是否按下
    val previousUptimeMillis: Long, // 上一次手势事件的时间戳
    val previousPosition: Offset, // 上一次手势事件相对组件左上角的位置
    val previousPressed: Boolean, // 上一次手势是否按下
    val consumed: ConsumedData, // 当前手势是否已被消费
    val type: PointerType = PointerType.Touch // 手势类型(鼠标、手指、手写笔、橡皮) 
)

利用这些丰富的手势信息,我们可以在上层定制实现各类复杂的交互手势。

可以看到其中的 consumed 成员记录着该事件是否已被消费,我们可以使用 PointerInputChange 提供的 consume 系列 API 来修改这个手势事件的消费标记。

API名称 作用
changedToDown 是否已经按下(按下手势已消费则返回false)
changedToDownIgnoreConsumed 是否已经按下(忽略按下手势已消费标记)
changedToUp 是否已经抬起(按下手势已消费则返回false)
changedToUpIgnoreConsumed 是否已经抬起(忽略按下手势已消费标记)
positionChanged 是否位置发生了改变(移动手势已消费则返回false)
positionChangedIgnoreConsumed 是否位置发生了改变(忽略已消费标记)
positionChange 位置改变量(移动手势已消费则返回Offset.Zero)
positionChangeIgnoreConsumed 位置改变量(忽略移动手势已消费标记)
isConsumed 当前手势是否已被消费
consumeDownChange 消费按下手势
consumePositionChange 消费移动手势
consumeAllChanges 消费按下与移动手势
isOutOfBounds 当前手势是否在固定范围内

前面提到,我们可以通过设置 PointerEventPass 来定制嵌套组件间手势事件分发顺序。假设分发流程中组件 A 预先获取到了手势信息并进行消费,手势事件仍然会被之后的组件 B 获取得到。组件 B 在使用 positionChange 获取的偏移值时会返回 Offset.ZERO,这是因为此时该手势事件已被标记为已消费的状态。当然组件 B 也可以通过 IgnoreConsumed 系列 API 突破已消费标记的限制获取到手势信息。

我们仍然通过前面使用的嵌套组件示例子来看看手势事件的消费。我们的嵌套组件中第一层组件使用 Inital,第二层组件使用 Final ,第三层组件使用 Main

Jetpack Compose中的手势操作和事件处理_第15张图片
我们在第三层组件的手势事件监听中进行消费,因为我们知道手势事件会交由第一层, 再交由第三层,最后交由第二层。第三层组件处于本次手势分发流程的中间位置。

当我们在第三层组件消费了 ACTION_DOWN 后,之后处理的第二层组件接收的手势事件仍是被标记为消费状态的。

@Composable
fun ConsumeDemo() {
    var result by remember { mutableStateOf("消费状态:\n") }
    Box(Modifier.myBox(300.dp, Color.Red, PointerEventPass.Initial) { event ->
            result +="第一层 isConsumed: ${event.changes[0].isConsumed}\n"
        },
        contentAlignment = Alignment.Center
    ) {
        Box(Modifier.myBox(250.dp, Color.Blue, PointerEventPass.Final) { event ->
            	result += "第二层 isConsumed: ${event.changes[0].isConsumed}\n"
            },
            contentAlignment = Alignment.Center
        ) {
            Box(Modifier.myBox(200.dp, Color.Green, PointerEventPass.Main) { event ->
                    val change = event.changes[0]
                    if (change.pressed != change.previousPressed) change.consume() // 消费事件
                    result += "第三层 isConsumed: ${change.isConsumed}\n"
                },
                contentAlignment = Alignment.Center
            ) {
                Text(text = result)
            }
        }
    }
}
private fun Modifier.myBox(size: Dp, color: Color, pass: PointerEventPass, block: (PointerEvent)->Unit): Modifier {
    return this.size(size).background(color).pointerInput(Unit) {
        awaitPointerEventScope {
            val event = awaitPointerEvent(pass)
            block(event)
        }
    }
}

Jetpack Compose中的手势操作和事件处理_第16张图片

另外,PointerEvent 对象除了可以消费事件和判断事件是否已消费外,还提供了一些列便利函数,如event.calculatePan()event.calculateZoom()event.calculateRotation()event.calculateCentroid()等等,它们返回当前状态和上一次状态之间中心点位置的变化增量,以便开发者可以处理多重手势操作。可见利用这些API几乎可以实现前面 detectTransformGestures 的功能。

awaitFirstDown

awaitFirstDown 将等待直到第一根手指 ACTION_DOWN 事件时恢复执行,并将手指按下事件返回。翻阅源码可以看出其内部实现原理并不复杂。

suspend fun AwaitPointerEventScope.awaitFirstDown(
    requireUnconsumed: Boolean = true
): PointerInputChange {
    var event: PointerEvent
    do {
        event = awaitPointerEvent()  // 监听手势事件
    } while (
    	// 遍历每一根手指的事件信息
        !event.changes.fastAll {
            // 需要没有被消费过的手势事件
            if (requireUnconsumed) {
                // 返回该事件是否是一个还没有被消费的DOWN事件
                // 当返回 false 时说明是不是DOWN事件或已被消费的DOWN事件
                it.changedToDown()
            } else {
                // 返回该事件是否是一个DOWN事件,忽略是否已被消费
                // 当返回 false 时说明是不是DOWN事件
                it.changedToDownIgnoreConsumed()
            }
        }
    )
    // 返回第一根手指的事件信息
    return event.changes[0]
}

forEachGesture

前面提到 Compose 手势操作实际上是在协程中监听处理的,当协程处理完一轮手势交互后便会结束,当进行第二次手势交互时由于负责手势监听的协程已经结束,手势事件便会被丢弃掉。那我们该怎样才能让手势监听协程能够持续不断地处理每一轮的手势交互呢。我们很容易想到可以在外层嵌套一个 while(true) 进行实现:

Modifier.pointerInput(Unit) {
    while (true) {
        awaitPointerEventScope {
            val downEvent = awaitFirstDown()  
            // do something
        }
    }
}

然而这么做并不优雅,且也存在着一些问题。

当用户出现一连串手势操作时,很难保证各手势之间有清晰分界,即无法保证每一轮手势结束后,所有手指都是离开屏幕的。在传统 View 体系中,一次手指按下、移动到抬起过程中的所有手势事件可以看作是一个完整的手势交互序列。每当用户触摸屏幕交互时,我们可以根据这一次用户输入的手势交互序列中的手势信息进行相应的处理。

当第一轮手势处理结束或者被中断取消后,如果仍有手指留在屏幕。如果采用 while(true) 处理手势,则第二轮手势处理可能会使用第一轮手势交互序列中信息,导致出现不符预期的结果。

Compose 为我们提供了 forEachGesture 方法保证了每一轮手势处理逻辑的一致性。实际上前面我们所介绍的 GestureDetect 系列 API,其内部实现都使用了 forEachGesture

Modifier.pointerInput(Unit) {
    forEachGesture {
        awaitPointerEventScope {
            val downEvent = awaitFirstDown()  
            // do something
        }
    }
} 

通过 forEachGesture 的源码可知,每一轮手势处理结束后或本次手势处理被取消时,都会使用 awaitAllPointersUp() 保证所有手指均已抬起。并且同时也会与当前组件的生命周期对齐,当组件离开视图树时,手势监听也会随之结束。

suspend fun PointerInputScope.forEachGesture(block: suspend PointerInputScope.() -> Unit) {
    val currentContext = currentCoroutineContext()
    while (currentContext.isActive) {
        try {
          block()
          // 挂起等待所有手指抬起
          awaitAllPointersUp()
        } catch (e: CancellationException) {
            if (currentContext.isActive) {
                // 手势事件取消时,如果协程还存活则等待手指抬起再进行下一轮监听
                awaitAllPointersUp()
                throw e
            }
        }
    }
}

awaitEachGesture

注意: forEachGesture 这个API现在已经被标记为Deprecated(但是你目前仍然可以使用),目前官方推荐的是使用 awaitEachGesture(block) 这个Api来代替它,awaitEachGesture的内部在 awaitPointerEventScope 内直接调用了传入的block,因此相比 forEachGesture 使用更加方便,不用在forEachGesture 内再套awaitPointerEventScope 了。

suspend fun PointerInputScope.awaitEachGesture(block: suspend AwaitPointerEventScope.() -> Unit) {
    val currentContext = currentCoroutineContext()
    awaitPointerEventScope {
        while (currentContext.isActive) {
            try {
                block()
                // Wait for all pointers to be up. Gestures start when a finger goes down.
                awaitAllPointersUp()
            } catch (e: CancellationException) {
                if (currentContext.isActive) {
                    // The current gesture was canceled. Wait for all fingers to be "up" before
                    // looping again.
                    awaitAllPointersUp()
                } else {
                    // detectGesture was cancelled externally. Rethrow the cancellation exception to
                    // propagate it upwards.
                    throw e
                }
            }
        }
    }
}

所以更加常规的优雅用法是使用awaitEachGesture来持续不断地监听每一轮的手势变化:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        val downEvent = awaitFirstDown() 
        // do something
    }
}  

例如利用 awaitEachGesture 实现一个简单的点击事件处理逻辑:

private fun Modifier.myClick(onClick: ()->Unit): Modifier {
    return this.pointerInput(Unit) {
            awaitEachGesture {
                awaitFirstDown() // 等待第一根手指按下
                while(true) {
                    val event = awaitPointerEvent()
                    if (event.type == PointerEventType.Move) { 
                        val pos = event.changes[0].position
                        if (pos.x < 0 || pos.x > size.width || pos.y < 0 || pos.y > size.height) {
                            // 按下状态时,手指滑动超出边界,不处理
                            break
                        }
                    } else if (event.type == PointerEventType.Release && event.changes.size == 1) {    
                    // } else if (event.changes.fastAll { it.changedToUp() }) {  // 源码中判断所有手指都抬起的用法(可参考waitForUpOrCancellation()的实现)
                        // 因为每个手指抬起都会触发PointerEventType.Release(而原生中后面手指的抬起是ACTION_POINTER_UP, Compose这点与原生不同)
                        // 判断changes.size==1是判断只有一根手指抬起即最后一根手指抬起
                        onClick()
                        break
                    } 
                }
            }
    }
}

drag

前面提到的 detectDragGestures,以及更为上层的 Draggable 修饰符内部都是使用 drag 挂起方法来实现拖动监听的。通过函数签名可以看到我们不仅需要手指拖动的监听回调,还需传入手指的标识信息,表示监听具体哪根手指的拖动手势。

suspend fun AwaitPointerEventScope.drag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
)

我们可以先利用 awaitFirstDown 获取到记录着交互信息的 PointerInputChange 实例,其中 id 字段记录着发生 ACTION_DOWN 事件的手指标识信息。通过结合 awaitEachGestureawaitFirstDowndrag,我们便可以实现一个简单的拖动手势监听了。

@Composable
fun BaseDragGestureDemo() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    Box(Modifier.fillMaxSize(), Alignment.Center) {
        Box(Modifier
            .size(100.dp)
            .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
            .background(Color.Green)
            .pointerInput(Unit) {
                awaitEachGesture {
                    val downEvent = awaitFirstDown() // 获取第一根手指的DOWN事件
                    // 根据手指标识符跟踪多种手势
                    drag(downEvent.id) {
                        offset += it.positionChange() // 根据手势位置的变化更新偏移量
                    }
                }
            }
        )
    }
}

Jetpack Compose中的手势操作和事件处理_第17张图片

awaitDragOrCancellation

drag 不同的是,awaitDragOrCancellation 负责监听 单次拖动事件。当该手指抬起时,如果有其他手指还在屏幕上,则会选择其中一根手指来继续追踪手势。当最后一根手指离开屏幕时则会返回抬起事件。

当手指拖动事件已经在 Main 阶段被消费,拖动行为会被认为已经取消,此时会返回 null。如果在调用 awaitDragOrCancellation 前,pointId 对应手指没有产生 ACTION_DOWN 事件则也会返回 null

当然我们也可以使用 awaitDragOrCancellation 来完成 UI 拖动手势处理流程。

@Composable
fun BaseDragGestureDemo2() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    Box(Modifier.fillMaxSize(), Alignment.Center) {
        Box(Modifier
            .size(100.dp)
            .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
            .background(Color.Green)
            .pointerInput(Unit) {
                awaitEachGesture {
                    val downPointer = awaitFirstDown() // 获取第一根手指的DOWN事件
                    while (true) {
                        // 根据手指标识符跟踪拖动手势,手指抬起或拖动事件被消费时返回null
                        val event = awaitDragOrCancellation(downPointer.id)
                        if (event == null) break // 拖动事件被取消
                        if (event.changedToUp()) break // 所有手指均已抬起
                        offset += event.positionChange() // 根据手势位置改变量更新偏移量状态
                    }
                }
            }
        )
    }
}

awaitTouchSlopOrCancellation

awaitTouchSlopOrCancellation 用于定制监听一次有效的拖动行为,这里的有效是开发者自己来定制的。在使用时,我们需要设置一个 pointId ,表示我们希望追踪手势事件的手指标识符。当该手指抬起时,如果有其他手指还在屏幕上,则会选择其中一根手指来继续追踪手势,而如果已经没有手指在屏幕上了则返回 null。如果在调用 awaitTouchSlopOrCancellation 前,pointId 对应手指没有产生 ACTION_DOWN 事件则也会返回 null

suspend fun AwaitPointerEventScope.awaitTouchSlopOrCancellation(
  pointerId: PointerId,
  onTouchSlopReached: (change: PointerInputChange, overSlop: Offset) -> Unit
)

其中 onTouchSlopReached 回调方法会在超过 ViewConfiguration 中所设定的阈值 touchSlop 时回调。如果根据事件信息我们希望接收这次手势事件,则应该通过 change 调用 consumePositionChange 进行消费,此时 awaitTouchSlopOrCancellation 会恢复执行,并返回当前 PointerInputChange。如果不消费,则会继续挂起检测滑动位移。

利用awaitTouchSlopOrCancellation实现惯性滑动

如果当手指拖动离开屏幕存在初速度时,被拖动的组件会惯性滑动一段距离后停下,这种交互效果被称作 Fling。
Jetpack Compose中的手势操作和事件处理_第18张图片
既然我们是要拖动组件,当发生拖动手势时组件我们可以设置 offset 移动组件位置。当发生 Fling 时组件会惯性朝着某一方向滑动一段距离后停下,实际上在手指离开屏幕时我们可以根据当前手势速度与组件位置来预先计算出组件最终停留的位置,所以 Fling 本质上只是以种交互动画。既然是动画,我们便可以使用 Animatable 包装组件偏移量信息。

var offset = remember {
  Animatable(Offset.Zero, Offset.VectorConverter)
}

对于拖动手势,我们首先需要使用 awaitFirstDown 获取 ACTION_DOWN 手势事件信息。值得注意的是,当上一轮 Fling 未结束本轮手势便开始时。我们可以使用 Animatable 提供的 stop 方法来中断结束上一轮动画。

forEachGesture {
  val down =  awaitPointerEventScope { awaitFirstDown() }
  offset.stop()
  ...
}

接下来我们可以利用 awaitTouchSlopOrCancellation 检测当前是否为有效拖动手势,当检测成功后便可以使用 drag 来监听具体的拖动手势事件。

forEachGesture {
  val down =  awaitPointerEventScope { awaitFirstDown() }
  offset.stop()
  awaitPointerEventScope {
    var validDrag: PointerInputChange?
    do {
      validDrag = awaitTouchSlopOrCancellation(down.id) { change, _ ->
        change.consumePositionChange() // 消费位置变化表示接受
      }
    } while (validDrag != null && !validDrag.isConsumed)
    if (validDrag != null) {
      // 拖动手势监听
    }
  }
}

前面我们提到过当手指离开屏幕时,我们需要根据离屏时的位置信息与速度信息来计算组件最终会停留的位置。位置信息我们可以利用 offset 获取得到,而速度信息的获取则需要使用速度追踪器 VelocityTracker

当发生拖动时,我们首先使用 snapTo 移动组件偏移位置。既然追踪手势速度,我们就需要将手势信息告知 VelocityTracker,通过 addPosition 实时告知 VelocityTracker 当前的手势位置,VelocityTracker 便可以实时计算出当前的手势速度了。

drag(validDrag.id) {
  launch {
    offset.snapTo(offset.value + it.positionChange())
    velocityTracker.addPosition(it.uptimeMillis, it.position)
  }
}

当手指离开屏幕时,我们可以利用 VelocityTrackerOffset 获取到实时速度信息与位置信息。之后,我们可以利用 splineBasedDecay 创建一个衰值推算器,这可以帮助我们根据当前速度与位置信息推算出组件 Fling 后停留的位置。由于最终位置可能会超出屏幕,所以我们还需设置数值上下界,并采用 animateTo 进行 Fling 动画。由于我们希望的是组件最终会缓缓的停下,所以这里采用的是 LinearOutSlowInEasing 插值器。

val decay = splineBasedDecay<Offset>(this)
var targetOffset = decay.calculateTargetValue(Offset.VectorConverter, offset.value, 
			Offset(horizontalVelocity, verticalVelocity)).run {
  Offset(x.coerceIn(0f, 320.dp.toPx()), y.coerceIn(0f, 320.dp.toPx()))
}
launch { offset.animateTo(targetOffset, tween(2000, easing = LinearOutSlowInEasing)) }

完整代码:

@Composable
fun DragFlingDemo() {
    Box(Modifier.fillMaxSize(),Alignment.Center) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            var horizontalVelocity by remember { mutableStateOf(0f) }
            var verticalVelocity by remember { mutableStateOf(0f) }
            Box(Modifier.fillMaxWidth().height(50.dp), Alignment.Center) {
                Text("Velocity", fontSize = 42.sp, fontWeight = FontWeight.Bold)
            }
            Spacer(Modifier.height(10.dp))
            Box(Modifier.fillMaxWidth().height(50.dp), Alignment.Center) {
                val text = "Horizontal: %.2f Vertical: %.2f".format(horizontalVelocity, verticalVelocity)
                Text(text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
            }
            Spacer(Modifier.height(20.dp))
            val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
            Box(Modifier.size(350.dp).background(Color.Gray)
                    .pointerInput(Unit) {
                        offset.updateBounds(
                            lowerBound = Offset.Zero,
                            upperBound = Offset(320.dp.toPx(), 320.dp.toPx())
                        )
                        coroutineScope { 
                            awaitEachGesture {
                                val down = awaitFirstDown()
                                launch { offset.stop() }
                                var validDrag: PointerInputChange?
                                do {
                                    validDrag = awaitTouchSlopOrCancellation(down.id) { change, _ ->
                                        if (change.positionChange() != Offset.Zero) change.consume()
                                    }
                                } while (validDrag != null && !validDrag.isConsumed)
                                if (validDrag != null) {
                                    val velocityTracker = VelocityTracker()
                                    var dragAnimJob: Job? = null
                                    drag(validDrag.id) {
                                        dragAnimJob = launch {
                                            offset.snapTo(offset.value + it.positionChange())
                                            velocityTracker.addPosition(it.uptimeMillis, it.position)
                                            horizontalVelocity = velocityTracker.calculateVelocity().x
                                            verticalVelocity = velocityTracker.calculateVelocity().y
                                        }
                                    }
                                    horizontalVelocity = velocityTracker.calculateVelocity().x
                                    verticalVelocity = velocityTracker.calculateVelocity().y
                                    val decay = splineBasedDecay<Offset>(this)
                                    val targetOffset = decay.calculateTargetValue(Offset.VectorConverter, 
                                            offset.value, Offset(horizontalVelocity, verticalVelocity)).run {
                                            Offset(x.coerceIn(0f, 320.dp.toPx()), y.coerceIn(0f, 320.dp.toPx()))
                                        }
                                    dragAnimJob?.cancel()
                                    launch {
                                        offset.animateTo(targetOffset, tween(2000, easing = LinearOutSlowInEasing))
                                    }
                                }
                            }
                        }
                    }
            ) {
                Box(Modifier
                        .offset { IntOffset(offset.value.x.roundToInt(), offset.value.y.roundToInt()) }
                        .size(30.dp).clip(CircleShape).background(Color.Green)
                )
            }
        }
    }
}

Jetpack Compose中的手势操作和事件处理_第19张图片


参考资料:

  • 《Jetpack Compose从入门到实战》- 机械工业出版社 - 2022年9月

你可能感兴趣的:(Jetpack,Compose,android,Jetpack,Compose,手势操作,拖动,滑动)