读源码长知识 | 原来可以这样扩大 View 点击区域

简介: 读源码长知识 | 原来可以这样扩大 View 点击区域

App 界面中,有一些控件尺寸很小,不容易点到。我总是通过加 padding 来解决这个问题。这样容易牵一发动全身,特别对于复杂界面,往往改变了一个控件的大小,其他控件的位置也随之而动。有没有更好的办法解决办法?在阅读触摸事件源码中,无意间发现了一种更解耦的方式。


读源码长知识系列文章如下,该系列从源码中汲取精华并运用于实战项目中。


  1. 读源码长知识 | 更好的RecyclerView点击监听器


  1. Android自定义控件 | 源码里有宝藏之自动换行控件


  1. Android自定义控件 | 小红点的三种实现(下)


  1. 读源码长知识 | 动态扩展类并绑定生命周期的新方式


  1. 读源码长知识 | Android卡顿真的是因为”掉帧“?


  1. 读源码长知识 | 原来可以这样扩大点击区域


引子


触摸事件源码分析可以点击这里, 现援引结论如下:


  • Activity接收到触摸事件后,会传递给PhoneWindow,再传递给DecorView,由DecorView调用ViewGroup.dispatchTouchEvent()自顶向下分发ACTION_DOWN触摸事件。


  • ACTION_DOWN事件通过ViewGroup.dispatchTouchEvent()DecorView经过若干个ViewGroup层层传递下去,最终到达ViewView.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文件中


推荐阅读


  1. Android触摸事件分发的“递”与“归”(一)


  1. Android触摸事件分发的“递”与“归”(二)


  1. 读源码长知识 | 原来可以这样扩大点击区域


目录
相关文章
|
7月前
|
IDE 开发工具
Poco脚本的点击位置与点击偏移
Poco脚本的点击位置与点击偏移
225 0
|
7月前
|
JavaScript
点击图片返回页面顶部的案例
点击图片返回页面顶部的案例
|
4月前
|
数据安全/隐私保护
动态切换EditText内容的显示
动态切换EditText内容的显示
32 2
若依颜色失效怎么办?F12刷新样式不管用,回到控制台,重新点击项目链接就好了
若依颜色失效怎么办?F12刷新样式不管用,回到控制台,重新点击项目链接就好了
点击button页面重新加载刷新
点击button页面重新加载刷新
65 0
如何实现“点击回到顶部”的功能?
如何实现“点击回到顶部”的功能?
135 0
element ui 上传图片之后跳转、刷新、保存,预览和删除丢失问题
这问题困惑了我好久,在官方的element ui 的组件库中,直接拿来使用的话,只有当前显示效果,一旦刷新页面或者保存之后,就会丢失,预览和删除功能。当保存后,保存到后端接口,再次查看,图片是能渲染出来,但是由于保存页面刷新,随之整个上传过程失败,而查看所拿到的图片只是一张静态图片,要想再次预览和查看,需要重新选中上传
252 0
|
API
scroll-view回到顶部功能的实现
在我最近写的一个项目中就有这样的一个需求,即无限滚动卡片列表中实现回到顶部,与已往的返回顶部功能不同,因为是通过scroll-view来实现的无限列表滚动,所以返沪顶部需要依靠scroll-view的一些特定属性和api,下面我将带大家分析,实现这个功能。
541 1
scroll-view回到顶部功能的实现
|
iOS开发
iOS开发 - touchBegan事件判断点击的位置在View上还是在View的子View上
iOS开发 - touchBegan事件判断点击的位置在View上还是在View的子View上
280 0
iOS开发 - touchBegan事件判断点击的位置在View上还是在View的子View上