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()
中,它是触摸事件传递的终点,消费的起点。但其中居然将触摸事件又传递给了别人:
public class View { // 触摸代理 private TouchDelegate mTouchDelegate = null; public boolean onTouchEvent(MotionEvent event) { final float x = event.getX(); final float y = event.getY(); final int viewFlags = mViewFlags; final int action = event.getAction(); ... // 将触摸事件分发给触摸代理 if (mTouchDelegate != null) { if (mTouchDelegate.onTouchEvent(event)) { return true; } } } }
在onTouchEvent()
中,触摸事件在被消费之前先传递给了mTouchDelegate
。它是一个触摸代理实例:
public class TouchDelegate { // 代理控件 private View mDelegateView; // 代理控件响应触摸事件的区域 private Rect mBounds; // 构造函数 public TouchDelegate(Rect bounds, View delegateView) { mBounds = bounds; mDelegateView = delegateView; ... } // 处理触摸事件 public boolean onTouchEvent(@NonNull MotionEvent event) { int x = (int)event.getX(); int y = (int)event.getY(); // 是否将触摸事件传递给代理 boolean sendToDelegate = false; boolean handled = false; switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: // 若 DOWN 事件发生在设定区域内,则将所有事件都传递给它。 sendToDelegate = mBounds.contains(x, y); ... break; ... } if (sendToDelegate) { // 改变触摸事件的位置,假装它发生在代理控件的中心 event.setLocation(mDelegateView.getWidth() / 2, mDelegateView.getHeight() / 2); // 将触摸事件传递给代理控件 handled = mDelegateView.dispatchTouchEvent(event); } return handled; } }
触摸代理的构造函数中需传入代理控件及其响应触摸事件的区域。若触摸事件落在该区域内则将事件传递给代理控件消费。
所以只需将代理控件的响应区域人为地增大即可实现点击区域的扩大:
val viewGroup: ViewGroup val childView: View // 为了获取子控件相对于父控件的位置, 必须 psot viewGroup.post { val rect = Rect() // 获取子控件相对于父控件位置并记录在 rect 中 ViewGroupUtils.getDescendantRect(viewGroup, childView, rect) // 将 rect 横向和纵向都往外扩 100 像素 rect.inset(- 100, - 100) // 为父控件设置触摸代理 viewGroup.touchDelegate = TouchDelegate(childView, rect) }
触摸代理得设置在父控件上,因为子控件的触摸事件经由父控件传递过来的,只有父控件中的触摸代理才能优先处理事件。
若用上述代码连续为两个控件扩大点击区域,就不奏效了。。。
自定义触摸代理
因为View
中只有一个TouchDelegate
成员,且TouchDelegate
中只有一个代理控件。
为了让触摸代理能服务多个控件,就不得不通过继承扩展它:
// 多重触摸代理 class MultiTouchDelegate(bound: Rect? = null, delegateView: View) : TouchDelegate(bound, delegateView) { // 保存多个代理控件及其触摸区域的容器 val delegateViewMap = mutableMapOf<View, Rect>() // 当前的代理控件 private var delegateView: View? = null // 新增代理控件 fun addDelegateView(delegateView: View, rect: Rect) { delegateViewMap[delegateView] = rect } // 完全重写, 以屏蔽父类逻辑 override fun onTouchEvent(event: MotionEvent): Boolean { val x = event.x.toInt() val y = event.y.toInt() var handled = false when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { // DOWN 发生时找到对应坐标下的代理控件 delegateView = findDelegateViewUnder(x, y) } MotionEvent.ACTION_CANCEL -> { delegateView = null } } // 若找到代理控件,则将所有事件都传递给它消费 delegateView?.let { event.setLocation(it.width / 2f, it.height / 2f) handled = it.dispatchTouchEvent(event) } return handled } // 遍历代理控件,返回其触摸区域包含指定坐标的那一个代理控件 private fun findDelegateViewUnder(x: Int, y: Int): View? { delegateViewMap.forEach { entry -> if (entry.value.contains(x, y)) return entry.key } return null } }
然后就可以像这样为多个控件扩大点击区域:
val viewGroup: ViewGroup val childView1: View val childView2: View val multiTouchDelegate = MultiTouchDelegate(childView1) viewGroup.touchDelegate = multiTouchDelegate viewGroup.post { val rect1 = Rect() ViewGroupUtils.getDescendantRect(viewGroup, childView1, rect1) rect1.inset(- 100, - 100) multiTouchDelegate.addDelegateView(childView1, rect1) val rect2 = Rect() ViewGroupUtils.getDescendantRect(viewGroup, childView2, rect2) rect2.inset(- 200, - 200) multiTouchDelegate.addDelegateView(childView2, rect2) }
Kotlin 语法糖重构
这样的使用成本还是太高了,对于业务层最友好的方式应该是只传递扩大的像素值,而无需关心“Rect对象创建”,“触摸代理对象创建”这些实现细节。
那就运用 Kotlin 的扩展方法重构一下:
// 为 View 新增 expand 扩展方法 fun View.expand(dx: Int, dy: Int) { // 将刚才定义代理类放到方法内部,调用方不需要了解这些细节 class MultiTouchDelegate(bound: Rect? = null, delegateView: View) : TouchDelegate(bound, delegateView) { val delegateViewMap = mutableMapOf<View, Rect>() private var delegateView: View? = null override fun onTouchEvent(event: MotionEvent): Boolean { val x = event.x.toInt() val y = event.y.toInt() var handled = false when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { delegateView = findDelegateViewUnder(x, y) } MotionEvent.ACTION_CANCEL -> { delegateView = null } } delegateView?.let { event.setLocation(it.width / 2f, it.height / 2f) handled = it.dispatchTouchEvent(event) } return handled } private fun findDelegateViewUnder(x: Int, y: Int): View? { delegateViewMap.forEach { entry -> if (entry.value.contains(x, y)) return entry.key } return null } } // 获取当前控件的父控件 val parentView = parent as? ViewGroup // 若父控件不是 ViewGroup, 则直接返回 parentView ?: return // 若父控件未设置触摸代理,则构建 MultiTouchDelegate 并设置给它 if (parentView.touchDelegate == null) parentView.touchDelegate = MultiTouchDelegate(delegateView = this) post { val rect = Rect() // 获取子控件在父控件中的区域 ViewGroupUtils.getDescendantRect(parentView, this, rect) // 将响应区域扩大 rect.inset(- dx, - dy) // 将子控件作为代理控件添加到 MultiTouchDelegate 中 (parentView.touchDelegate as? MultiTouchDelegate)?.delegateViewMap?.put(this, rect) } }
然后业务层就可以像这样轻松的扩大点击区域:
val childView1: View val childView2: View childView1.expand(100, 100) childView2.expand(200, 200)
talk is cheap, show me the code
View.expand()
在这个仓库的Layout.kt
文件中