浅谈TouchDelegate的坑与用法

从一个布局讲起

最近要实现如下图所示的布局:


布局示意

底部按钮使用TextView实现, 按钮数量及文案长度不确定, 要求左右两侧间距相同, 每个按钮之间间距等分.

对于这种布局, 如果只想使用原生的布局方式来做, 会比较恶心, ConstraintLayout能做到, 但有些大炮打蚊子的感觉, 索性直接使用一个横向的LinearLayout, 修改他的onLayout方法手动进行布局, 只用十几行代码就搞定了.

由于布局的要求是按钮间间距等分, 因此TextView的宽高全部使用WRAP_CONTENT, 这样在测量的时候, 拿到的是按钮自己的宽高, 能很容易算出间距, 但这样会带来一个问题: 按钮的点击区域太小, 如下图所示:

布局边界

由于按钮的文字大小并不大, 实际使用的时候经常点不到按钮上面, 因此需要扩大触摸区域, 要让按钮的点击区域尽可能的大.

一般情况下, 我们扩大触摸区域都是给按钮加padding, 也就是说按钮的实际宽高会比视觉上看到的要大一些, 这种做法可行的前提是按钮的大小和位置确定. 而这个布局本身需要视觉上按钮和按钮之间间距等分, 且按钮的数量不确定, 按钮的文案长度不确定, 不能提前知道间距的大小, 因此我们不能通过改变按钮的大小来做. 如果在测量时动态改变大小, 又会重新触发一轮新的测量, 逻辑上需要区分两次测量, 增加了复杂度, 因此我直接排除了给按钮加padding的方案.

尝试TouchDelegate

既然不能通过改变按钮大小来扩大触摸区域, 那么自然就想到了使用TouchDelegate.

public TouchDelegate(Rect bounds, View delegateView)

一般而言, 用的时候delegateView为需要扩大触摸区域的View. bounds为扩大后的点击区域在父控件中的Rect, 这个TouchDelegate最终设给父控件.

+------------------------------------------------------+
|                                                      |
|                                                      |
|          +-------------------------+                 |
|          |                         |                 |
|          |      +---------+        |                 |
|          |      |         |        |                 |
|          |      |  按钮A   |  区域B |     父控件C      |
|          |      |         |        |                 |
|          |      +---------+        |                 |
|          |                         |                 |
|          |                         |                 |
|          +-------------------------+                 |
|                                                      |
|                                                      |
+------------------------------------------------------+

例如对于上图的情况, 父控件C中有一个按钮A, 希望把A的触摸区域扩到到B, 此时bounds为B在C中的Rect, delegateView为A, 而TouchDelegate设置给C.

TouchDelegate这东西在我的印象里, 一直就是和扩大触摸区域绑定的东西, 因为除了谈扩大触摸区域, 其他的事情基本不会和TouchDelegate扯上关系.

包括TouchDelegate的注释也是这么说的:

Helper class to handle situations where you want a view to have a larger touch area than its actual view bounds. The view whose touch area is changed is called the delegate view.

看起来似乎只要给布局中的每个按钮都用一个TouchDelegate扩大触摸区域就行.

然而实际上TouchDelegate的并不能满足这种情况.

正如TouchDelegate的名字所暗示的, 他是一个touch事件的代理, 相关代码如下:

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

从上面的代码可以看出, TouchDelegate使得一个View A能把自己的触摸事件代理给另一个View B, 而且一个View只能设置一个TouchDelegate.

当View B为View A的子View或子View的子View时, 如果Rect比View B的大小要大, 恰好能起到扩大View B触摸区域的作用, 所以扩大触摸区域只是TouchDelegate的一个特殊用法.

然而由于一个View只能设置一个TouchDelegate, 使得用TouchDelegate扩大触摸区域这个功能有点古怪. 就拿上面看到的布局来说, 需要扩大触摸区域的View不止一个, 而一个父控件只能允许一个子View扩大触摸区域, 并不符合我的需求.

组合TouchDelegate

既然一个View只能设置一个TouchDelegate, 而我们又需要扩大多个按钮的点击区域, 可以将多个TouchDelegate组合在一个TouchDelegate里设置给被代理的View.

public class TouchDelegateComposite extends TouchDelegate {
    private static final Rect USELESS_RECT = new Rect();
    private final List<TouchDelegate> mDelegates = new ArrayList<TouchDelegate>(8);

    public TouchDelegateComposite(@NonNull View view) {
        super(USELESS_RECT, view);
    }

    public void addDelegate(@NonNull TouchDelegate delegate) {
        mDelegates.add(delegate);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        for (TouchDelegate delegate : mDelegates) {
            if (delegate.onTouchEvent(event)) {
                return true;
            }
        }
        return false;
    }
}

注意这段代码给父类的构造方法传了一个USELESS_RECT, 这里不能传null, 否则当代码运行在Android 4.1.2及更早的版本上会崩溃, 因为TouchDelegate构造方法中会使用外部传入的Rect构造一个新的Rect,而早期版本的Rect在构造时没有对传入的参数判空, 如果传null会导致空指针.

这段代码看起来很美好, 然而并不能正常工作. 代码的本意是把代理的event分发给每个TouchDelegate, 并在遇到有TouchDelegate能处理该事件的时候停止派发, 返回结果.

思路是正确的, 但是实现细节有问题, 由于TouchDelegate的onTouchEvent源码比较简单, 这里直接完整贴出来:

// TouchDelegate.java 8.1.0_r33
public boolean onTouchEvent(MotionEvent event) {
    int x = (int)event.getX();
    int y = (int)event.getY();
    boolean sendToDelegate = false;
    boolean hit = true;
    boolean handled = false;

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

        if (bounds.contains(x, y)) {
            mDelegateTargeted = true;
            sendToDelegate = true;
        }
        break;
    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_MOVE:
        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.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)
            int slop = mSlop;
            event.setLocation(-(slop * 2), -(slop * 2));
        }
        handled = delegateView.dispatchTouchEvent(event);
    }
    return handled;
}

上面可以很明显的看到TouchDelegate在派发事件给子View前, 会调整传入event的坐标, 因此在外部如果想多次派发event, 需要还原event的坐标, 避免坐标错乱, 修改后的代码如下:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        final float x = event.getX();
        final float y = event.getY();
        for (TouchDelegate delegate : mDelegates) {
            event.setLocation(x, y);
            if (delegate.onTouchEvent(event)) {
                return true;
            }
        }
        return false;
    }

坐标的问题解决了, 代码执行起来看起来挺正常. 但依然有巨大的问题.

TouchDelegate的缺陷

不知道是因为用的少还是大家没有发现, TouchDelegate本身有一个很致命的问题: 给一个View设置TouchDelegate会导致该View在一种特殊情况下无法响应点击事件.

还是刚才的图:

+------------------------------------------------------+
|                                                      |
|                                                      |
|          +-------------------------+                 |
|          |                         |                 |
|          |      +---------+        |                 |
|          |      |         |        |                 |
|          |      |  按钮A   |  区域B |    父控件C      |
|          |      |         |        |                 |
|          |      +---------+        |                 |
|          |                         |                 |
|          |                         |                 |
|          +-------------------------+                 |
|                                                      |
|                                                      |
+------------------------------------------------------+

假如现在使用TouchDelegate将A的触摸区域扩大到B, 且A, C控件都可以响应点击事件.

如果用户点击A区域, 再点击C区域, 两个点击事件都能触发.
如果用户点击扩展区域B(不包括A), 此后当用户点击C控件, 将无法触发C的点击事件.

根本原因在于TouchDelegate中成员变量mDelegateTargeted没有在收到DOWN事件时重置为false.

仔细看源码:

// TouchDelegate.java 8.1.0_r33
public boolean onTouchEvent(MotionEvent event) {
    int x = (int)event.getX();
    int y = (int)event.getY();
    boolean sendToDelegate = false;
    boolean hit = true;
    boolean handled = false;

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

        if (bounds.contains(x, y)) {
            mDelegateTargeted = true;
            sendToDelegate = true;
        }
        break;
    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_MOVE:
        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.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)
            int slop = mSlop;
            event.setLocation(-(slop * 2), -(slop * 2));
        }
        handled = delegateView.dispatchTouchEvent(event);
    }
    return handled;
}

mDelegateTargeted只有在ACTION_CANCEL的时候才置false, 而一般情况下我们不会遇到ACTION_CANCEL, 一旦mDelegateTargeted被置true, 基本就恒为true了. 那么当用户点击一个不在代理区内的坐标时, ACTION_DOWN对应分支的代码不会执行, 因为不在Rect里. 又由于mDelegateTargeted为true, ACTION_MOVEACTION_UP会导致sendToDelegate置true, 执行派发事件代码, 并返回结果, 由于可点击的View默认吃全部事件, 因此返回值一定为true.

回到View.java里:

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

这就导致设置了TouchDelegate的View自身的onTouchEvent除了ACTION_DOWN能自己处理, 其他事件全被TouchDelegate吃掉了.

也就是说, 如果用户通过扩展区域B触发了A的点击事件, 将会导致C永远无法触发点击事件, 原因就是mDelegateTargeted被置true后基本没有机会重置.

之所以点击A区域不会造成这个bug, 是因为TouchDelegate拦截的是C控件的onTouchEvent, 如果用户点A区域, 事件将由C直接派发给A, A在onTouchEvent里直接消耗了用户的触摸事件, C的onTouchEvent不会收到该轮触摸事件, 这是基本的事件分发流程, 就不在这里赘述了.

综上, TouchDelegate的缺陷可以描述为:
当TouchDelegate为一个clickable的View扩展点击区域后, 如果用户点击了一次扩展区域, 那么在此之后, 该TouchDelegate将吃掉非扩展区域内的所有ACTION_MOVE, ACTION_UP事件, 即当TouchDelegate#onTouchEvent收到不在扩展区域内的ACTION_MOVE, ACTION_UP触摸事件时, 将错误的返回true.

当然ACTION_CANCEL也会吃, 但这会使得TouchDelegate恢复正常.

对组合TouchDelegate的影响

由于TouchDelegate#onTouchEvent的返回值在某些不应该返回true的情况下, 会返回true. 如果组合TouchDelegate遇到TouchDelegate#onTouchEvent返回true的时候就停止分发, 那么可能会出现一种奇怪的现象, 用户点击其他子View后无法触发点击事件.

拿下面的图举个例子:

+-----------------------------------------------------------------------------------+
|                                     父控件C                                        |
|                                                                                   |
|          +-------------------------+      +-------------------------+             |
|          |                         |      |                         |             |
|          |      +---------+        |      |     +---------+         |             |
|          |      |         |        |      |     |         |         |             |
|          |      |  按钮A  |  区域B  |      |     |   按钮D |   区域E |             |
|          |      |         |        |      |     |         |         |             |
|          |      +---------+        |      |     +---------+         |             |
|          |                         |      |                         |             |
|          |                         |      |                         |             |
|          +-------------------------+      +-------------------------+             |
|                                                                                   |
|                                                                                   |
+-----------------------------------------------------------------------------------+

父控件C设置了一个组合TouchDelegate, 分别将A, D的触摸区域扩大为B, E.

当用户先点点击扩展区域B(点击发生在B区域内部, A区域外部), 再点击扩展区域E(点击发生在E区域内部, D区域外部), 按钮D只收得到ACTION_DOWN事件, 收不到ACTION_MOVEACITON_UP事件.

因为按钮A的TouchDelegate在第一次点击后, 内部状态已经错乱, 此后当它的TouchDelegate#onTouchEvent在收到不在扩展区域ACTION_DOWN时返回false, 在收到不在扩展区域的ACTION_MOVEACTION_UP时返回true. 此时按钮D的点击事件无法正常触发. 但点击按钮D是能正常触发D的点击事件的, 因为此时事件直接由C派发给了D, 没有经过C的onTouchEvent.

修改方案也很简单, 将事件派发给全部TouchDelegate, 最终代码如下:

public class TouchDelegateComposite extends TouchDelegate {
    private static final Rect USELESS_RECT = new Rect();
    private final List<TouchDelegate> mDelegates = new ArrayList<TouchDelegate>(8);

    public TouchDelegateComposite(@NonNull View view) {
        super(USELESS_RECT, view);
    }

    public void addDelegate(@NonNull TouchDelegate delegate) {
        mDelegates.add(delegate);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        boolean res = false;
        float x = event.getX();
        float y = event.getY();
        for (TouchDelegate delegate : mDelegates) {
            event.setLocation(x, y);
            res = delegate.onTouchEvent(event) || res;
        }
        return res;
    }

}

官方修复

之前我贴出来的代码是Android 8.1的, 在Android 9.0上, Google终于发现并修复了这个问题, 下面是9.0上的TouchDelegate相关代码:

// TouchDelegate.java 9.0.0_r3
public boolean onTouchEvent(MotionEvent event) {
    ...

    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            mDelegateTargeted = mBounds.contains(x, y);
            sendToDelegate = mDelegateTargeted;
            break;
        ...
    }
    ...
}

可以看到这个版本的TouchDelegate修复了mDelegateTargeted没有重置的问题, 不再会因为点击扩展区域, 导致父控件的点击事件无法触发.

推荐阅读更多精彩内容