前言

在上一篇文章中,我介绍了如何在Compose中完成自定义View和自定义ViewGroup,相较于原生的View体系,Compose的自定义View会更加简单方便。那么在这篇文章中,我将会介绍在Compose中如何完成触摸事件和嵌套滑动的处理。

1 Compose中的触摸事件

在原生的View体系中,常见的触摸事件有:ACTION_DOWN、ACTION_MOVE、ACTION_UP,当手指按下时,会遍历View树型结构拿到mFirstTouchTarget,以此将后续的MOVE事件和UP事件都交给这个组件消费,在View中消费事件是通过onTouchEvent方法处理的。

如果我们想要对事件进行拦截,通常会重写onInterceptTouchEvent,根据具体的业务场景来判断是否拦截事件,以及在嵌套的滑动组件中,对于事件冲突的处理尤为重要,所以本节我将会介绍在Compose中如何完成触摸事件的处理。

1.1 Compose中的点击事件

在Compose当中的Modifier提供了clickable函数用于处理点击事件;

Text(text = "点击我", Modifier.clickable {
    Log.d(TAG, "TestTouchEvent: 单击事件")
})

而对于双击,长按,则是另一个函数combinedClickable来完成。

fun Modifier.combinedClickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onLongClickLabel: String? = null,
    onLongClick: (() -> Unit)? = null,
    onDoubleClick: (() -> Unit)? = null,
    onClick: () -> Unit
){
    // ......
}

其实从源码中可以看到(这里我不带大家看了,可以自行查看,很简单的源码),像点击手势的处理,是通过Modifier.pointInput来处理的。

Text(text = "点击我", Modifier.pointerInput(Unit) {
    awaitEachGesture {
        val event = awaitPointerEvent()
        when(event.type){
            PointerEventType.Press ->{
                Log.d(TAG, "TestTouchEvent: Press")
            }
            PointerEventType.Move->{
                Log.d(TAG, "TestTouchEvent: Move")
            }
            PointerEventType.Exit->{
                Log.d(TAG, "TestTouchEvent: Exit")
            }
            PointerEventType.Scroll->{
                Log.d(TAG, "TestTouchEvent: Scroll")
            }
        }
    }
})

Modifier.pointInput算是Compose对于触摸反馈最底层的处理了,通过awaitPointerEvent可以获取用户输入的事件,根据类型判断是Press(点击)、Move(移动)、Scroll(滑动)等事件类型。

Text(text = "点击我", Modifier.pointerInput(Unit) {
    detectTapGestures {
        Log.d(TAG, "TestTouchEvent: 点击了")
    }
})

或者直接在pointInputScope中通过detectTapGestures来监测点击事件。

这是我之前在介绍Compose时,已经使用过点击事件,这里是简单的对点击事件的底层实现做了介绍,接下来我要介绍一下滑动事件。

1.2 Compose中的滑动事件 – draggable

在Compose中,提供了draggablescrollable函数,用于处理滑动事件,先看下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 {
    // ......
}

draggable函数中,有两个必填的值,stateorientation在滑动组件中,例如LazyColumnPager等,必须要有一个state对象。

1.2.1 LazyColumn中的state对象

LazyColumn中,state默认是执行了rememberLazyListState函数,利用其返回值,也就是得到了LazyListState对象。

@Composable
fun TestScrollableState() {
    val list = remember {
        mutableStateListOf("A", "B", "C", "D", "E", "F")
    }
    val scope = rememberCoroutineScope()
    val state = rememberLazyListState()
    LazyColumn(state = state) {
        items(list) {
            Text(
                text = "当前字母:$it",
                Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            )
        }
    }
    Button(onClick = {
        scope.launch {
            state.scrollToItem(list.size - 1)
        }
    }) {
        Text(text = "定位")
    }
}

那么这个state是干什么用的呢?其实就是为了用来处理列表的滑动,或者监听列表滑动。 我们常见的一个需求就是,当进到某个页面时,需要定位到列表中的某个元素,那么如果使用RecyclerView,那么可以通过scrollToPosition(index)来完成。

但是Compose是声明式的UI,无法拿到组件的实例对象,因此就是通过state来完成滑动的控制,例如点击按钮滑动到列表最后一位,那么就调用state的scrollToItem函数来完成。

1.2.2 draggable函数分析

再回到draggable函数,除了state之外,还需要设置orientation,就是滑动的方向。因为draggable是监听一维方向的滑动, 因此只能拿到x轴或者y轴方向上滑动偏移量。

@Composable
fun TestDraggable() {
    Text(
        text = "悬浮窗",
        Modifier
            .size(200.dp)
            .background(Color.Blue)
            .draggable(rememberDraggableState {
                Log.d(TAG, "TestDraggable: $it")
            }, Orientation.Horizontal)
    )
}

因为draggable也需要一个state,一般情况下都是会使用rememberDraggableState来生成一个DraggableState,我们看其回调值其实就是一个float类型的参数,意味着draggable就是用来检测一维方向上的偏移量。

@Composable
fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState {
    val onDeltaState = rememberUpdatedState(onDelta)
    return remember { DraggableState { onDeltaState.value.invoke(it) } }
}

还有一个参数,不是必填项,就是interactionSource,能够反映此时的用户与界面的交互状态,假设有个需求,当组件拖拽的时候,需要显示某个文案;停止拖拽之后显示另一个文案。

@Composable
fun TestDraggable() {
    val interaction = remember {
        MutableInteractionSource()
    }
    Column {
        Text(
            text = "悬浮窗",
            Modifier
                .size(200.dp)
                .background(Color.Blue)
                .draggable(rememberDraggableState {
                    Log.d(TAG, "TestDraggable: $it")
                }, Orientation.Horizontal, interactionSource = interaction)
        )
        val isDragged by interaction.collectIsDraggedAsState()
        if (isDragged) {
            Text(text = "正在拖拽")
        } else {
            Text(text = "静止状态中")
        }
    }
}

可以将MutableInteractionSource转换为可监听的State,当拖动状态发生变化时,可以监听到。

1.2.3 通过draggable实现拖拽效果

在前面我提到,所有的滑动组件都会使用到state,像draggable中使用到的rememberDraggableState可以拿到一维方向的偏移量,那么肯定能够在偏移量上做文章,实现拖拽效果。

@Composable
fun TestDraggable() {
    val interaction = remember {
        MutableInteractionSource()
    }
    // x轴的滑动距离
    var scrollX = remember {
        mutableStateOf(0)
    }
    Column {
        Text(
            text = "悬浮窗",
            Modifier
                .draggable(rememberDraggableState {
                    // 发起重组
                    scrollX.value += it.toInt()
                }, Orientation.Horizontal, interactionSource = interaction)
                .layout { measurable, constraints ->
                    val placeable = measurable.measure(constraints = constraints)
                    layout(placeable.width, placeable.height) {
                        //摆放位置
                        placeable.placeRelative(IntOffset(scrollX.value, 0))
                    }
                }
                .size(200.dp)
                .background(Color.Blue)
        )
        val isDragged by interaction.collectIsDraggedAsState()
        if (isDragged) {
            Text(text = "正在拖拽")
        } else {
            Text(text = "静止状态中")
        }
    }
}

例如记录一个scrollX,用于记录水平方向的偏移量,这个值是累加的,每次拖拽都会触发重组重新测量布局,在Modifier.layout函数中进行布局的重新摆放逻辑。

1.3 Compose中的滑动事件 – scrollable

在上一节中,介绍了draggable的使用,这一节将会介绍scrollable的使用,其实如果看过scrollable的源码,会发现它在底层还是通过draggable实现的。

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

那么为什么不统一用draggable,而是要单独加了一个scrollable?原因就是通过scrollable要做一些精细化的效果处理,例如:惯性滑动、嵌套滑动nestscroll、边界回弹等。

其实很好理解,draggable从字面意思上看就是拖拽,虽然滑动也是拖拽的一种,但是并不意味着所有的拖拽场景都需要所谓的惯性滑动、嵌套滑动,例如设置中的进度条。

来看下使用,我下面是用scrollable实现了组件的横向移动能力,

@Composable
fun TestScrollable() {
    val currentX = remember {
        mutableStateOf(0)
    }
    Text(
        text = "悬浮窗",
        Modifier
            .offset {
                IntOffset(currentX.value, 0)
            }
            .scrollable(rememberScrollableState {
                currentX.value += it.toInt()
                it
            }, Orientation.Horizontal)
            .size(200.dp)
            .background(Color.Blue)
    )
}

draggable一样,scrollable也需要一个state,Compose给提供好了就是rememberScrollableState函数。

@Composable
fun rememberScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableState {
    val lambdaState = rememberUpdatedState(consumeScrollDelta)
    return remember { ScrollableState { lambdaState.value.invoke(it) } }
}

但是需要注意的是,和rememberDraggableState不同的是,它需要一个返回值,这个返回值代表当前横滑的距离被某个组件消费了多少。 以便将剩余的滑动距离交给父容器或者子组件消费,这就是嵌套滑动的原理所在。

除此之外,scrollable中还提供了overscrollEffect参数,用于处理触边后的回弹效果,Compose中提供了默认的实现ScrollableDefaults.overscrollEffect()

flingBehavior则是用于处理惯性滑动,默认可以不传值,在底层使用默认值。

// 如果没有特殊的惯性滑动需求,底层使用默认值。
val fling = flingBehavior ?: ScrollableDefaults.flingBehavior()

所以scrollabledraggable的基础之上,增加了几种滑动效果的逻辑处理。

1.4 Compose的二维滑动

前面我在介绍draggablescrollable的时候说过,他们只支持一维方向的滑动,所以需要设置orientation属性,如果想要监听二维的滑动,Compose没有提供直接使用的API,需要Modifier.pointerInput来配合完成。

在1.1 小节中,我介绍点击事件的时候,提到过detectTapGestures可以从底层监听点击事件,那么如果想要监听二维滑动,那么可以通过detectDragGestures来完成。

Text(text = "二维滑动",
    Modifier
        .size(200.dp)
        .background(Color.Blue)
        .pointerInput(Unit){
            detectDragGestures { change, dragAmount -> 
            }
        })

detectDragGestures中,有两个参数,第一个参数:PointerInputChange,代表手指点按的信息,每一个手指按下都有对应的id和位置信息等,以此来处理手势的抬起和按下;第二个参数:代表的是手指滑动的位置信息,是一个Offset类型的数据,记录x和y轴的偏移量。

@Composable
fun TestMultiScroll() {
    val currentX = remember {
        mutableStateOf(0)
    }
    val currentY = remember {
        mutableStateOf(0)
    }
    Text(text = "二维滑动",
        Modifier
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                layout(placeable.width, placeable.height) {
                    placeable.placeRelative(currentX.value, currentY.value)
                }
            }
            .size(200.dp)
            .background(Color.Blue)
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    currentX.value += dragAmount.x.roundToInt()
                    currentY.value += dragAmount.y.roundToInt()
                }
            })
}

所以要实现悬浮窗的拖动,就可以使用detectDragGestures来实现。

2 Compose中的嵌套滑动

相关文章:

Android进阶宝典 — NestedScroll嵌套滑动机制实现吸顶效果

在之前的文章中,我详细介绍过在传统的View体系中,如何通过嵌套滑动机制完成一些需求,它的实现还是比较复杂的,需要实现NestScrollingParentNestScrollingChild接口。

而在Compose中实现嵌套滑动,在1.3小节中,我介绍过了Modifier.scrollable,其实就是在其基础之上实现。

2.1 Compose自有的嵌套滑动组件

在传统的View体系中,像RecyclerViewNestScrollView等,都具备嵌套滑动的能力,例如RecyclerView:

public class RecyclerView extends ViewGroup implements ScrollingView,
        NestedScrollingChild2, NestedScrollingChild3 { // ......}

它实现了NestedScrollingChild2接口,所以它具备嵌套滑动的能力。所以在Compose当中通过Modifier.scrollable实现的滑动组件,大概率都具备嵌套滑动的能力,比如LazyColumn

Compose编程思想 -- 触摸事件和嵌套滑动事件处理

gif可能看不太清楚,目前效果就是两个LazyColumn嵌套在一起,当滑动内部的LazyColumn的时候,外部的LazyColumn不会滑动,只有当内部的LazyColumn到底之后,外部的LazyColumn才可以继续滑动。

@Composable
fun TestNestScroll() {
    LazyColumn {
        item {
            LazyColumn(
                Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            ) {
                items(10) {
                    Text(
                        text = "child $it",
                        Modifier
                            .fillMaxWidth()
                            .height(30.dp)
                            .background(Color.Red)
                    )
                }
            }
        }
        items(20) {
            Text(
                text = "parent $it",
                Modifier
                    .fillMaxWidth()
                    .height(30.dp)
                    .background(Color.Blue)
            )
        }
    }
}

当然,作为程序员,面对一些定制化的需求,还是需要自己实现的。

2.2 自定义实现嵌套滑动

在Compose当中,提供了Modifier.nestedScroll来实现嵌套滑动,既然我要讲嵌套滑动,首先需要明确一下,嵌套滑动的原理:

其实嵌套滑动很简单,在Compose当中对于父容器是不会主动处理滑动事件,是子组件通过回调通知父容器是否需要滑动,通常是在子组件滑动之前「询问」父容器是否要消费滑动距离,以及在子组件滑动完成之后,也要询问父容器是否需要消费剩余的滑动距离。

ok,知道原理之后,就知道该做哪些事了!

  • 通知父容器是否消费事件,分两次进行;
  • 父容器接收到回调之后,选择是否处理事件消费

那么如何通知父容器是否消费事件,就是采用NestedScrollDispatcher来进行嵌套滑动的事件分发,也就是nestedScroll函数的第二个参数。

fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "nestedScroll"
        properties["connection"] = connection
        properties["dispatcher"] = dispatcher
    }
) {
    val scope = rememberCoroutineScope()
    // provide noop dispatcher if needed
    val resolvedDispatcher = dispatcher ?: remember { NestedScrollDispatcher() }
    remember(connection, resolvedDispatcher, scope) {
        resolvedDispatcher.originNestedScrollScope = scope
        NestedScrollModifierLocal(resolvedDispatcher, connection)
    }
}

接下来带大家实现一个嵌套滑动组件。

@Composable
fun TestNestScroll2() {
    var currentY = remember {
        mutableStateOf(0)
    }
    Column(
        Modifier
            .fillMaxWidth()
            .offset {
                IntOffset(0, currentY.value)
            }
            .draggable(rememberDraggableState {
                currentY.value += it.roundToInt()
            }, Orientation.Vertical)
    ) {
        for (index in 1..20) {
            Text(
                text = "第 $index 个组件",
                Modifier
                    .fillMaxWidth()
                    .height(20.dp)
            )
        }
    }
}

这个组件具备了上下滑动的能力,接下来会处理嵌套滑动的逻辑。

2.2.1 NestedScrollDispatcher

伙伴们重点看下NestedScrollDispatcher关于滑动事件分发函数的注释:

class NestedScrollDispatcher {
    // ......
    /**
     * 用于子组件处理滑动之前,回调通知父容器是否需要消费事件
     *
     * @param available 一次滑动事件的距离
     * @param source 滑动事件的来源
     *
     * @return 祖先节点,或者说父容器消费的滑动距离
     */
    fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
        return parent?.onPreScroll(available, source) ?: Offset.Zero
    }
    /**
     * 子组件滑动完成之后,再次通知父容器是否需要消费事件
     *
     * @param consumed 当前子组件消费的距离
     * @param available 当前父容器可以再次消费的剩余距离
     * @param source 滑动事件的来源
     *
     * @return the amount of scroll that was consumed by all ancestors
     */
    fun dispatchPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
    }
    // 下面两个惯性函数其实是一样的。
    suspend fun dispatchPreFling(available: Velocity): Velocity {
        return parent?.onPreFling(available) ?: Velocity.Zero
    }
    suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity {
        return parent?.onPostFling(consumed, available) ?: Velocity.Zero
    }
}

NestedScrollDispatcher就是用来通知父容器是否需要消费事件的工具,所以我对draggable的内部逻辑进行了修改。

Modifier.draggable(rememberDraggableState { duration ->
    //滑动前,通知父容器,有duration长度的滑动距离,要不要消费?
    val parentConsumed =
        dispatch.dispatchPreScroll(Offset(0f, duration), NestedScrollSource.Drag)
    //那么子组件能够消费的距离,需要减去父容器消费的距离,具体父容器消费多少,不需要关心
    val availableDuration = duration.roundToInt() - parentConsumed.y.roundToInt()
    currentY.value += availableDuration
    // 滑动结束之后,再次通知父容器,要不要消费?
    dispatch.dispatchPostScroll(
        Offset(0f, availableDuration.toFloat()), // 子组件消费了全部的剩余距离
        Offset.Zero, // 父容器可消费的滑动距离为0
        NestedScrollSource.Drag
    )
}, Orientation.Vertical)

2.2.2 NestedScrollConnection

那么子组件通过NestScrollDispatcher发起的回调,父容器在哪接收到呢?就是通过NestedScrollConnection,它是一个接口,所以在用的时候需要自己实现一个实例,或者创建一个匿名内部类都可以。

接口函数的注释可以阅读一下:

@JvmDefaultWithCompatibility
interface NestedScrollConnection {
    /**
     * Pre scroll event chain. 它会在子组件允许父容器消费滑动事件的时候回调,是在子组件滑动之前接收到的回调。
     *
     * @param available 父容器可以消费的滑动距离,即dispatch.dispatchPreScroll传入的第一个参数
     * @param source 滑动事件来源
     *
     * @return 当前组件消费多少的滑动事件
     */
    fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
    /**
     * Post scroll event pass. 子组件完成滑动之后会回调
     * @param consumed 子组件消费的滑动距离,即dispatch.dispatchPostScroll传入的第一个参数
     * @param available 父容器可以消费的滑动距离,即dispatch.dispatchPostScroll传入的第二个参数
     * @param source 滑动事件来源
     *
     * @return the amount that was consumed by this connection
     */
    fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset = Offset.Zero
    // 惯性的我先不管了
    suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
    suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return Velocity.Zero
    }
}

所以总体的嵌套滑动处理,在理解了其中的原理之后,其实就大概能写出来其中的核心逻辑了,当然这个demo不存在嵌套滑动的逻辑,我在内部再加一个LazyColumn。

@Composable
fun TestNestScroll2() {
    var currentY = remember {
        mutableStateOf(0)
    }
    //分发给父容器
    val dispatch = remember {
        NestedScrollDispatcher()
    }
    val connection = remember {
        object : NestedScrollConnection{
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // 假设全消费了
                Log.d(TAG, "onPreScroll: $available ")
                return available
            }
            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                Log.d(TAG, "onPostScroll: consumed $consumed available $available")
                //子组件滑动完成之后, 如果还有可用的距离,那么父组件就消费
                return super.onPostScroll(consumed, available, source)
            }
        }
    }
    Column(
        Modifier
            .fillMaxWidth()
            .offset {
                IntOffset(0, currentY.value)
            }
            .nestedScroll(connection, dispatch)
            .draggable(rememberDraggableState { duration ->
                //滑动前,通知父容器,有duration长度的滑动距离,要不要消费?
                val parentConsumed =
                    dispatch.dispatchPreScroll(Offset(0f, duration), NestedScrollSource.Drag)
                //那么子组件能够消费的距离,需要减去父容器消费的距离,具体父容器消费多少,不需要关心
                val availableDuration = duration.roundToInt() - parentConsumed.y.roundToInt()
                Log.d(TAG, "TestNestScroll2: availableDuration $availableDuration")
                currentY.value += availableDuration
                // 滑动结束之后,再次通知父容器,要不要消费?
                dispatch.dispatchPostScroll(
                    Offset(0f, availableDuration.toFloat()), // 子组件消费了全部的剩余距离
                    Offset.Zero, // 父容器可消费的滑动距离为0
                    NestedScrollSource.Drag
                )
            }, Orientation.Vertical)
    ) {
        for (index in 1..20) {
            Text(
                text = "第 $index 个组件",
                Modifier
                    .fillMaxWidth()
                    .height(20.dp)
            )
        }
        //内部嵌套一个LazyColumn。
        LazyColumn(Modifier.height(80.dp)){
            items(10){
                Text(
                    text = "第 $it 个内部组件",
                    Modifier
                        .fillMaxWidth()
                        .height(20.dp)
                )
            }
        }
    }
}

先看下这个布局结构

Compose编程思想 -- 触摸事件和嵌套滑动事件处理

当子组件滑动的时候,自定义的ScrollView(父容器)会首先收到子组件的回调,在onPreScroll中处理决定是否要消费事件:

  • 假设在onPreScroll中,父容器消费了全部的滑动距离,那么内部的LazyColumn就不能滑动了;
  • 默认不处理onPreScroll,在子组件不能再滑动的时候,就继续滑动父容器,需要做下面的逻辑。
val connection = remember {
    object : NestedScrollConnection{
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            Log.d(TAG, "onPostScroll: consumed $consumed available $available")
            //子组件滑动完成之后, 如果还有可用的距离,那么父组件就消费
            currentY.value += available.y.toInt()
            return available
        }
    }
}

因为子组件已经无法滑动了,因此所有的事件子组件不再消费,从而在onPostScroll中会将事件原封不动的回调给父容器,父容器从而消费事件继续滑动。

如果把定义的ScrollView放在其他的容器中,那么其自身就会成为子组件,会执行draggable中的嵌套滑动逻辑。