文章目录
TouchSlop
TouchSlop
是系统所能识别出的被认为是滑动的最小距离,不同设备 TouchSlop 的值也有所不同。
从 framework 层可以看到这个常量的定义:
<dimen name="config_viewConfigurationTouchSlop">8dp</dimen>
// 当两次滑动之间的距离小于 TouchSlop,则不认为是进行滑动操作
ViewConfiguration.get(getContext()).getScaledTouchSlop();
VelocityTracker
追踪手指在滑动过程中的速度,包括水平和竖直方向的速度。
如果 GestureDetector 不能满足需求,或者觉得 GestureDetector 过于复杂,可以自己处理 onTouchEvent()
事件。但需要使用 VelocityTracker 来计算手指移动速度。
使用方法:
private VelocityTracker velocityTracker = VelocityTracker.obtain();
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
// 重置实例
velocityTracker.clear();
}
// 把事件添加进 VelocityTracker
velocityTracker.addMovement(event);
switch(event.getActionMasked()) {
case MotionEvent.ACTION_UP:
// units:计算的时间长度,单位ms
// 填入1000,那么 getXVelocity() 返回的值就是每1000ms内手指移动的像素数
// maxVelocity:速度上限,超过这个速度,计算出的速度会回落到这个速度
// 填了200,而实时速度是300,那么实际的返回速度将是200
// 获取 maxVelocity 的方式:
// ViewConfiguration viewConfiguration = ViewConfiguration.get(getContext());
// int maxVelocity = viewConfiguration.getScaledMaximumFlingVelocity()
velocityTracker.computeCurrentVelocity(1000, 200);
// 在获取速度之前需要调用 computeCurrentVelocity 计算
// 速度计算公式: 速度 = (终点位置 - 起点位置) / 时间段 【速度根据滑动方向不同可以为负值】
int xVelocity = (int) velocityTracker.getXVelocity();
int yVelocity = (int) velocityTracker.getYVelocity();
break;
}
}
滑动
滑动效果实现原理:将一次滑动切割分成多个小滑动,不断进行滑动重绘达到弹性滑动的效果。
Scroller 实现弹性滑动
scrollTo()
和 scrollBy()
进行滑动是瞬间完成的,用户体验不好,使用 Scroller 实现过渡效果滑动。
Scroller 注意事项:
使用 Scroller 对 View 进行弹性滑动,只针对 View 的内容滑动,View 的位置没有发生改变,比如 Button 进行了弹性滑动,但 Button 进行单击事件仍然只有原先的位置有效,按位置滑动的 Button 影像不具备单击响应
Scroller scroller = new Scroller(mContext);
// OverScroller scroller = new OverScroller(mContext);
// onTouchEvent()中
// 缓慢滚动到指定位置
private void smoothScrollTo(int dextX, int dextY) {
int scrollerX = getScrollerX();
int delta = destX - scrollerX;
//1秒内滑向destX
mScroller.startScroll(scrollX, 0, delta, 0, 1000);
invalidate();//invalidate()->draw()->computeScroll()->postInvalidate()->draw()->computeScroll()......
}
// onTouchEvent()外
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
// postInvalidate();
postInvalidateOnAnimation();
}
}
动画实现弹性滑动
// 动画实现的弹性滑动效果与 Scroller 相同,只对 View 的内容进行滑动,对 View 的位置不影响
final int startX = 0;
final int deltaX = 100;
ValueAnimator animator = ValueAnimator.ofInt(0, 1).setDuration(1000);
animator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimaotrUpdate(ValueAnimator animator) {
float fraction = animator.getAnimatedFraction();
((ViewGroup)mButton.getParent()).scrollTo(startX + (int) (deltaX * fraction), 0);
}
});
animator.start();
Handler 延时策略实现弹性滑动
private static final int MESSAGE_SCROLL_TO = 1;
private static final int FRAME_COUNT = 30;
private static final int DELAYED_TIME = 33;
private int mCount = 0;
@SuppressLint("HandlerLeak")
private Handler mHandler = new Handler(){
public void handleMessage(Message msg) {
switch(msg.what) {
case MESSAGE_SCROLL_TO:
mCount++;
if (mCount < FRAME_COUNT) {
float fraction = mCount / (float) FRAME_COUNT;
int scrollX = (int) (fraction * 100);
((ViewGroup)mButton.getParent()).scrollTo(scrollX, 0);
mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO, DELAYED_TIME);
}
break;
}
}
};
View 的事件拦截机制
事件的分发过程由三个很重要的方法来共同完成: dispatchTouchEvent()
、onInterceptTouchEvent()
和 onTouchEvent()
。
- public boolean dispatchTouchEvent(MotionEvent ev)
用来进行事件的分发。如果事件能够传递给当前 View,那么此方法一定会被调用,返回结果受当前 View 的 onTouchEvent()
和下级 View 的 dispatchTouchEvent()
的影响,表示是否消耗当前事件。
dispatchTouchEvent()
它是一个总的调度方法。
- public boolean onInterceptTouchEvent(MotionEvent event)
在上述方法内部调用,用来判断是否拦截某个事件,如果当前 View 拦截了某个事件,那么在同一个事件序列当中,此方法不会被再次调用,返回结果表示是否拦截当前事件。
在返回 true 确认拦截事件时,其实还会发送一次 MotionEvent.ACTION_CANCEL 事件,通知子 View 这个事件我已经拦截处理了,你恢复状态吧!
场景比如手指点击屏幕列表滑动时,子 View 响应到了按下事件改变了背景颜色,但列表拦截了事件去滑动 onInterceptTouchEvent()
被调用返回了 true,所以就会发送 MotionEvent.ACTION_CANCEL 通知子 View 恢复背景。
- public boolean onTouchEvent(MotionEvent event)
在 dispatchTouchEvent()
中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前 View 无法再次接收到事件。
// View伪代码
public boolean dispatchTouchEvent(MotionEvent ev) {
return onTouchEvent(ev);
}
// ViewGroup伪代码
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if (onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
} else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
根据上面伪代码,事件传递的规则:
对于一个根 ViewGroup 来说,点击事件产生后,首先会传递给它,这时它的 dispatchTouchEvent()
就会被调用
-
如果这个 ViewGroup 的
onInterceptTouchEvent()
返回 true 表示它要拦截当前事件,接着事件就会交给这个 ViewGroup 处理,即它的onTouchEvent()
就会被调用 -
如果这个 ViewGroup 的
onInterceptTouchEvent()
返回 false 表示它不拦截事件,这时当前事件就会继续传递给它的子元素,接着子元素的dispatchTouchEvent()
就会被调用,如此反复直到事件被最终处理
当一个 View 需要处理事件时,如果它设置了 onTouchListener,那么 onTouchListener 中的 onTouch()
就会被回调。
这时事件如果处理还要看 onTouch()
的返回值,如果返回 false,则当前 View 的 onTouchEvent()
方法会被调用;如果返回 true,那么 onTouchEvent()
将不会被调用。
由此可见,给 View 设置的 onTouchListener,其优先级比 onTouchEvent()
要高。在 onTouchEvent()
中,如果当前设置的有 onClickListener,那么它的 onClick()
会被调用。
View 事件拦截执行顺序
-
Activity#dispatchTouchEvent()
-> -
递归:
ViewGroup#dispatchTouchEvent()
->-
ViewGroup#onInterceptTouchEvent()
-> -
ViewGroup#onTouchEvent()
-
child#dispatchTouchEvent()
-
super#dispatchTouchEvent()
View#onTouchEvent()
-
Activity#onTouchEvent()
-
dispatchTouchEvent()
-> onInterceptTouchEvent()
-> onTouchEvent()
【优先级:onTouchListener 的 onTouch()
-> onTouchEvent()
-> onClickListener 的 onClick()
】
View 有三个方法,dispatchTouchEvent()
里面包含着 onInterceptTouchEvent()
和 onTouchEvent()
两个方法。
事件传递机制的结论
-
同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏幕的那一刻结束,在这个过程中所产生的一系列事件,这个事件序列以 down 事件开始,中间含有数量不定的 move 事件,最终以 up 事件结束
-
正常情况下,一个事件序列只能被一个 View 拦截且消耗。因为一旦一个元素拦截了某此事件,那么同一个事件序列内的所有事件都会直接交给它处理,因此同一个事件序列中的事件不能分别由两个 View 同时处理,但是通过特殊手段可以做到,比如一个 View 将本该自己处理的事件通过
onTouchEvent()
强行传递给其他 View 处理 -
某个 View 一旦决定拦截,那么这一个事件序列都只能由它来处理(如果事件序列能够传递给它的话),并且它的
onInterceptTouchEvent()
不会再被调用。即一个 View 决定拦截一个事件后,那么系统会把同一个事件序列内的其他方法都直接交给它来处理,因此就不用再调用这个 View 的onInterceptTouchEvent()
去询问它是否要拦截了 -
某个 View 一旦开始处理事件,如果它不消耗 MotionEvent.ACTION_DOWN 事件(
onTouchEvent()
返回了 false),那么同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交由它的父元素处理,即父元素的onTouchEvent()
会被调用。 -
如果 View 不消耗除 MotionEvent.ACTION_DOWN 以外的其他事件,那么这个点击事件会消失,此时父元素的
onTouchEvent()
并不会被调用,并且当前 View 可以持续收到后续的事件,最终这些消失的点击事件会传递给 Activity 处理(即 View 要消耗一个事件,是要一个完整的事件序列,由 down 到 up 结束) -
ViewGroup 默认不拦截任何事件。Android 源码中 ViewGroup 的
onInterceptTouchEvent()
默认返回 false -
View 没有
onInterceptTouchEvent()
,一旦有点击事件传递给它,那么它的onTouchEvent()
就会被调用 -
View 的
onTouchEvent()
默认都会消耗事件(返回 true),除非它是不可点击的(clickable 和longClickable 同时为 false)。View的 longClickable 属性默认都为 false,clickable 属性要分情况,比如 Button 的 clickable 属性默认为 true,而 TextView 的 clickable 属性默认为 false -
View 的 enable 属性不影响
onTouchEvent()
的默认返回值。哪怕一个 View 是 disable 的状态,只要它的 clickable 或 longClickable 有一个为 true,那么它的onTouchEvent()
就返回 true -
onClick()
会发生的前提是当前 View 是可点击的,并且它收到了 down 和 up 的事件 -
事件传递过程是由外向内的,即事件总是先传递给父元素,然后再由父元素分发给子 View,通过
requestDisallowInterceptTouchEvent()
可以在子元素中干预父元素的事件分发过程,但是 MotionEvent.ACTION_DOWN 事件除外
根据以上结论,伪代码如下:
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
// ViewGroup 的 onInterceptTouchEvent() 默认返回 false
// 父 View 确认拦截会发送一个 MotionEvent.ACTION_CANCEL 通知子 View 不要再处理事件了
if (onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
} else {
// View 没有 onInterceptTouchEvent()
// 如果 View 的 dispatchTouchEvent() 返回 true,即回调 onTouchEvent()
consume = child.dispatchTouchEvent(ev);
if (consume) {
// 如果 View 的 onTouchListener 有设置,会优先传递给onTouchListener#onTouch()
if (child.onTouchListener != null) {
child.onTouchListener.onTouch(ev);
} else {
// View 的 onTouchEvent() 默认返回 true 拦截事件
// 除非 clickable=false && longClickable=false 不可点击
// onTouchEvent() 内部如果有 onClickListener,也会回调onClickListener#onClick
consuem = child.onTouchEvent(ev);
if (!consume) {
// View 不消耗事件,往上抛给父元素的 onTouchEvent() 处理
child.getParent().onTouchEvent(ev);
}
}
}
}
}
滑动冲突
常见的滑动冲突场景
滑动冲突解决
解决滑动冲突的方式是配合外部拦截法或内部拦截法,再结合滑动差、速度差、滑动角度等方式进行处理。
外部拦截法
指点击事件都先经过父 View 的拦截处理,根据父 View 决定是否需要拦截。
伪代码说明:
// 外部拦截法,此段代码重写在与子 View 滑动冲突的父 View 中
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch(event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercept = false;
break;
case MotionEvent.ACTION_MOVE: // 判断水平距离的滑动和竖直距离的滑动差进行相应拦截
if (父View需要当前点击事件) { int deltaX = x - mLastXIntercept;
intercept = true; int deltaY = y - mLastYIntercept;
} else { => if (Math.abs(deltaX) > Math.abs(deltaY)) {
intercept = false; intercept = true;
} } else {
break; intercept = false;
case MotionEvent.ACTION_UP: }
intercept = false;
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercept;
}
要重写父 View 的 onInterceptTouchEvent()
注意:
-
MotionEvent.ACTION_DOWN 事件必须返回 false,如果 MotionEvent.ACTION_DOWN 被拦截,一系列事件(即 MotionEvent.ACTION_DOWN 、MotionEvent.ACTION_MOVE、MotionEvent.ACTION_UP)将被拦截,否则事件无法传递给子元素
-
MotionEvent.ACTION_MOVE 事件来确定是否需要拦截事件,拦截 true,不拦截 false
-
MotionEvent.ACTION_UP 事件不能返回 true,会导致子 View 无法接收 MotionEvent.ACTION_UP 事件 onClick 事件无法触发
内部拦截法
指父 View 不拦截任何事件全由子 View 拦截,需要事件则子 View 消耗,不需要抛给父 View 处理。
伪代码说明:
// 内部拦截法,此段代码重写在与外部父 View 滑动冲突的子 View 中
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch(event.getAction()) {
case MotionEvent.ACTION_DOWN:
parent.requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (父View需要此类点击事件) {
parent.requestDisallowInterceptTouchEvent(false); // 这个方法只处理一次事件流,需要处理就需要再调用一次
}
break;
case MotionEvent.ACTION_UP:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}
子 View 要重写 dispatchTouchEvent()
,父 View 要重写 onInterceptTouchEvent()
。
注意:
-
内部拦截法与 Android 的事件分发机制不一致,需要配合
requestDisallowInterceptTouchEvent()
-
父 View 要默认拦截除了 MotionEvent.ACTION_DOWN 以外的其他事件,当子 View 调用
parent.requestDisallowInterceptTouchEvent(false)
时,父 View 才能拦截下来 -
父 View 不拦截 MotionEvent.ACTION_DOWN 事件,拦截了则传递不到子 View 去
自定义触摸反馈总结
自定义触摸反馈简单总结
-
重写
onTouchEvent()
,写入触摸反馈算法,并返回 true 处理事件流 -
如果是 ViewGroup 并且可能与子 View 产生触摸判别冲突,还需要重写
onInterceptTouchEvent()
,在 MotionEvent.ACTION_DOWN 返回 false,在合适的时候返回 true 来拦截事件流 -
如果需要临时阻止父 View 的拦截,可以用
getParent().requestDisallowInterceptTouchEvent()
,这是一个非持久的方法,仅对当前事件流有效
ViewGroup 触摸反馈详细操作
-
除了重写
onTouchEvent()
,还需要重写onInterceptTouchEvent()
-
onInterceptTouchEvent()
中,MotionEvent.ACTION_DOWN 做的事和onTouchEvent()
基本一致或完全一致-
原因:MotionEvent.ACTION_DOWN 在多数手势中起到的是起始记录的作用(例如记录手指落点),而
onInterceptTouchEvent()
调用后,onTouchEvent()
未必会被调用,因此需要把这个记录责任转交给onInterceptTouchEvent()
-
有时 MotionEvent.ACTION_DOWN 也会在经过
onInterceptTouchEvent()
之后再转交给自己的onTouchEvent()
(例如当没有触摸到子 View 或者触摸到的子 View 没有消费事件时)。因此需要确认在onInterceptTouchEvent()
和onTouchEvent()
都被调用,程序状态不会出问题
-
-
onInterceptTouchEvent()
中,MotionEvent.ACTION_MOVE 一般的作用是确认滑动,即当用户朝某一方向滑动一段距离(touch slop)后,ViewGroup 要向自己的子 View 和父 View 确认自己将消费事件-
确认滑动的方式:
Math.abs(event.getX() - downX) > ViewConfiguration.getXxxSlop()
-
告知子 View 的方式:在
onInterceptTouchEvent()
中返回 true,子 View 会收到 MotionEvent.ACTION_CANCEL,并且后续事件不再发给子 View -
告知父 View 的方式:调用
getParent().requestDisallowInterceptTouchEvent(true)
。这个方法会递归通知每一级父 View,让他们在后续事件中不再尝试通过onInterceptTouchEvent()
拦截事件。这个通知仅在当前事件序列有效,即在这组事件结束后(即用户抬手后),父 View 会自动对后续的新事件序列启用拦截机制
-