从一个布局讲起
最近要实现如下图所示的布局:
布局示意
底部按钮使用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_MOVE
和ACTION_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#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_MOVE
和ACITON_UP
事件, 因为按钮A的TouchDelegate
在收到ACTION_DOWN
时返回false, 在收到ACTION_MOVE
和ACTION_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
没有重置的问题, 不再会因为点击扩展区域, 导致父控件的点击事件无法触发.
网友评论