读源码长知识 | 原来可以这样扩大 View 点击区域
作者:唐子玄
链接:https://juejin.cn/post/6968237652017414151
App 界面中,有一些控件尺寸很小,不容易点到。我总是通过加 padding 来解决这个问题。这样容易牵一发动全身,特别对于复杂界面,往往改变了一个控件的大小,其他控件的位置也随之而动。有没有更好的办法解决办法?在阅读触摸事件源码中,无意间发现了一种更解耦的方式。
引子
触摸事件源码分析可以点击这里, 现援引结论如下:
Activity接收到触摸事件后,会传递给PhoneWindow,再传递给DecorView,由DecorView调用ViewGroup.dispatchTouchEvent()自顶向下分发ACTION_DOWN触摸事件。ACTION_DOWN事件通过ViewGroup.dispatchTouchEvent()从DecorView经过若干个ViewGroup层层传递下去,最终到达View。View.dispatchTouchEvent()被调用。View.dispatchTouchEvent()是传递事件的终点,消费事件的起点。它会调用onTouchEvent()或OnTouchListener.onTouch()来消费事件。每个层次都可以通过在
onTouchEvent()或OnTouchListener.onTouch()返回true,来告诉自己的父控件触摸事件被消费。只有当下层控件不消费触摸事件时,其父控件才有机会自己消费。触摸事件的传递是从根视图自顶向下“递”的过程,触摸事件的消费是自下而上“归”的过程。
触摸代理
View 对触摸事件的消费逻辑都集中在onTouchEvent()中,它是触摸事件传递的终点,消费的起点。但其中居然将触摸事件又传递给了别人:
1public class View {
2 // 触摸代理
3 private TouchDelegate mTouchDelegate = null;
4 public boolean onTouchEvent(MotionEvent event) {
5 final float x = event.getX();
6 final float y = event.getY();
7 final int viewFlags = mViewFlags;
8 final int action = event.getAction();
9 ...
10 // 将触摸事件分发给触摸代理
11 if (mTouchDelegate != null) {
12 if (mTouchDelegate.onTouchEvent(event)) {
13 return true;
14 }
15 }
16 }
17}
在onTouchEvent()中,触摸事件在被消费之前先传递给了mTouchDelegate。它是一个触摸代理实例:
1public class TouchDelegate {
2 // 代理控件
3 private View mDelegateView;
4 // 代理控件响应触摸事件的区域
5 private Rect mBounds;
6
7 // 构造函数
8 public TouchDelegate(Rect bounds, View delegateView) {
9 mBounds = bounds;
10 mDelegateView = delegateView;
11 ...
12 }
13
14 // 处理触摸事件
15 public boolean onTouchEvent(@NonNull MotionEvent event) {
16 int x = (int)event.getX();
17 int y = (int)event.getY();
18 // 是否将触摸事件传递给代理
19 boolean sendToDelegate = false;
20 boolean handled = false;
21
22 switch (event.getActionMasked()) {
23 case MotionEvent.ACTION_DOWN:
24 // 若 DOWN 事件发生在设定区域内,则将所有事件都传递给它。
25 sendToDelegate = mBounds.contains(x, y);
26 ...
27 break;
28 ...
29 }
30 if (sendToDelegate) {
31 // 改变触摸事件的位置,假装它发生在代理控件的中心
32 event.setLocation(mDelegateView.getWidth() / 2, mDelegateView.getHeight() / 2);
33 // 将触摸事件传递给代理控件
34 handled = mDelegateView.dispatchTouchEvent(event);
35 }
36 return handled;
37 }
38}
触摸代理的构造函数中需传入代理控件及其响应触摸事件的区域。若触摸事件落在该区域内则将事件传递给代理控件消费。
所以只需将代理控件的响应区域人为地增大即可实现点击区域的扩大:
1val viewGroup: ViewGroup
2val childView: View
3
4// 为了获取子控件相对于父控件的位置, 必须 psot
5viewGroup.post {
6 val rect = Rect()
7 // 获取子控件相对于父控件位置并记录在 rect 中
8 ViewGroupUtils.getDescendantRect(viewGroup, childView, rect)
9 // 将 rect 横向和纵向都往外扩 100 像素
10 rect.inset(- 100, - 100)
11 // 为父控件设置触摸代理
12 viewGroup.touchDelegate = TouchDelegate(childView, rect)
13}
触摸代理得设置在父控件上,因为子控件的触摸事件经由父控件传递过来的,只有父控件中的触摸代理才能优先处理事件。
若用上述代码连续为两个控件扩大点击区域,就不奏效了。。。
自定义触摸代理
因为View中只有一个TouchDelegate成员,且TouchDelegate中只有一个代理控件。
为了让触摸代理能服务多个控件,就不得不通过继承扩展它:
1// 多重触摸代理
2class MultiTouchDelegate(bound: Rect? = null, delegateView: View)
3 : TouchDelegate(bound, delegateView) {
4 // 保存多个代理控件及其触摸区域的容器
5 val delegateViewMap = mutableMapOf<View, Rect>()
6 // 当前的代理控件
7 private var delegateView: View? = null
8
9 // 新增代理控件
10 fun addDelegateView(delegateView: View, rect: Rect) {
11 delegateViewMap[delegateView] = rect
12 }
13
14 // 完全重写, 以屏蔽父类逻辑
15 override fun onTouchEvent(event: MotionEvent): Boolean {
16 val x = event.x.toInt()
17 val y = event.y.toInt()
18 var handled = false
19 when (event.actionMasked) {
20 MotionEvent.ACTION_DOWN -> {
21 // DOWN 发生时找到对应坐标下的代理控件
22 delegateView = findDelegateViewUnder(x, y)
23 }
24 MotionEvent.ACTION_CANCEL -> {
25 delegateView = null
26 }
27 }
28 // 若找到代理控件,则将所有事件都传递给它消费
29 delegateView?.let {
30 event.setLocation(it.width / 2f, it.height / 2f)
31 handled = it.dispatchTouchEvent(event)
32 }
33 return handled
34 }
35
36 // 遍历代理控件,返回其触摸区域包含指定坐标的那一个代理控件
37 private fun findDelegateViewUnder(x: Int, y: Int): View? {
38 delegateViewMap.forEach { entry -> if (entry.value.contains(x, y)) return entry.key }
39 return null
40 }
41}
然后就可以像这样为多个控件扩大点击区域:
1val viewGroup: ViewGroup
2val childView1: View
3val childView2: View
4val multiTouchDelegate = MultiTouchDelegate(childView1)
5viewGroup.touchDelegate = multiTouchDelegate
6
7viewGroup.post {
8 val rect1 = Rect()
9 ViewGroupUtils.getDescendantRect(viewGroup, childView1, rect1)
10 rect1.inset(- 100, - 100)
11 multiTouchDelegate.addDelegateView(childView1, rect1)
12
13 val rect2 = Rect()
14 ViewGroupUtils.getDescendantRect(viewGroup, childView2, rect2)
15 rect2.inset(- 200, - 200)
16 multiTouchDelegate.addDelegateView(childView2, rect2)
17}
Kotlin 语法糖重构
这样的使用成本还是太高了,对于业务层最友好的方式应该是只传递扩大的像素值,而无需关心“Rect对象创建”,“触摸代理对象创建”这些实现细节。
那就运用 Kotlin 的扩展方法重构一下:
1// 为 View 新增 expand 扩展方法
2fun View.expand(dx: Int, dy: Int) {
3 // 将刚才定义代理类放到方法内部,调用方不需要了解这些细节
4 class MultiTouchDelegate(bound: Rect? = null, delegateView: View) : TouchDelegate(bound, delegateView) {
5 val delegateViewMap = mutableMapOf<View, Rect>()
6 private var delegateView: View? = null
7
8 override fun onTouchEvent(event: MotionEvent): Boolean {
9 val x = event.x.toInt()
10 val y = event.y.toInt()
11 var handled = false
12 when (event.actionMasked) {
13 MotionEvent.ACTION_DOWN -> {
14 delegateView = findDelegateViewUnder(x, y)
15 }
16 MotionEvent.ACTION_CANCEL -> {
17 delegateView = null
18 }
19 }
20 delegateView?.let {
21 event.setLocation(it.width / 2f, it.height / 2f)
22 handled = it.dispatchTouchEvent(event)
23 }
24 return handled
25 }
26
27 private fun findDelegateViewUnder(x: Int, y: Int): View? {
28 delegateViewMap.forEach { entry -> if (entry.value.contains(x, y)) return entry.key }
29 return null
30 }
31 }
32
33 // 获取当前控件的父控件
34 val parentView = parent as? ViewGroup
35 // 若父控件不是 ViewGroup, 则直接返回
36 parentView ?: return
37
38 // 若父控件未设置触摸代理,则构建 MultiTouchDelegate 并设置给它
39 if (parentView.touchDelegate == null) parentView.touchDelegate = MultiTouchDelegate(delegateView = this)
40 post {
41 val rect = Rect()
42 // 获取子控件在父控件中的区域
43 ViewGroupUtils.getDescendantRect(parentView, this, rect)
44 // 将响应区域扩大
45 rect.inset(- dx, - dy)
46 // 将子控件作为代理控件添加到 MultiTouchDelegate 中
47 (parentView.touchDelegate as? MultiTouchDelegate)?.delegateViewMap?.put(this, rect)
48 }
49}
然后业务层就可以像这样轻松的扩大点击区域:
1val childView1: View
2val childView2: View
3
4childView1.expand(100, 100)
5childView2.expand(200, 200)
talk is cheap, show me the code
View.expand()在这个仓库的Layout.kt文件中
https://github.com/wisdomtl/Layout_DSL
推荐阅读: