降Compose十八掌之『时乘六龙』| Advanced Gestures

公众号「稀有猿诉」        原文链接 降Compose十八掌之『时乘六龙』| Advanced Gestures

通过前面的一篇文章我们学会了如何用各种高级别抽象的扩展函数来进行手势处理。像clickable,draggable,scrollable和anchoredDraggable都是类似于View系统中的各种回调(如onClick或者onScroll)是高级别的抽象,这里我们只能处理手势,大部分情况下这也够用了。
在这里插入图片描述

但是,对于一些复杂的交互 场景需要我们先识别手势,然后再处理手势,这时就不能再用封装好的扩展函数了,而必须要用到像View系统中的onTouchEvent那样的低级别的回调,直接拿到触点事件,然后再识别手势,最后再处理手势。这篇文章就学习一下如何使用Jetpack Compose中的低级别函数来识别和处理手势。

处理原始触点事件

除了使用一些封装好了的扩展函数来直接处理某个手势以外,还可以直接接收原始的事件输入。

Jetpack Compose在Modifier中提供了扩展函数pointerInput来接收原始的触点事件,与View系统中的onTouch是类似的,这是触点事件的低级别的API。通过此API能获得触点事件,之后可以进行手势识别和手势处理,因为拿到的是原始的触点事件,所以很多逻辑要自己写,有些麻烦,但因为完全可控,所以可以实现一些更为复杂的手势识别和手势处理,比如任意方向的拖拽和滑动,长按后的拖拽,多点触控等等。

接收触点事件

扩展函数pointerInput接收三个参数,前两个都是作为事件处理回调的标识(keys),第三个参数是事件处理回调,是一个尾部lambda,当某个key发生变化,这个lambda会重新执行,否则即使发生重组(Recomposition),这个lambda也会不会重新执行。

fun Modifier.pointerInput(key1: Any?, key2: Any?, block: suspend PointerInputScope.() -> Unit): Modifier

第三参数就是我们要提供的事件处理回调,它是运行在PointerInputScope上下文中的lambda,这个上下文作用域里面有很多扩展函数可以直接使用。仔细看这个lambda是suspend的,这是因为输入事件可能不是即时的,可能会有等待的情况,也就是说lambda是在有事件的时候才会执行。PointerInputScope中的函数也都定义为suspend的,这些函数在lambda中可以直接调用,所以lambda本身也必须 是suspend的。

比如说想要打印触点事件,就可以这样写:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

filter是一个事件的类型参数当作key,也当作过滤条件。在事件处理回调lambda中,用awaitPointerEvent获得每一个触点事件,然后打印出来,awaitPoniterEventScope函数是创建一个协程上下文作用域用以等待事件输入,在其内调用awaitPointerEvent来获得事件。

识别手势

虽然pointerInput是一个低级别的接口,但也并不意味着所有的逻辑都必须从头写,在PointerInputScope中已经定义了大量的函数可以识别大部分手势:

比如像前面文章中提到的拖拽也可以用pointerInput实现:

@Composable
private fun DraggableTextLowLevel() {
    Box(modifier = Modifier.fillMaxSize()) {
        var offsetX by remember { mutableStateOf(0f) }
        var offsetY by remember { mutableStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
        )
    }
}

detect_drag_demo

前面提到了像detectTapGestures和detectDragGestures都是suspend的函数,所以在一个pointerInter的lambda中只能有一个,比如像下面这样写detectDragGestures不会得到执行:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // 不会得到执行,走不到这里,前面一个是suspend的
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

一个可行的解决办法就是可以写多个pointerInput,每个ponterInput处理一种手势:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

直接处理事件

遇到交互比较复杂的场景,或者当PointerInputScope中提供的识别函数不能解决问题时,或者需要把几种不同的手势组合在一起时,就需要直接处理事件。像View系统中的onTouch一样,我们需要知道不同的事件类型,比如pointer down,pointer move和pointer up等。PointerInputScope中提供了一个函数awaitEachGesture可以取代while (true)来获得每个事件;awaitFirstDown,是手势的开始相当于ACTION_DOWN;waitForUpOrCancellation是事件结束,相当于ACTION_UP和ACTION_CANCEL;drag相当于ACTION_MOVE。我们来看一个例子:

@Composable
private fun LogPointerEvents() {
    var log by remember { mutableStateOf("") }
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text(log)
        Box(
            Modifier
                .size(240.dp)
                .background(Color.DarkGray)
                .pointerInput(Unit) {
                    awaitEachGesture {
                        val down = awaitFirstDown().also {
                            log = "Action Down"
                        }
                        var change = awaitTouchSlopOrCancellation(down.id) { ch, _ ->
                            ch.consume()
                        }
                        while (change != null && change.pressed) {
                            change = awaitDragOrCancellation(change.id)
                            if (change != null && change.pressed) {
                                log = "Action Move ${change.type} ${change.position}"
                            }
                        }
                        log = "Action Up"
                    }
                }
        )
    }
}

event.gif

需要注意的是这些函数都是suspend的,也就是说当预期的行为发生时才会带着结果返回,比如awaitFirstDown()当有第一个触点事件发生时结束suspend然后返回;awaitTouchSlopOrCancellation当有超过拖拽阈值时结束suspend然后返回;awaitDragOrCancellation当有拖拽发生时结束suspend返回。

触点事件的派发流程

为了更好的处理事件,需要了解一下Jetpack Compose的事件派发流程,与View系统是类似的,事件派发的过程也是沿着Composable的树形结构,从父Composable到子Composable,同一层级的顺序则是从上到下,从前到后(Z轴方向),依次做『Hit test』,直到事件被消费,就停止派发。

事件的消费过程则是反过来,子Composable如果未消费就返回给父Composable,前面的Composable未消费,就继续向下传递,直到事件被消费。

如果是自己在pointerInput中直接处理事件,就要特别注意手动的把事件给消费掉,否则可能会继续传递。像awaitPointerEvent,awaitFirstDown,awaitDragOrCancellation等返回的都是PointerInputChange对象,调用它的consume()方法即可把事件消费掉。再比如像上面的例子awaitTouchSlopOrCancellation中,也需要手动的把事件给消费掉,如果把ch.consume()这句删除,就会发现awaitDragOrCancellation不会得到执行,这是因为awaitTouchSlopOrCancellation这个方法还在执行中,调用ch.consume()把事件消费掉,这个函数才会返回。

这也说明了,还是要尽量用系统封装好的手势识别和手势处理函数,不到万不得已不要直接处理原始事件,因为逻辑写起来肯定相当复杂。

多点触控

多点触控是超过一个触点同时在屏幕上操作,最为常见的手势就是旋转和缩放,可以使用扩展函数transformable监听旋转,平移和缩放手势,其中平移单个触点也能触发,缩放和旋转则需要两个触点,当超过3个触点时这个函数不会回调,也即不会触发任何手势:

@Composable
private fun TransformableSample() {
    // set up all transformation states
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        rotation += rotationChange
        offset += offsetChange
    }
    Box(
        Modifier
            // 把参数应用到图层去做变幻
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            // 接收变幻手势
            .transformable(state = state)
            .background(Color.Blue)
            .fillMaxSize()
    )
}

multi_touch_demo

除了直接使用Modifier.transformable以外,还可以用前面提到过的pointerInput中的detectTransformGestures这个函数也能得到平移,旋转和缩放的变化数值,把这些数值应用到graphicsLayer去做变幻就可以了,使用detectTransformGestures的另一个好处是可以与其他的手势结合起来。

总结

通过本文我们学习如何得到原始的触点事件,以及如何识别手势,相信对触点事件以及手势识别有了更深入的理解,并且借助这些扩展函数就可以写出交互性更好的应用程序界面。即使遇到一些复杂的交互 场景,或者需要组合多种手势时,也都能从容应对。

References

subscription

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

  • 27
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值