事件触摸 机制

一个事件到达界面时, 它的入口是dispatchTouchEvent. 这个方法是视图处理事件的唯一接口, 所有到达视图的事件, 都必须经过这个方法.


简单地说, 系统接收到一个事件, 要丢到一个LinearLayout里面, 怎么办?
直接调这个dispatchTouchEvent, 接收返回的true或者false, 完了.后面的处理就和这个LinearLayout没关系了.


那就有人问了, 那onInterceptTouchEvent, onTouchEvent, onClickListenr, 或者这个LinearLayout里面的Button, 不是还没处理吗, 怎么就完了?

虽然外部只调用dispatchTouchEvent, 但是在这个方法内部, 它自己会根据一系列逻辑调用这些方法.
我们今天聊的, 就是里面的这个逻辑.

事件在视图之间传递的顺序大概是这样的:


父容器的dispatchTouchEvent-->调用内部容器的dispatchTouchEvent-->调用基本控件的dispatchTouchEvent.
这里的容器就是ViewGroup, 控件就是各种View.



通过这样的传递, 一个复杂的组件对它的上层来说就变得统一了: 我不要去关心你里面有什么乱七八糟的基本控件, 怎么摆放, 怎么处理. 我分分钟几十万上下, 找一个小小的具体的Button我累不累? 反正我就把事件丢给你, 怎么处理你来决定.
同样他的下级也是这个思路, 把事件丢到更下一级, 最终传达到一个Button让它响应处理.


这就是责任链模式.

但是这样又产生一个问题: 如果父容器只能起一个传递的作用, 事件只能由子控件响应, 那我ScrollView怎么滑动? ViewPager怎么切换? 工作不能都压给最底层的员工吧, 总有些事情是我经理得自己干的吧, 比如做报表写工作计划泡前台调戏测试之类的..... 咳咳扯远了.


所以这里就需要有一个判断的方法. 一个事件来了, 我自己审核一下, 这个是我的职责, 那后续的事情我就揽下来, 不往下传了. 否则就继续往下传. 嘿嘿我真是太机智了.


这个判断的方法就是onInterceptTouchEvent.


说到这里, 要区分 "动作" 和 "事件" 的概念了.


动作就是用户的一个完整的操作, 比如一个点击, 一个滑动, 等等.
而事件就是MotionEvent了.
我们知道所有的事件MotionEvent其实都是瞬态的, 一个事件本身只代表这一瞬间是什么样子. 你无法从单个MotionEvent看出用户当前是滑动还是长按, 滑动了多远, 向左还是向右. 一个完整的滑动动作是由不断触发 ACTION_DOWN, ACTION_MOVE x N, ACTION_UP(或者CANCEL)来组成的. 这些ACTION_DOWN, ACTION_MOVE代表事件的类型(getAction()).


那么问题又来了.一个单独的瞬态的ACTION_MOVE, 我怎么知道这个事件是2秒前的那次DOWN还是5秒前的那次DOWN带着的? 我如何判断一个完整的动作处理完没有?



视图是通过getDownTime() 这个属性. 每个动作的一系列事件都有相同的downTime, 也就是DOWN事件的getEventTime这个方法所返回的时间. 


所以, 一个完整的动作是由一组MotionEvent组成的, 他们拥有共同的downTime. 当视图接收到具有一个新的downTime的事件时, 它就认为, 之前的动作已经处理完毕了.


这一块基本很少有提到过, 平时也用不太上. 但是很重要.
回到onInterceptTouchEvent这个方法.


这个方法的参数虽然是一个事件, 但它实际上拦截的是一个完整的动作. 这就意味着, 如果你在DOWN事件或者某一个MOVE事件返回了true(意味着告诉视图:"我要拦截了, 这个动作我来处理!"), 那之后的所有事件都不会再继续往子视图传递了,直到有一个新的动作开始!


到这里, 我们可以试图解释一下, 为什么当ViewPager放在ScrollView里面时, 左右滑动经常变得很困难的原因.(筒子们可以试着自己动手写一个Demo, 你会发现左右滑动切换会变得比在外面困难很多.)


原因就是ScrollView在接收到MOVE事件时, 会判断前后两次事件的y坐标之差, 超过一定阈值就会认为是上下滑动事件, 然后就无情地通过onInterceptTouchEvent接管掉了.
而且这个阈值特别小......


而我们在左右滑动的时候, 手指很难保证完全水平地动作. 稍微有一点角度就产生了Y值的变化, 然后ScrollView大爷的onInterceptTouchEvent就觉得"卧槽这是上下滑动事件啊劳资不能不管啊!!!!".然后果断返回true.
于是可怜的ViewPager再也没接收到后续的事件, 也就切换不了了.
同样也可以解释为什么两个ScrollView嵌套时, 里面那个滑不动: 因为Move事件在传递到外面那个ScrollView的onInterceptTouchEvent时直接就被拦了, 压根就传不到里面去. 所以都不是什么冲突的问题, 就是越外面的越先接触到事件, 越能优先拦截下来.
所以要解决这个问题, 也很好办. 重写ScrollView, 修改丫的onInterceptTouchEvent呗.


大概改成这个样子(lastX和lastY在每个事件处理完成后都需要重新赋值成新的getRawX和getRawY, 这里没体现): 


然后再说onTouchEvent.


这个是出现的比较多的, 也是执行比较具体的职责的. 它处理的是每个视图对具体事件的处理.
距离来说, SrollView就是滑动, ViewPager就是切换, ListView也是类似滑动的效果, SeekBar就是拖动滚动条的效果了.
结合上面说的, onInterceptTouchEvent 决定返回true后就交由父视图自己处理, 那很明显就是到这个方法里面来了.
所以我们有这么一张图:

同样, 子视图也是这么个逻辑, 所以这张图会变成

或许会发现为什么最底下那个没有onInterceptTouchEvent?
因为这个方法是ViewGroup特有的, 基本的View是没有这个方法的....
其实也很好理解, 这是判断要不要把事件拦截的方法. 你一个基本的View又没子视图有什么好拦截的?
先放一张图. 这张图纯原创, 是我对于事件传递理解的精华所在,看懂它你就明白每个方法返回true或者false对于整个事件传递的影响如何了.

为了方便, 以后的dispatchTouchEvent就简称dispatch, onInterceptTouchEvent简称intercept,onTouchEvent简称touch.
然后我们再来理一下这里面分发的逻辑.



以公司举例, 你是一个组长, 你的领导丢了一个项目给你(调用了你的dispatch):"那个XXX, 有个XX项目你跟一下.", 你掂量了一下,你带的实习生小A或许可以胜任(intercept返回false), 你就又丢给了小A(调用了小A的dispatch). 
这就是目前的逻辑. 在intercept返回false(判断不拦截事件)后, 事件交到子视图去了.
然后难道就这么完了?


作为组长, 你有责任接受小A的反馈:"组长,这个任务我可以搞定"或者"组长, 我来大姨妈了, 搞不定啊". 然后你该擦屁股擦屁股, 该向上反馈向上反馈"老大, 这个项目我们评估过, 搞不定啊."
这个反馈怎么给? 就是通过dispatch的返回值. dispatch的返回值代表了这个动作在这个视图层面上能否得到处理.
注意: 这里说的是动作, 也就是和intercept拦截的概念一样, 是当前及之后的一系列事件.
这点也很重要.

当小A反馈给你"老大, 我搞不定"(child.dispatch()返回false)后, 身为老大的你应该怎么做?
直接把这个false报上去:"老大, 我底下的小A评估过了, 做不了"(this.dispatch()也返回false)?


恭喜你, 你下午就可以去财务室领剩下的薪水了.然后小A分分钟顶上你的位置走上人生巅峰.



当然得自己评估下啊! 不然要你这个老大干嘛的!
所以这个时候你就得自己来尝试处理了(调用自己的touch方法). 然后得出结论"有搞头"(touch返回true)或者"卧槽原来真的是个坑"(touch返回false), 然后再把这个结论往上报.


所以你看, 处理逻辑进化了一下, 变成这样子

有人问, 小A说能做(child.dispatch返回true), 难道我不审核就直接往上报吗(this.dispatch也返回true)?难道不怕他评估错误了吗?
嗯....按真正的项目流程来说是要的. 不过这里只是为了说明事件分发的逻辑为什么会这么设计.
而且别急, 如果小A评估有误, 之后也有相应的处理.
到这个阶段, 我们总结一下, 视图的onTouchEvent有两种时候会被调用, 一是自己的intercept返回true时(组长我当仁不让, 调戏测试MM的活就交给我了!!). 二是子视图当前事件返回false(你接了一个研发的项目, 小A结果是个美工.... 算了这个项目我还是看看能不能就自己弄了吧.).


我们刚才提到,dispatch返回值代表的是是否有能力处理一个动作, 也就是当前事件之后的一系列事件. 可这里体现的只是当前事件的处理?和之后的事件什么关系?


事实上, 当小A在某个事件时告诉你"老大,我搞不定了"的时候, 属于同一个动作的其他事件就再也不会分配给小A了. 
是的, 效果就和你主动intercept返回true是一样的. 下个同系列的事件传到你这里来时, 你就直接丢给自己的touch处理了, 能搞就搞, 不搞就继续返回false, 于是你的领导也对你采取同样的措施, 不再分配事件给你.....


对于你来说, 这个决策是可以理解的: 整个项目已经明显超出了小A的能力范围, 难道我还要每个需求都确认一遍小A能不能做? 你只有一次机会, 没把握住就没了.


这充分说明了职场的残酷性啊同志们
从这里可以看出, 如果你希望你的领导能把任务一直传递给你, 你应该怎么做?
1. 让你的领导任务来了先传给你试试, 不要随便拦了(parent.intercept返回false);
2. 在接受到任务时, 不管是分配给更下一级也好, 自己处理也好, 告诉领导"我搞定了"(this.dispatch返回true).


这样也只能保证任务传达到你领导时能确定传递到你手上. 万一在更上层的地方被拦了或者被拒绝了, 那谁也没办法.....


所以一个完整的dispatch方法的逻辑是这样子的:


dispatch(event){
if(intercept()){ //先判断自己要不要拦截
return touch(); //拦下来了自己怎么处理就反馈上去
}else{
if(childLastDispatch){ //这个表示子视图是否能处理上次事件
boolean flag = childLastDispatch = child.dispatch(); //让子视图先试图处理
if(!flag){
flag = touch(); //子视图处理不了, 自己处理
}
return flag;
}else{
return touch(); //子视图之前就表示了处理不了, 那就反馈自己处理的结果
}
}
}
时间有限, 没检查, 不过大概意思是这么回事.

scrollView嵌套滑动, 大概就是这么一个概念:


外层是一个大的ScrollView, 黄色部分是外层滑到底部时超出屏幕的部分, 绿色部分是一个小的ScrollView.
向上拉时, 黄色部分滑到屏幕外之后, 就开始滑动绿色的小的scrollview. 往下拉时则是先滑动小的, 小的划到顶部后再把外层的黄色部分滑动下来.


....好吧, 这样是挺抽象的. 如果不知道效果的筒子可以下载一个安卓市场或者应用宝,看看它们的详情页的效果. 这两个软件的实现效果看似一样, 但实现方式是完全不一样的.

之前提过, ScrollView之所以不能嵌套, 原因在于外层的onInterceptTouchEvent方法会拦截上下滑动的事件, 导致事件根本传不到里层的ScrollView里去.



所以基本思路就是, 在外层的滑动方法里做判断: 如果当前应该由外层滑动, 就由外层继续执行动作, 否则就让内层去执行.


我们可以重写onTouchEvent方法, 加入如下判断:


if(//当前向下滑动&&子scrollView还能继续向下滑动){
//手动控制子scrollView向下滑动
}else if(//当前向上滑动 && 父ScrollView已经滑到底部){
//手动控制子scrollView向上滑动
}else{
//自己的滑动动作
}


由此可见, 这里的关键在于:
1. 怎么判断滑动方向
2. 怎么判断能否继续滑动
3. 怎么控制子scrollView滑动

对于问题1, 做过事件相关的筒子应该都有经验, 判断MOVE事件和前一个事件的Y坐标之差就可以得知.


对于问题2, 可以通过scrollview的一个方法:getScrollY(). 这个方法是判断已滑动的距离. 所以可以通过判断getScrollY()的值来判断是否已经滑到顶部或底部.


对于问题3, scrollView很贴心地提供了一个方法: scrollBy, 可以让外部调用进行滑动.


这样一来, 可以敲出这样的代码:


@Override
public boolean onTouchEvent(MotionEvent ev) {
boolean result = false;
if (ev.getAction() == MotionEvent.ACTION_MOVE) {
float distanceY = lastY - ev.getRawY();
if (distanceY < 0) {// 向下滑动的事件// 判断子ScrollView是否已经滑下来了
if (childScrollView.getScrollY() != 0) {
childScrollView.scrollBy(0, (int) distanceY);
result = true;// 表示事件消费掉了
} else {// 否则就留给自己处理
scrollBy(0, (int) distanceY);
result = true;
}
} else {// 向上// 先判断自己能不能滑上去
if (getScrollY() < maxScrollLength) {
// 自己处理
scrollBy(0, (int) distanceY);
result = true;
} else {
childScrollView.scrollBy(0, (int) distanceY);
result = true;
// 表示事件消费掉了
}
}
}
lastX = ev.getRawX();
lastY = ev.getRawY();
if (result) {
return true;
}
return super.onTouchEvent(ev);
}


这里maxScrollLength是父ScrollView可滑动的最大距离

lastX和lastY需要在DOWN事件的时候重置一下:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
lastX = ev.getRawX();
lastY = ev.getRawY();
}
return super.dispatchTouchEvent(ev);
}


为什么不在onTouchEvent里面重置呢?
因为DOWN事件一般都会被子控件响应掉(onTouchEvent返回true), 从之前的分析我们可以得知, DOWN事件基本是不会传到ScrollView里面的

这样的逻辑处理后, 已经可以实现基本的滑动了.
但是实际使用起来, 你会发现体验非常的不好, 动作稍微快点或者进行甩动的时候, 你会发现不管是父ScrollView还是子ScrollView, 压根都甩不动, 卡涩地让人抓狂.


这是为什么呢?
这里面又牵涉到甩动动作(fling)的判断和处理了

前面说到, 滑动的时候会有明显的卡涩感, 其实是因为没有处理甩动(fling)动作的原因.


可以观察一下平时的滑动, 手指离开屏幕后其实常常会有一个延续的短暂滚动的状态, 这个小小的动作能让滚动看起来显得有惯性, 让手感变得更好.


依照前面的逻辑, 其实只能处理ACTION_MOVE的事件——也就是手指还停留在屏幕上时的情况. 虽然ScrollView很贴心地提供了fling接口, 但没有适合的调用的时机.


所以我们要求助于android的手势判断类: GestureDetector


这个类很贴心地帮我们把系列事件转化为相应的手势(也就是之前所说的动作. 为了规范, 下文还是全部称呼为"手势"吧...汗), 其中当然也有判断fling的手势.


首先写一个类继承SimpleOnGestureListener, 你会发现有onFling和onScroll两个方法可以重写:
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
return super.onFling(e1, e2, velocityX, velocityY);
}



public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY{
return super.onScroll(e1, e2, distanceX, distanceY);
};

其中onFling就是我们的目标.
逻辑和onTouchEvent其实很类似:


@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
// 这里的velocityY是两次事件的纵坐标差
if (velocityY > 0) {
// 向下滑动的事件
// 判断子ScrollView是否已经滑下来了
if (childScrollView.getScrollY() != 0) {
MLog.i("child fling down " + velocityY);
childScrollView.fling((int) -velocityY);
return true; // 表示事件消费掉了
} else {
// 否则就留给自己处理
MLog.i("self fling down " + velocityY);
}


} else {
// 向上
// 先判断自己能不能滑上去
if (getScrollY() < maxScrollLength) {
// 自己处理
MLog.i("self fling up " + velocityY);
} else {
MLog.i("child fling up " + velocityY);
childScrollView.fling((int) -velocityY);
return true; // 表示事件消费掉了
}
}
return super.onFling(e1, e2, velocityX, velocityY);
}


这里的super.onFling(e1, e2, velocityX, velocityY);其实就是返回false.
然后初始化一个GestureDetector
mGesture = new GestureDetector(getContext(), new MGestureListener());
并在onTouchEvent里先调用一下mGesture .onTouchEvent(), 如下:


public boolean onTouchEvent(MotionEvent ev) {
boolean result = false;
if (mGesture.onTouchEvent(ev)) {
result = true;
} else if (ev.getAction() == MotionEvent.ACTION_MOVE) {
float distanceY = lastY - ev.getRawY();
......
......
}
lastX = ev.getRawX();

lastY = ev.getRawY();
if (result) {
return true;
}
return super.onTouchEvent(ev);
}


再试试, 就会发现滑动基本完美了
有人看见SimpleOnGestureListener的onScroll方法也可以重写, 或许就会想把onTouchEvent的处理移到这里来. 其实这也是可以的, 不过要注意一点:


onScroll的参数distanceY是个坑!!! 不能像onFling的velocityY或者之前重写onTouch里的distanceY一样直接作为事件变化的距离直接用! 否则你每次刚滑动的时候都会发现整个ScrollView会大幅度地往反方向滚动一次.


原因就是这个distanceY计算的是每两次ACTION_MOVE之间的纵向坐标差值...也就是说当你刚开始滑动手势的时候, 我们需要的是本次ACTION_MOVE 和 ACTION_DOWN 之间的差值, 而这货算的是ACTION_MOVE 和上一次滑动手势的最后一次的ACTION_MOVE 之间的坐标差.... 算这个东西到底有什么意义啊摔!!!
所以到头来, 坐标变化差还是得自己算....
http://tieba.baidu.com/p/3517030519?pn=1

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值