Compose 实践与探索十七 —— 多指手势与自定义触摸反馈

news2025/4/1 20:51:56

上一节我们讲了滑动的手势识别以及嵌套滑动,二者都属于触摸反馈这个大的范畴内的知识。本节我们将深入触摸反馈这个话题,讲一讲多指手势的识别与完全自定义的触摸反馈的实现。

1、多指手势

多指手势可以分为两类:

  1. 利用 API 处理预设好的手势
  2. 自定义的多指手势识别:自己分析触摸到屏幕上的每一根手指的滑动轨迹,然后识别对应的手势

本节讲解第 1 种,下节介绍第 2 种。

Compose 提供了三种多指手势的识别:移动、放缩与旋转,它们都存在于 detectTransformGestures 函数中,该函数也需要在 pointerInput() 内使用。我们先来看该函数的参数:

/**
* 一个用于旋转、平移和缩放的手势检测器。一旦达到触摸阈值,用户可以使用旋转、平移和缩放手势。
* 当发生旋转、缩放或平移中的任何一种手势时,将调用 onGesture,传递旋转角度(以度为单位)、
* 缩放比例因子和像素偏移量。每个改变都是前一次调用和当前手势之间的差异。在触摸阈值之后,这将
* 消耗所有位置变化。onGesture 还将提供所有已按下指针的中心点。
*
* 如果 panZoomLock 设置为 true,则只有在检测到旋转的触摸阈值之前才允许旋转,然后才能进行平移
* 或缩放动作。否则,将检测到平移和缩放手势,但不会检测到旋转手势。如果 panZoomLock 设置为 false,
* 则一旦触摸阈值被触发,将检测到所有三种手势。
*/
suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)

第一个参数 panZoomLock 是一个开关,分为两种情况:

  • 当它为 false 时,三种手势可以同时识别
  • 当它为 true 时,如果先识别到旋转操作,那么就不会再监测滑动和缩放;如果先监测到滑动或缩放,那么就不会再监测旋转。相当于是把滑动和缩放放在一组,旋转单独放在另外一组,只监测先触发的那组操作

第二个参数 onGesture 参数是一个回调函数,它的参数含义如下:

  • centroid:所有按下手指的中心点。这是一个辅助参数,需要配合后面三个参数使用
  • pan:位移参数,表示中心点 centroid 在这一时刻与上一时刻的位置偏移量
  • zoom:这一时刻与上一时刻相比的放缩倍数
  • rotation:这一时刻与上一时刻相比的旋转角度

2、完全自定义触摸算法

前面我们讲了很多半自动化 API 进行手势识别,那些 API 可以覆盖绝大多数的使用场景。本节我们来介绍,如何通过底层 API 实现完全自定义的触摸算法。

2.1 写法、思维逻辑与代码框架

完全自定义触摸算法意味着要从获取触摸事件开始,自己对每一个触摸事件进行处理。

与前面介绍过的半自动化 API 类似的是,需要在 Modifier.pointerInput() 中调用底层 API 来接收触摸事件,这个底层 API 是 AwaitPointerEventScope 接口内的 awaitPointerEvent():

@RestrictsSuspension
@JvmDefaultWithCompatibility
interface AwaitPointerEventScope : Density {
    /**
     * 挂起协程,直到指定的输入通道(input pass)报告 PointerEvent 事件,pass 参数默认值是
     * PointerEventPass.Main。
     * [awaitPointerEvent] 会在受限制的挂起作用域中以同步的方式恢复执行。这意味着调用者在
     * [awaitPointerEvent] 返回后可以立即对输入做出反应,并同时影响当前帧和输入处理管道的下一个
     * 处理程序或阶段。调用者应在等待下一个事件前,对返回的 [PointerEvent] 进行修改,以便在输入处
     * 理流程的下一阶段运行前消费该事件的某些处理结果。
     */
    suspend fun awaitPointerEvent(
        pass: PointerEventPass = PointerEventPass.Main
    ): PointerEvent
}

它的大致使用模式如下:

setContent {
    // 尾随 lambda 是挂起函数环境的
    Modifier.pointerInput(Unit) {
        // 事件对象
        var event: PointerEvent
        // 提供调用 awaitPointerEvent() 的环境 AwaitPointerEventScope
        awaitPointerEventScope {
            while (true) {
                // 挂起以获取触摸事件
                event = awaitPointerEvent()
            }
        }
    }
}

awaitPointerEvent() 的返回值类型是 PointerEvent,该类型内部封装了原生的触摸事件类型 MotionEvent。 通过 PointerEvent 的 type 属性可以获取事件的具体类型:

    actual var type: PointerEventType = calculatePointerEventType()
        internal set

    private fun calculatePointerEventType(): PointerEventType {
        val motionEvent = motionEvent
        if (motionEvent != null) {
            return when (motionEvent.actionMasked) {
                MotionEvent.ACTION_DOWN,
                MotionEvent.ACTION_POINTER_DOWN -> PointerEventType.Press
                MotionEvent.ACTION_UP,
                MotionEvent.ACTION_POINTER_UP -> PointerEventType.Release
                MotionEvent.ACTION_HOVER_MOVE,
                MotionEvent.ACTION_MOVE -> PointerEventType.Move
                MotionEvent.ACTION_HOVER_ENTER -> PointerEventType.Enter
                MotionEvent.ACTION_HOVER_EXIT -> PointerEventType.Exit
                ACTION_SCROLL -> PointerEventType.Scroll

                else -> PointerEventType.Unknown
            }
        }
        ...
        return PointerEventType.Move
    }

在 calculatePointerEventType() 中能看出原生的 MotionEvent 的类型在 Compose 中对应 PointerEventType 的哪种类型。需要注意的是,原生中把第一个手指按下的类型定义为 ACTION_DOWN,把非第一个手指按下的类型定义为 ACTION_POINTER_DOWN。但是在 Compose 中,将按下类型都合并为 Press。同样的,原生的最后一个手指抬起是 ACTION_UP,非最后一个手指抬起是 ACTION_POINTER_UP,在 Compose 中也被合并为 Release。

此外,我们还应注意到,Compose 获取事件的代码形式与原生不同。原生是在发生触摸事件后通过回调 onTouchEvent() 传入具体的事件类型实现的事件监听:

class CustomView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {...}
            MotionEvent.ACTION_POINTER_DOWN -> {...}
            else -> {...}
        }
        return super.onTouchEvent(event)
    }
}

而 Compose 则需要自己循环调用 awaitPointerEvent() 去获取发生的事件 PointerEvent,然后通过 PointerEvent 的 type 属性去判断具体的触摸事件类型。看似是 Compose 要比 Android 原生更麻烦一些,但实际上这并不是 Compose 与原生的区别,而是传统的 Java 回调与 Kotlin 协程在写法上的不同造成的。协程以线性代码完成了异步操作,从整体上来讲,这样的写法要比回调更加直观和简单。

接下来举个例子,自己实现一个点击监听器:

fun Modifier.clickListener(onClick: () -> Unit) = pointerInput(Unit) {
    awaitPointerEventScope {
        // 循环接收所有的触摸事件
        while (true) {
            // 先接收一串触摸事件的第一个事件,按下事件
            val down = awaitPointerEvent()
            // 循环接收后续事件
            while (true) {
                val event = awaitPointerEvent()
                // 滑动事件不能超出组件范围,否则抬起时不响应 onClick
                if (event.type == PointerEventType.Move) {
                    val position = event.changes[0].position
                    if (position.x < 0 || position.x > size.width || position.y < 0 || position.y > size.height) {
                        break
                    }
                } else if (event.type == PointerEventType.Release) {
                    onClick()
                    break
                }
            }
        }
    }
}

以上是实现的比较粗糙的点击监听器,在组件范围内按下并抬起后会回调 onClick 参数,但如果是在组件范围外抬起,就跳出本次的事件监听,从下一次的按下事件重新开始。监听过程中,使用了 PointerEvent 的 changes 数组:

actual val changes: List<PointerInputChange>

该数组的元素类型 PointerInputChange 是触摸事件的核心数据结构,用于描述单个指针(如手指、触控笔或鼠标)的输入状态变化,它是手势处理系统中最细粒度的操作单元。changes 数组就是所有指针触摸状态的变化,示例代码中只取了第一个元素,实际上就是只针对第一个按下的指针进行了状态的判断,没有支持多指逻辑。

下面我们可以试着对示例代码进行优化。

首先,使用在 1.4.0-alpha01 版本中新增的 awaitEachGesture() 可以简化示例代码,它可以帮我们实现连续手势的检测,因此就不用在最外层加一个 while(true) 了:

fun Modifier.clickListener(onClick: () -> Unit) = pointerInput(Unit) {
    awaitEachGesture {
        // 先接收一串触摸事件的第一个事件,按下事件
        val down = awaitPointerEvent()
        // 循环接收后续事件
        while (true) {
            val event = awaitPointerEvent()
            // 滑动事件不能超出组件范围,否则抬起时不响应 onClick
            if (event.type == PointerEventType.Move) {
                val position = event.changes[0].position
                if (position.x < 0 || position.x > size.width || position.y < 0 || position.y > size.height) {
                    break
                }
            } else if (event.type == PointerEventType.Release) {
                onClick()
                break
            }
        }
    }
}

此外,要对抬起和按下事件做细致的区分。因为前面我们贴出相关代码了,Compose 将任何手指的抬起和按下都定义为 Press 和 Release 类型,不同的手指按下与抬起的效果应该是不同的。对于示例代码而言,应该是最后一个手指抬起(对应原生的 MotionEvent.ACTION_UP)才认为发生了点击事件,所以要添加一个“抬起的是最后一个手指”的条件,

fun Modifier.clickListener(onClick: () -> Unit) = pointerInput(Unit) {
    awaitEachGesture {
        val down = awaitPointerEvent()
        while (true) {
            val event = awaitPointerEvent()
            if (event.type == PointerEventType.Move) {
                val position = event.changes[0].position
                if (position.x < 0 || position.x > size.width || position.y < 0 || position.y > size.height) {
                    break
                }
            } else if (event.type == PointerEventType.Release && event.changes.size == 1) { 
                // 添加抬起时只有一个手指的条件
                onClick()
                break
            }
        }
    }
}

关于判断手指抬起的更详细逻辑,可以参考 AwaitPointerEventScope.waitForUpOrCancellation() 的代码:

suspend fun AwaitPointerEventScope.waitForUpOrCancellation(
    pass: PointerEventPass = PointerEventPass.Main
): PointerInputChange? {
    while (true) {
        val event = awaitPointerEvent(pass)
        // 判断所有手指都抬起了
        if (event.changes.fastAll { it.changedToUp() }) {
            // All pointers are up
            return event.changes[0]
        }

        if (event.changes.fastAny {
                it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
            }
        ) {
            return null // Canceled
        }

        // Check for cancel by position consumption. We can look on the Final pass of the
        // existing pointer event because it comes after the pass we checked above.
        val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
        if (consumeCheck.changes.fastAny { it.isConsumed }) {
            return null
        }
    }
}

获取按下事件的函数也可以优化为 awaitFirstDown():

fun Modifier.clickListener(onClick: () -> Unit) = pointerInput(Unit) {
    awaitEachGesture {
        // 先接收一串触摸事件的第一个事件,按下事件
        val down = awaitFirstDown()
        // 循环接收后续事件
        while (true) {
            val event = awaitPointerEvent()
            // 滑动事件不能超出组件范围,否则抬起时不响应 onClick
            if (event.type == PointerEventType.Move) {
                val position = event.changes[0].position
                if (position.x < 0 || position.x > size.width || position.y < 0 || position.y > size.height) {
                    break
                }
            } else if (event.type == PointerEventType.Release && event.changes.size == 1) {
                onClick()
                break
            }
        }
    }
}

2.2 触摸事件的消费、拦截、取消

Android 原生为什么会有事件的消费、拦截和取消这些概念呢?因为手势可能会冲突。比如用户在屏幕上点击了一下,那么它点击到的是子组件还是父组件呢?这要看哪个组件消费了这个点击事件。结合实际场景,可能会有两种不同的表现:

  1. 在一个列表中,每个列表项上都有一个点赞按钮,当用户点击点赞按钮时,它触发的点赞按钮而不是整个列表项的点击。因为用户想点击的一定是表面的那个组件,所以 Android 通常会让子组件优先消费,只有在子组件不消费时才会让父组件消费
  2. 另外一种情况,还是在滑动列表中,点在一个列表项上然后向上滑动,用户是要滑动父组件,而不是点击列表项。此时就是父组件拦截了该事件,这个拦截发生在子组件消费之前

总的来说,原生的触摸事件的处理分为拦截与消费两大步骤,在子组件消费之前,会向上按照父组件到根组件的方向去询问是否要进行拦截,如果有任意一个父组件拦截了事件,那么就由该组件消费事件,并向子组件发送一个取消事件,在手指抬起之前,后续的所有事件都不会再发送给子组件。

而假如没有任何父级组件拦截事件,该事件就由子组件开始消费,只有在子组件不消费该事件时,才会一级一级地向父组件的方向传递,查看是否消费。

因此,在原生中,拦截是一个较为强势的过程,只有父组件不拦截才轮到子组件消费。

而在 Compose 中,不再有拦截的概念,只有消费的概念,或者说,把拦截的过程合并到消费中了。首先,事件会先传给父组件,无论父组件是否消费该事件,它都会继续向下传导到子组件中,只不过,如果父组件消费了事件,那么子组件就无法消费该事件了,但是可以利用事件已经被消费的事实去做一些别的事情,然后再次把该事件传回给父组件,形成一个父 -> 子 -> 父的事件传递链。最后再传回给父组件,是为了给嵌套滑动提供基础支持。这是原生的拦截 -> 消费过程所不具备的。

Compose 的事件消费原则:

  • 使用事件前,先检查该事件是否已经被消费了,一般是没有被消费才可被使用,但也有例外,比如滑动过程是不介意按下事件是否被消费了的
  • 使用完某个事件,一般应该消费掉该事件。但肯定也会有特殊场景,比如不想因为对事件的处理影响整体流程,那么处理完事件后也可以不消费掉它

最后来说一下相关的 API。

此前我们说过了 awaitPointerEvent(),它的参数 pass 的默认值为 PointerEventPass.Main,PointerEventPass 实际上就是定义了事件传递流程的枚举类:

enum class PointerEventPass {
    Initial, Main, Final
}

Initial 表示事件第一次由父组件传递给子组件的过程,Main 表示事件由子组件传给父组件的过程,而 Final 表示事件第二次父组件到子组件的过程。当我们一起使用这三个参数时,得到的其实是同一个事件对象:

// event1、event2、event3 是同一个 PointerEvent 对象
val event1 = awaitPointerEvent(PointerEventPass.Initial)
val event2 = awaitPointerEvent(PointerEventPass.Main)
val event3 = awaitPointerEvent(PointerEventPass.Final)

消费事件与检查事件是否已经被消费:

event2.changes[0].consume()
event2.changes[0].isConsumed

完整的代码可以参考 Compose 官方的滑动监听、长按监听等源码。

2.3 多指手势和多重手势

多重手势是指多个手指可以同时进行旋转、滑动、缩放手势,即一个手势可以起多重作用。

如何计算多指手势?对多个触摸点做综合计算,比如说多指滑动,先拿到滑动事件,计算所有手指的中心点,然后用这一时刻中心点与上一时刻中心点的差值作为滑动位移。中心点就用所有点的坐标加和除以触摸点的个数即可。 当然这个有现成函数可以用,比如 calculatePan() 就是计算多指滑动的中心点的 API:

val event = awaitPointerEvent()
event.calculatePan()

其余的还有 calculateRotation()、calculateZoom()、calculateCentroid() 等。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2325417.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

centos8上实现lvs集群负载均衡nat模式

1.背景&#xff1a; 个人&#xff08;菜鸟&#xff09;学习笔记&#xff0c;学点记下来&#xff0c;给未来的自己看。高手看了也请多指点。 按照课程讲&#xff0c;lvs是我国大神开发的负载均衡程序&#xff0c;被收录进内核&#xff0c;只要安装时内核里有它&#xff0c;它就…

影响HTTP网络请求的因素

影响 HTTP 网络请求的因素 1. 带宽 2. 延迟 浏览器阻塞&#xff1a;浏览器会因为一些原因阻塞请求&#xff0c;浏览器对于同一个域名&#xff0c;同时只能有4个连接&#xff08;这个根据浏览器内核不同可能会有所差异&#xff09;&#xff0c;超过浏览器最大连接数限制&…

(UI自动化测试web端)第二篇:元素定位的方法_css定位之css选择器

看代码里的【find_element_by_css_selector( )】( )里的表达式怎么写&#xff1f; 文章介绍了第三种写法css选择器&#xff0c;你要根据网页中的实际情况来判断自己到底要用哪一种方法来进行元素定位。每种方法都要多练习&#xff0c;全都熟了之后你在工作当中使用起来元素定位…

MPU6050模块详解:从原理到STM32驱动指南(上) | 零基础入门STM32第八十九步

主题内容教学目的/扩展视频加速度传感器电路连接。手册分析。驱动程序&#xff0c;读出数据。能读出3轴数据。 师从洋桃电子&#xff0c;杜洋老师 &#x1f4d1;文章目录 一、MPU6050模块介绍1.1 核心特性1.2 模块化优势 二、MPU6050模块连接方法2.1 硬件连接2.2 电源注意事项 …

STM32 MODBUS-RTU主从站库移植

代码地址 STM32MODBUSRTU: stm32上的modbus工程 从站 FreeModbus是一个开源的Modbus通信协议栈实现。它允许开发者在各种平台上轻松地实现Modbus通信功能&#xff0c;包括串口和以太网。FreeMODBUS提供了用于从设备和主站通信的功能&#xff0c;支持Modbus RTU和Modbus TCP协…

架构师面试(二十二):TCP 协议

问题 今天我们聊一个非常常见的面试题目&#xff0c;不管前端还是后端&#xff0c;也不管做的是上层业务还是底层框架&#xff0c;更不管技术方向是运维还是架构&#xff0c;都可以思考和参与一下哈&#xff01; TCP协议无处不在&#xff0c;我们知道 TCP 是基于连接的端到端…

程序自动化填写网页表单数据

1 背景介绍 如何让程序自动化填写网页表单数据&#xff0c;特别是涉及到批量数据情况时&#xff0c;可以减少人力。下面是涉及到的一些场景&#xff0c;都可以通过相关自动化程序实现。 场景1 场景1&#xff0c;领导安排&#xff0c;通过相关省、市、县、乡镇数据&#xff0…

Razer macOS v0.4.10快速安装

链接点这里下载最新的 .dmg 文件。将下载的 .dmg 映像文件拖入 应用程序 文件夹中。若首次打开时出现安全警告【什么扔到废纸篓】&#xff0c;这时候点击 Mac 的“系统偏好设置”-> “安全性与隐私”-> “通用”&#xff0c;然后点击底部的 “打开”。【或者仍然打开】 对…

如何自动化同义词并使用我们的 Synonyms API 进行上传

作者&#xff1a;来自 Elastic Andre Luiz 了解如何使用 LLM 来自动识别和生成同义词&#xff0c; 使术语可以通过程序方式加载到 Elasticsearch 同义词 API 中。 提高搜索结果的质量对于提供高效的用户体验至关重要。优化搜索的一种方法是通过同义词自动扩展查询词。这样可以更…

一. 相机模组摆放原理

1. 背景&#xff1a; 相机开发时经常出现因模组摆放问题&#xff0c;导致相机成像方向异常。轻则修改软件、模组返工&#xff0c; 重则重新修改堆叠&#xff0c;影响相机调试进度。因此&#xff0c;设计一个模型实现模组摆放纠错很有必要。 2. 原理&#xff1a; 2.1 口诀&am…

【C++游戏引擎开发】《线性代数》(1):环境配置与基础矩阵类设计

一、开发环境配置 1.1 启用C 20 在VS2022中新建项目后右键项目 1.2 启用增强指令集 1.3 安装Google Test vcpkg安装使用指南 vcpkg install gtest:x64-windows# 集成到系统目录&#xff0c;只需要执行一次&#xff0c;后续安装包之后不需要再次执行 vcpkg integrate inst…

sqli-labs靶场 less 8

文章目录 sqli-labs靶场less 8 布尔盲注 sqli-labs靶场 每道题都从以下模板讲解&#xff0c;并且每个步骤都有图片&#xff0c;清晰明了&#xff0c;便于复盘。 sql注入的基本步骤 注入点注入类型 字符型&#xff1a;判断闭合方式 &#xff08;‘、"、’、“”&#xf…

基于大模型的知识图谱搜索的五大核心优势

在传统知识图谱与生成式AI融合的浪潮中&#xff0c;基于大模型的知识图谱搜索正成为新一代智能检索的标杆技术&#xff0c;飞速灵燕智能体平台就使用了该技术&#xff0c;其核心优势体现在&#xff1a; 1. 语义穿透力升级 突破关键词匹配局限&#xff0c;通过大模型的深层语义…

【MySQL】从零开始:掌握MySQL数据库的核心概念(五)

由于我的无知&#xff0c;我对生存方式只有一个非常普通的信条&#xff1a;不许后悔。 前言 这是我自己学习mysql数据库的第五篇博客总结。后期我会继续把mysql数据库学习笔记开源至博客上。 上一期笔记是关于mysql数据库的增删查改&#xff0c;没看的同学可以过去看看&#xf…

Java版Manus实现来了,Spring AI Alibaba发布开源OpenManus实现

此次官方发布的 Spring AI Alibaba OpenManus 实现&#xff0c;包含完整的多智能体任务规划、思考与执行流程&#xff0c;可以让开发者体验 Java 版本的多智能体效果。它能够根据用户的问题进行分析&#xff0c;操作浏览器&#xff0c;执行代码等来完成复杂任务等。 项目源码及…

鸿蒙UI开发

鸿蒙UI开发 本文旨在分享一些鸿蒙UI布局开发上的一些建议&#xff0c;特别是对屏幕宽高比发生变化时的应对思路和好的实践。 折叠屏适配 一般情况&#xff08;自适应布局/响应式布局&#xff09; 1.自适应布局 1.1自适应拉伸 左右组件定宽 TypeScript //左右定宽 Row() { …

Elasticsearch-实战案例

一、没有使用Elasticsearch的查询速度698ms 1.数据库模糊查询不走索引&#xff0c;在数据量较大的时候&#xff0c;查询性能很差。需要注意的是&#xff0c;数据库模糊查询随着表数据量的增多&#xff0c;查询性能的下降会非常明显&#xff0c;而搜索引擎的性能则不会随着数据增…

IP数据报报文格式

一 概述 IP数据报由两部分组成&#xff1a;首部数据部分。首部的前一部分是固定长度&#xff0c;一共20字节大小&#xff0c;是所有IP数据报文必须具有的&#xff1b;固定部分后面是一些可选字段&#xff0c;其长度是可变的。 二 首部固定部分各字段意义 &#xff08;1&…

openEuler24.03 LTS下安装Kafka集群

目录 前提条件 Kafka集群规划 下载Kafka 解压 设置环境变量 配置Kafka 分发到其他机器 分发安装文件 分发环境变量 启动Kafka 测试Kafka 关闭Kafka 集群启停脚本 问题及解决 前提条件 安装好ZooKeeper集群&#xff0c;可参考&#xff1a;openEuler24.03 LTS下安…

qt QQuaternion详解

1. 概述 QQuaternion 是 Qt 中用于表示三维空间中旋转的四元数类。它包含一个标量部分和一个三维向量部分&#xff0c;可以用来表示旋转操作。四元数在计算机图形学中广泛用于平滑的旋转和插值。 2. 重要方法 默认构造函数 QQuaternion::QQuaternion(); // 构造单位四元数 (1…