Jetpack Compose 自定义触摸:一维滑动监测

303 阅读6分钟

触摸处理:从 View 到 Jetpack Compose

传统 View 系统的触摸处理

在传统 View 系统中要自定义触摸反馈,通常需要去重写各类与事件处理相关的方法,像这样:

class CustomViewGroup(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) {
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 视图布局逻辑
        TODO()
    }

    // 事件分发:所有触摸事件的入口,拥有最高控制权,决定事件如何向下传递。
    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        // 精细化控制事件分发逻辑
        return super.dispatchTouchEvent(ev)
    }

    // 事件拦截:仅存在于 ViewGroup。用于判断是否拦截当前事件序列,
    // 如果返回 true,则事件由当前 ViewGroup 的 onTouchEvent 处理,不再传递给子 View。
    // 如果返回 false,则事件继续传递给子 View(或当前 ViewGroup 的 onTouchEvent,如果无子 View 可处理)。
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        // 根据需求判断是否拦截
        return super.onInterceptTouchEvent(ev)
    }

    // 事件处理:实际处理触摸事件的地方。
    // 对于普通 View,直接重写此方法。
    // 对于 ViewGroup,如果 onInterceptTouchEvent 拦截了事件,或者没有子 View 处理事件,则会调用此方法。
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        // 处理具体的触摸反馈
        return super.onTouchEvent(event)
    }
}

简单来说:

  • 重写 onTouchEvent 方法来监测和处理触摸事件,这是对于没有子 View 的 View 来说的,也就是继承了 View 而没有继承 ViewGroup 的类,比如TextViewImageView

  • 而对于继承了 ViewGroup 的类,比如 LinearLayout,除了 onTouchEvent,我们还需要重写 onInterceptTouchEvent 来决定是否将事件拦截下来自己处理,还是传递给子 View。

  • 在更复杂的场景下,你还需要去重写最底层的 dispatchTouchEvent,控制事件的分发,来实现更精细化的定制。

此外 Android SDK 也提供了一些辅助类 GestureDetectorScaleGestureDetector,来帮助我们识别常见的手势(如单击、双击、长按、滑动、捏合缩放等)。

Compose 中的触摸处理

而到了 Compose,对于触摸反馈精细化的定制,你可以这样写:

Modifier.pointerInput(key1 = Unit) { // key1 用于在输入发生变化时重启协程
    awaitEachGesture { // 循环等待并处理每一组手势事件
        // 循环获取原始触摸事件(Pointer Events)
        val event = awaitPointerEvent()

        // 根据 event 进行计算和响应
        println(event.changes.first().position) // 打印第一个触摸点的位置 
    
    }
}

多次点击的运行效果:

D/System.out    Offset(63.0, 71.9)
D/System.out    Offset(63.0, 71.9)
D/System.out    Offset(63.0, 71.9)

但我们一般不这么写,因为 Compose 已经提供了相当完备并且简易的上层 API 来处理常见的触摸交互。

对于最简单的手势处理,比如点击、双击、长按,你可以看我的这篇文章:传送门

本文详细讲解一维滑动相关的 Modifier 修饰符:draggablescrollable。其中 scrollable 的底层实现就是draggable,只不过scrollable针对滑动场景增加了一些额外的功能支持,那么,我们先来看较为底层的 draggable

draggable: 实现一维拖拽

参数详解

draggable 用于处理一维(水平或垂直)的拖拽手势。

fun Modifier.draggable(
    state: DraggableState,
    orientation: Orientation,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    startDragImmediately: Boolean = false,
    onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
    onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
    reverseDirection: Boolean = false
): Modifier

它有两个必填参数,分别是 stateorientation

state 和 orientation 参数

什么是 state 状态呢?其实每一个可操作/交互的组件和修饰符,都有一个 state 参数。

就比如 LazyColumn 组件就有 state 参数,这个参数可以让我们去手动操作界面,准确来说是修改界面所依赖的状态对象,从而完成操作界面的效果。例如:

@Preview(showBackground = true)
@Composable
fun LazyColumnStateSample() {
    val scope = rememberCoroutineScope()
    val state = rememberLazyListState() // LazyColumn 的状态
    Column {
        LazyColumn(
            modifier = Modifier.border(1.dp, Color.LightGray).size(160.dp, 160.dp), 
            state = state  // 将状态与组件关联
        ) { 
            items(items = (1..100).map { it }) {
                Text("Item $it")
            }
        }

        Button(
            onClick = {
                scope.launch {
                    // 以动画的形式滚动到第一个列表项
                    state.animateScrollToItem(0)
                }
            }
        ) { Text("点击跳转到第一项") }
    }
}

预览效果:

image.gif

draggablestate 参数也是类似的道理,是用于管理拖拽状态的,只需调用 rememberDraggableState 函数就能创建一个 DraggableState 对象:

val draggableState = rememberDraggableState(
    onDelta = { delta -> // Δ 拖拽的位移变化量
        // ..
    }
)

其中要传入一个 onDelta 回调,当用户的手指在屏幕上拖动时,它会被连续调用。delta 就是每一次拖拽时的位移量,单位是像素。并且这个位移是一维的,只表示水平或垂直方向上的位移量,正负代表不同的拖动方向。

orientation 参数就能指定拖拽的方向,Orientation.Horizontal(水平) 或 Orientation.Vertical(垂直)。

了解完这两个必填参数就可以来写一个简单的 Demo 了。

@Composable
fun DraggableLogSample() {
    val draggableState = rememberDraggableState(
        onDelta = { delta ->
            Log.d("DraggableLogSample", "此次拖拽的位移量是:$delta 像素")
        }
    )

    Text(
        text = "水平拖动我",
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
            .background(Color.LightGray)
            .draggable(
                state = draggableState,
                orientation = Orientation.Horizontal
            )
    )
}

其它重要参数

再来看看 draggable 的其它可选参数,这些参数都有默认值:

fun Modifier.draggable(
    state: DraggableState,
    orientation: Orientation,
    enabled: Boolean = true, // 拖拽是否生效
    interactionSource: MutableInteractionSource? = null, // 交互数据源
    startDragImmediately: Boolean = false, // 是否立即开始拖拽监测
    onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}, // 拖动开始时的回调(过了 "touch slop" 阶段),参数 startedPosition 表示拖拽开始时的位置
    onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, // 拖动结束时的回调,参数 velocity 是松手时在拖拽方向上的速度,单位是像素每秒
    reverseDirection: Boolean = false // 是否反转手势方向,向上滑动就变为向下滑动,向右滑动就变为向左滑动
): Modifier

其中就讲两个参数,分别是 interactionSourcestartDragImmediately,其它都挺简单的:

  • interactionSource: 交互数据源,可以观察组件是否正在被拖拽。

    @Composable
    fun DraggableInteractionSourceTextSample() {
        val draggableState = rememberDraggableState(
            onDelta = { delta -> 
                Log.d("draggable", "此次拖拽的位移量是:$delta")
            }
        )
    
        // 交互数据源
        val interactionSource = remember { MutableInteractionSource() }
        val isDraggable = interactionSource.collectIsDraggedAsState() // 收集拖拽状态
    
    
        Column {
            Text(
                "Hello draggable!",
                Modifier.draggable(
                    state = draggableState,
                    orientation = Orientation.Horizontal,
                    interactionSource = interactionSource
                )
            )
    
            Text(text = if (isDraggable.value) "文本正在拖拽中" else "文本静止")
        }
    }
    

    运行结果:

    image.gif
  • startDragImmediately:为 false 时,用户按下后,需要移动一小段距离(称为 "touch slop"),才会开始真正的拖拽监测并触发onDelta回调,这样可以防止因误触造成的点击导致组件的晃动,提高了用户的体验感;为 true 时,用户手指一接触屏幕,就会立即启动拖拽监测,onDelta回调也会被触发。

示例:拖动文字组件

我们来写文字组件被左右拖动的示例:

@Composable
fun DraggableTextDemo() {
    var offsetX by remember { mutableFloatStateOf(0f) } // 水平偏移量

    Text(
        text = "左右拖动我!",
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal, // 水平拖动
                state = rememberDraggableState { delta ->
                    // 修改当前位移
                    offsetX += delta
                }
            )
            .fillMaxWidth()
            .padding(12.dp)
    )
}

运行效果:

image.gif

注意:这里所涉及到的所有偏移量单位都是像素(px)。

scrollable: 更强大的滑动支持

首先搞清楚,scrollableModifier.verticalScroll()Modifier.horizontalScroll() 是不一样的,后两个是给本身不具备滚动能力的组件增加滑动功能的,而 scrollable 只是做滑动行为监测的,管理滑动状态的。

scrollable 相对于 draggable 所增加的功能就是:

  1. 支持惯性滑动:用户松手后,内容可以继续按一定速度滑动并逐渐减速停止。

  2. 支持嵌套滑动:协调父子组件之间的滑动事件分发与处理。

  3. 支持滑动到边界的效果:滑动到内容边界时继续拖拽产生的视觉效果(如光晕)。

有了这几种效果的支持,使得 scrollable 非常适合滑动布局这类场景。

参数详解

onDelta 回调

scrollable 的写法其实和 draggable 是差不多的,先来看它的函数原型:

@Stable
@ExperimentalFoundationApi
fun Modifier.scrollable(
    state: ScrollableState, 
    orientation: Orientation,
    overscrollEffect: OverscrollEffect?,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    flingBehavior: FlingBehavior? = null,
    interactionSource: MutableInteractionSource? = null,
    bringIntoViewSpec: BringIntoViewSpec = ScrollableDefaults.bringIntoViewSpec()
)

也是有 stateorientation 参数,state 参数用于管理滑动状态,我们通常使用 rememberScrollableState() 函数来创建它:

val scrollableState = rememberScrollableState(
    consumeScrollDelta = { delta -> // 滑动量
       
    }
)

draggable 不同的是,在它的 consumeScrollDelta 回调中还需要一个 Float 类型的返回值,表示当前组件实际消耗掉的滑动距离,这个返回值是为了支持嵌套滑动的,而未被消费的滑动距离可以被传递给父组件或子组件进行处理。

orientation 参数用于指定滑动方向(水平或垂直)。

我写一个简单的示例来展示onDelta回调的写法:

@Composable
fun ScrollableDemo() {
    Text(
        text = "scrollable的作用是监测滑动",
        modifier = Modifier.scrollable(
            orientation = Orientation.Horizontal,
            state = rememberScrollableState { delta -> // 表示此次滑动的偏移量

                println("此次滑动,偏移量为 $delta 像素")

                delta // 表示当前组件消费了全部的滑动增量
            }
        )
    )
}

其他重要参数

我们再来看看 scrollable 的其它重要参数,好几个参数都在 draggable 中讲解过了,唯一不同的是 flingBehavioroverscrollEffectbringIntoViewSpec

  • flingBehavior 参数是用于定义惯性滑动的行为的,即用户快速滑动后松手时,内容如何减速和停止。通常使用默认值即可,因为都是慢慢减速直到停下来,除非你有特殊的需求才需要自己实现,比如定制惯性滑动曲线或停止条件。

  • overscrollEffect 参数定义的是滑动到内容边界时继续拖拽产生的视觉效果,一般也是不填(有默认值)。

  • bringIntoViewSpec 参数定义的是当子项请求显示在视图中时,父组件滚动的行为。就比如一个文本输入框获得焦点时,要确保它在滚动视图中可见。你可以配置滚动的动画规格(动画曲线、滚动多快)和子项最终的位置逻辑(刚好这个组件可见,还是尝试显示在视图的中心)。

我们前面说过了 scrollable 非常适合作为滑动布局,事实上,Compose也是这样做的,Modifier.verticalScroll()LazyColumn 底层使用的都是 scrollable

总结

draggable 提供的是基础的、通用的一维拖拽手势识别。适用于简单的自定义拖动行为的场景,比如:自定义进度条的拖动控制。

scrollable 是在 draggable 基础上,增加了惯性滑动、嵌套滑动和滑动边界效果的支持,专用于滑动布局场景。比如:可滚动页面容器。

实际开发中,如何选择?

  • 如果你只需简单的内容/列表滚动,直接使用 LazyColumnLazyRowModifier.verticalScrollModifier.horizontalScroll

  • 需要自定义一个元素的拖拽行为,无需额外的效果时,可以使用 draggable

  • 当你需要自定义一个具备惯性滑动,或者父组件或子组件需要协调滑动行为的组件时,就使用 scrollable