Android 4.0规定的有效可触摸的UI元素标准是48dp,这是一个用户手指能准确并且舒适触摸的区域。
日常开发中,如果我们想扩大一个View的点击区域,往往通过给View设置padding即可实现,但是对于某些特殊的情况,如图
因为布局对齐的关系,这个SeekBar不能有paddingTop,而这时又需要在上方增加可响应区域,就只能用TouchDelegate了。
这里提供一篇Android Developer上介绍 TouchDelegate 的文档,TouchDelegate让父视图能够将子视图的可轻触区域扩展到子视图的边界之外。当子视图必须较小,同时又应该具有较大的轻触区域时,此方法很有用。
TouchDelegate的使用方法很简单,考虑以下这种情形
我们想扩大View2的点击区域至View1内部的Bounds区域,代码如下:
view1.post(new Runnable() {
@Override
public void run() {
Rect bounds = new Rect();
// 获取View2占据的矩形区域在其父View(也就是View1)中的相对坐标
view2.getHitRect(bounds);
// 计算扩展后的j矩形区域Bounds相对于View1的坐标,left、top、right、bottom分别为View2在各个方向上的扩展范围
bounds.left -= left;
bounds.top -= top;
bounds.right += right;
bounds.bottom += bottom;
// 创建TouchDelegate,delegateView为View2
TouchDelegate touchDelegate = new TouchDelegate(bounds, view2);
// 为View1设置TouchDelegate,原因可以参考View.java中mTouchDelegate的注释
// The delegate to handle touch events that are physically in this view but should be handled by another view.
view1.setTouchDelegate(touchDelegate);
}
});
使用TouchDelegate的扩展点击区域的原理,可以查看View.java源码(基于API 27),前面使用了下面的代码为View1设置了TouchDelegate
/**
* Sets the TouchDelegate for this View.
*/
public void setTouchDelegate(TouchDelegate delegate) {
mTouchDelegate = delegate;
}
当我们点击View2内部的区域,仍然会触发View2的onClick();而当我们点击View2外且在Bounds内(亦在View1内)的区域,根据Android Touch事件的分发原理,最终一定会触发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
boolean sendToDelegate = false;
// 事件是否发生在Bounds内
boolean hit = true;
// 作为返回值,标识View1是否消费了event(实际上可能传递给View2消费了)
boolean handled = false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Rect bounds = mBounds;
// Down事件发生在Bounds内
if (bounds.contains(x, y)) {
// 存储Down事件的处理策略(传递给View2)供后续的Move和Up参考
mDelegateTargeted = true;
sendToDelegate = true;
}
// !!! 下面被注释的代码为作者添加的优化代码 !!!
// 只有加上下面的代码才能保证在点击Rounds区域触发View2的onClick()后
// 再点击View1仍会触发View1的onClick()
// else {
// mDelegateTargeted = false;
// sendToDelegate = false;
// }
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_MOVE:
// 这里首先参考前面Down事件的处理策略
sendToDelegate = mDelegateTargeted;
if (sendToDelegate) {
Rect slopBounds = mSlopBounds;
// 再检查事件是否发生在SlopBounds内,它是由Bounds向外扩展TouchSlop形成的
if (!slopBounds.contains(x, y)) {
// Down事件在发生在Bounds内,但Move和Up事件未发生在SlopBounds内,说明在此期间手指滑出了指定区域
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));
}
// 将事件分发给View2处理
handled = delegateView.dispatchTouchEvent(event);
}
return handled;
}
然而,直接使用API 27的TouchDelegate会存在一种 bad case:当点击过一次扩展区域Bounds(不包括View2内的部分),View1的点击失效。这是因为当点击过一次Bounds区域,mDelegateTargeted 会被置为true;当下一次点击View1时,由于sendToDelegate为false,Down事件会交由View1处理;而由于mDelegateTargeted仍为true,后续的Move和Up事件还会交由View2处理,阅读View.java的onTouchEvent()源码可知,这种情况下View1的performClick()不会被调用,也就不会触发View1的onClick()
好在Google工程师已经发现了这个问题,并在API 28进行了修复:
最后,本文通过对API 27的TouchDelegate进行扩展,也给出一种使用TouchDelegate扩展点击区域的优化方案,该方案具有以下两个优点:
- 解决了先点击一次扩展区域Bounds(不包括View2内的部分),View1点击失效的问题。
- 内部改用绝对坐标,View2的扩展区域Bounds不再局限于其直接父类View1内,即被代理的View可以是代理View的任意祖先View,使用者只需提供四个方向需要扩展的大小。