Android手势拦补点

一、 前言

在Android日常开发中,我们时有处理业务中手势的需求,即:基于事件的拦截、分发、消费三个回调,判断手势逻辑。

我们知道,当一个View消费了ACTION_DOWN事件,才可以接受到后续的事件,反之无法收到后续事件。那么如果一个View消费了事件后,判断为自己不需要的事件,又想将事件重新传递给子View处理怎么办呢?

这就引出了本文的要点——手势拦补点操作,以Android Q为例,进行介绍。


二、 拦点

1. 什么是拦点

拦点,即拦截触摸事件点位,不让事件向下传递。这并非我们常规的onInterceptTouchEvent方法中返回一个true来实现拦截这么简单,而是宏观上的以应用或窗口为单位的拦截,比如Android Q开始原生支持的手势交互。


2. 为什么拦点

以OPPO手机ColorOS 7为例,在设置-便捷辅助-导航键中选择两侧滑动手势,打开一个应用,我们在屏幕两侧偏下的边缘,向内滑动,可以触发手势操作:

在这里插入图片描述

而在这个位置点击,如果对应位置有按钮之类的控件(比如设置页的Preference项),也可以响应点击事件:

在这里插入图片描述

根据这些,我们提出疑惑,手势图形和应用页面二者应该属于不同的窗口,且手势的窗口层级高于应用,一个事件同一时刻应该只有一个地方可以消费,那么这里是如何实现二者均可以消费呢?这便是基于拦点实现的。


3. 怎么拦点

由于这部分功能的具体实现,位于每个ROM厂商的非开放仓库中,这里仅从技术侧介绍大致的实现思想,不同厂商之间实现原理大致相同:

对于一个窗口,事件的源头为ViewRootImpl类中InputEventReceiver的实例,其接收来自于硬件层传感器经Framework层传到App层的Input事件,并通过DecorView向整个View树进行深度优先的传递。

所以,每一个MotionEvent通过注册有InputEventReceiver的事件通道,先经过ViewRootImpl,之后才会传递到应用中的View。

当事件来到,在ViewRootImpl中会进行拦截和判断,是否满足当前用户选择的手势的判定,同时将每一个事件点存进cache中。假设以10个点为阈值,10个点内判断手势成功,则响应手势的逻辑。如果10个点判断手势失败,则不再判断手势,将cache中的点位全部分发下去,且后续的事件来到后也不再拦截,直接分发。

  • ViewRootImpl中做的这套拦截逻辑,即为拦点,它避免了手势的事件误传到应用层。
  • 手势图形绘制位于另一个窗口层级更高的常驻进程,从而保证绘制的层级能在绝大部分窗口之上,其也注册了InputEventReceiver作为事件通道。
  • cache中的点位重新分发的过程即为补点,这将在下一节介绍。

三、 补点

1. 什么是补点

补点,即补充触摸事件点位,让事件重新沿系统传递链传递。这也并非我们主动去调用dispatchTouchEventonTouchEvent方法来传递一个MotionEvent,而是真正地模拟用户触摸事件,从底层到上层的传递,走一遍完整的流程,最终将返回值交给ViewRootImpl处理。


2. 为什么补点

假设我们有这样一个需求,一个父View里面塞了很多个子View,产品希望以父View为整体,可以实现在全屏内跟手拖动,而里面的子View各自又可以响应点击事件。

这个需求拆分成两部分:

  • 父View跟手位移:拦截事件,自己消费,实时更新父View坐标
  • 子View响应点击事件:设置点击监听器

问题来了,当父View拦截并开始消费事件后,子View因为没有ACTION_DOWN消费,是无法收到后续事件的,因此永远无法正常响应点击事件。

那么怎么实现既可以让父View消费,又可以让子View消费呢?这就需要用到补点。


3. 怎么补点

对于该需求场景,如果我们在跟手位移结束,即ACTION_UP时,判断本次操作应该为一次click事件,然后将这个click事件对应的ACTION_DOWNACTION_UP传给系统,重新从ViewRootImpl向下传递给子View,便可解决该问题。

这个补偿事件点的操作称为补点。

与触摸事件相关的系统服务为InputManagerService,其对应用层开放的管理类为InputManager,其中有一个方法injectInputEvent,用于主动注入Input事件到IMS中:

在这里插入图片描述

遗憾的是,它是一个Hide API,且需要INJECT_EVENTS权限,即仅向系统层或有特权的系统应用(拥有AOSP证书签名、安装目录位于/system/app/中)开放。

幸运的是,可以使用替代方案Instrumentation

在这里插入图片描述

通过该API,可以间接地向系统注入点击事件对应的两个MotionEvent。需要注意的是:

  • 需在子线程调用
  • MotionEvent通过MotionEvent#obtain构造
  • 时间戳使用SystemClock#uptimeMillis()

由于我们在父View进行了事件拦截以使自身消费事件,因此这里构造的MotionEvent需要通过setEdgeFlags设置一个特定的标识,当拥有标识的事件经过onInterceptTouchEvent方法时,对其放行,使其正常分发。

以上便是一次完整的补点操作。


4. 实战

以上一节补点的需求案例为例:

  • 在一个名为Container的父View中,放入一个ImageView和两个Button
  • Container为整体,在全屏内可以跟手拖动
  • Container里面的子View各自能响应点击事件

(1) 自定义ViewGroup实现跟手拖动

  • 由于需要全屏范围内拖动,父布局需使用FrameLayout
  • 自定义ViewGroup这里继承ConstraintLayout
  • 重写onInterceptTouchEvent返回true,使事件全部由自身消费
  • 重写onTouchEvent,保存ACTION_DOWN时的落点和View的坐标,以计算相对位移,ACTION_MOVE时更新View的坐标,实现跟手拖动
  • 位移时判断是否超出屏幕,限制坐标边界值

(2) 判断是否满足点击事件

  • 点击事件的判定通常需要考虑位移差间隔时长两个因素,如果严谨一点还可以加上对离手速度的判断
  • 本例中,ACTION_DOWNACTION_UP的横纵坐标差均小于50px,间隔时长小于250ms,不考虑离手速度

(3) 注入事件到系统

  • 在子线程中实例化一个Instrumentation实例
  • 根据离手坐标构造MotionEvent
  • 对其设置edgeFlags,注意不能和系统标识位重复
  • 连续注入两个事件

(4) 拦截回调中放行注入的事件

  • 如果事件的edgeFlags等于自定义的标识,则不拦截事件,使其分发给子View

(5) 运行效果

在这里插入图片描述


四、 总结

拦补点思想用于解决一些手势冲突问题,其本质还是基于事件分发机制,即:InputEventReceiver->ViewRootImpl->PhoneWindow->DecorView->ViewGroup->View这一分发链。

因此对这一机制越熟悉,运用起来也就越得心应手。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值