Android TouchDelegate详解及优化

96
stevewang
0.8 2018.05.13 18:29* 字数 770

Android 4.0规定的有效可触摸的UI元素标准是48dp,这是一个用户手指能准确并且舒适触摸的区域。

日常开发中,如果我们想扩大一个View的点击区域,往往通过给View设置padding即可实现,但是对于某些特殊的情况,如图

开发者选项-显示布局边界

因为布局对齐的关系,这个SeekBar不能有paddingTop,而这时又需要在上方增加可响应区域,就只能用TouchDelegate了。

关于TouchDelegate,这里提供一篇Android Developer上介绍 TouchDelegate 的文档,并包含demo snippet。

TouchDelegate的使用方法很简单,考虑下图这种情形

image

我们想扩大View2的点击区域至View1内部的Bounds区域,只需在View2完成绘制后获取到其扩展区域Bounds相对于View1的坐标,再为View1设置TouchDelegate即可。代码如下:

view2.post(new Runnable() {
    @Override
    public void run() {
        Rect bounds = new Rect();
        // 获取View2区域在View1中的相对位置,这里因为View1是View2的直接父View,所以使用getHitRect()
        view2.getHitRect(bounds);
        // 计算扩展后的区域Bounds相对于View1的位置
        bounds.left -= left;
        bounds.top -= top;
        bounds.right += right;
        bounds.bottom += bottom;
        // 创建TouchDelegate
        TouchDelegate touchDelegate = new TouchDelegate(bounds, view2);
        // 为View1设置TouchDelegate
        view1.setTouchDelegate(touchDelegate);
    }
}

使用TouchDelegate的扩展点击区域的原理,可以查看View.java源码,前面使用了下面的代码为View1设置了TouchDelegate

/**
 * Sets the TouchDelegate for this View.
 */
public void setTouchDelegate(TouchDelegate delegate) {
    mTouchDelegate = delegate;
}

当我们点击View2内部的区域,仍然会触发View2的onClick();而当我们点击View2外且在Bounds内(亦在View1内)的区域,根据Android的事件分发原理,会触发View1的onTouchEvent()方法

public boolean onTouchEvent(MotionEvent event) {
    ......
    if (mTouchDelegate != null) {
        if (mTouchDelegate.onTouchEvent(event)) {
            return true;
        }
    }
    ......
}

因为我们为View1设置了TouchDelegate,所以会进入TouchDelegate的onTouchEvent(),如果这个方法返回了ture,View1的onTouchEvent()也会返回true并到此结束,对外宣称View1消费了这个事件,但实际上并不会触发View1的onClick();而如果这个方法返回了false,则会继续执行后面的逻辑。TouchDelegate的onTouchEvent() 源码如下:

/**
 * Will forward touch events to the delegate view if the event is within the bounds
 * specified in the constructor.
 *
 * @param event The touch event to forward
 * @return True if the event was forwarded to the delegate, false otherwise.
 */
public boolean onTouchEvent(MotionEvent event) {
    // 这里是触摸点相对于View1的坐标
    int x = (int)event.getX();
    int y = (int)event.getY();
    // 是否将event发送给View2,注意这是一个默认值为false的局部变量
    boolean sendToDelegate = false;
    // 事件是否发生在Bounds内
    boolean hit = true;
    // 作为返回值,标识View1是否消费了event(实际上可能传递给View2消费了)
    boolean handled = false;

    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            Rect bounds = mBounds;

            // Down事件包含在Rounds区域里,发送event事件给View2
            if (bounds.contains(x, y)) {
                // 存储Down事件的处理策略供后续的Move和UP参考
                mDelegateTargeted = true;
                sendToDelegate = true;
            }
            // !!! 下面被注释的代码为作者添加的优化代码 !!!
            // 只有加上下面的代码才能保证在点击Rounds区域触发View2的onClick()后
            // 再点击View1仍会触发View1的onClick()
//          else {
//              mDelegateTargeted = false;
//          }

            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_MOVE:
            // 这里直接参考了前面存储的Down事件的处理策略
            sendToDelegate = mDelegateTargeted;
            if (sendToDelegate) {
                Rect slopBounds = mSlopBounds;
                if (!slopBounds.contains(x, y)) {
                    hit = false;
                }
            }
            break;
        case MotionEvent.ACTION_CANCEL:
            sendToDelegate = mDelegateTargeted;
            mDelegateTargeted = false;
            break;
    }
    // 这里使用的是局部变量来决定事件的处理策略
    if (sendToDelegate) {
        final View delegateView = mDelegateView;

        if (hit) {
            // Offset event coordinates to be inside the target view
            // 因为后面要把event分发给View2处理,这里先将触摸点坐标由相对于View1变为相对于View2
            event.setLocation(delegateView.getWidth() / 2, delegateView.getHeight() / 2);
        } else {
            // Offset event coordinates to be outside the target view (in case it does something like tracking pressed state)
            // 因为后面要把event分发给View2处理,这里先将触摸点坐标由相对于View1变为相对于View2
            int slop = mSlop;
            event.setLocation(-(slop * 2), -(slop * 2));
        }
        // 将事件分发给View2处理
        handled = delegateView.dispatchTouchEvent(event);
    }
    return handled;
}

正如上面源码中本人所添加的那段被注释的代码上面的文字注释所描述的那样,直接使用系统提供的TouchDelegate会存在一种 bad case:当点击过一次扩展区域Bounds之后,View1的点击事件失效

这是因为当点击过一次Bounds区域(不包括View2内的部分),依据Google官方提供的TouchDelegate的onTouchEvent()源码,mDelegateTargeted 会被置为true;当下一次点击View1时,由于sendToDelegate为false,DOWN事件会交由View1处理;而由于mDelegateTargeted仍为true,后续的MOVE和UP事件会交由View2处理,阅读View.java的onTouchEvent()源码可知,这种情况下View1的performClick()不会被调用,也就不会触发View1的onClick()

最后,基于系统提供的TouchDelegate进行扩展,给出使用TouchDelegate扩展点击区域的最佳解决方案,其具有以下两个优点:

  1. 解决了先点击一次扩展区域Bounds(不包括View2内的部分),再点击View1失效的bad case
  2. 内部改用绝对坐标,完美的封装了TouchDelegate,使用者只需提供四个方向需要扩展的大小而不需要提供扩展区域Bounds相对于View1的Rect坐标
Android