文章目录
- 一、View基础知识
- 1. View 的位置参数
- 2. MotionEvent 和 TouchSlop
- 1)MotionEvent:在手指触摸后产生的一系列事件
- 2)TouchSlop:是系统所能识别的被认为是滑动的最小距离
- 3. VelocityTracker 、GestureDetector 和 Scroller
- 1)VelocityTracker:速度追踪,追踪手指在滑动过程中的速度
- 2)GestureDetector:手势检测,用于辅助用户的单击、滑动、长按、双击等行为
- 3)Scroller:弹性滑动对象,用于实现View的弹性滑动
- 二、View的滑动
- 1. 使用scrollTo/scrollBy
- 2. 使用动画
- 3. 改变布局参数
- 4. 各种滑动方式比对
- 三、弹性滑动
- 1. 使用Scroller
- 2. 使用动画
- 3. 使用延时策略
- 1)Handler/postDelay
- 2)Thread/sleep
- 四、View的事件分发机制
- 1. 传递规则
- 1)三个主要方法
- 2)事件传递顺序
- 3)onTouchEvent中回调顺序
- 4)一些结论
- 2. 事件分发机制源码解析
- 1)Activity 对点击事件的分发过程
- 2)顶级View(ViewGroup)对事件的分发过程
- 3)View对点击事件的处理过程
- 五、View的滑动冲突
- 1. 常见的滑动冲突场景
- 2. 滑动冲突的处理规则
- 3. 滑动冲突的解决方式(待深入学习)
- 1)外部拦截法
- 2)内部拦截法
一、View基础知识
1. View 的位置参数 left、right、top、bottom
View在平移的过程中,top和left表示的是原始左上角的位置信息,值不会发生改变,发生改变的是x、y、translationX和translationY这四个参数
2. MotionEvent 和 TouchSlop 1)MotionEvent:在手指触摸后产生的一系列事件
- ACTION_DOWN
- ACTION_MOVE
- ACTION_UP
通过getRawX/getRawY 返回相对于屏幕左上角的x和y坐标
2)TouchSlop:是系统所能识别的被认为是滑动的最小距离 获取这个常量:ViewConfiguration.get(getContext()).getScaledTouchSlop()
3. VelocityTracker 、GestureDetector 和 Scroller 1)VelocityTracker:速度追踪,追踪手指在滑动过程中的速度
- 在View 的onTouchEvent中追踪当前单击事件的速度
VelocityTracker velocityTracker = VelocityTracker.obtain();
VelocityTracker.addMovement(event);
- 先计算速度再获取速度
VelocityTracker.computeCurrentVelocity(1000);
//1000像素
int xVelocity = (int)velocityTracker.getXVelocity();
- 速度指的是一段时间内手指所滑过的像素数
- 不需要是用clear重置并回收
VelocityTracker.clear();
VelocityTracker.recycle();
2)GestureDetector:手势检测,用于辅助用户的单击、滑动、长按、双击等行为
- 创建一个GestureDetector对象并实现OnGestureListener接口,还可以实现OnDoubleTaoListener 从而监听双击行为
GestureDetector mGestureDetector = new GestureDetector(this);
mGestureDetector.setIsLongpressEnabled(false);
//解决长按屏幕无法拖动现象
- 接管目标View的onTouchEvent方法
boolean consume = mGestureDetector.onTouchEvent(event);
return consume;
- 做完上面两步,就可以有选择地实现OnGestureListener 和 OnDoubleTaoListener中的方法:onSingleTapUp(单击)、onFling(滑动)、onScroll(拖动)、onLongPress(长按)、onDoubleTap(双击)等
3)Scroller:弹性滑动对象,用于实现View的弹性滑动 使用View的scrollTo/scrollBy 方法进行滑动时,其滑动效果是瞬间完成的,这时就可以用Scroller来实现有过渡效果的滑动
Scroller需要和View的computeScroll方法配合使用,典型代码如下:
Scroller scroller = new Scroller(mContext);
// 缓慢滚动到指定位置
private void smoothScrollTo(int destX, int destY) {
int scrollX = getScrollX();
int delta = destX - scrollX;
// 1000ms内滑向destX,效果是慢慢滑动
scroller.starlScroll(scrollX, 0, delta, 0, 1000);
invalidate();
}@override
public void computeScroll() {
if (scroller.computeScrollOffset) {
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
二、View的滑动
3种方式
1. 使用scrollTo/scrollBy
- scrollBy 实际也是基于 scrollTo实现,前者基于当前位置相对滑动,后者基于所传递参数绝对滑动
- mScrollX 总是等于View左边缘和View内容左边缘在水平方向的距离,可通过getScrollX获取
- mScrollY 总是等于View上边缘和View内容上边缘在竖直方向的距离,可通过getScrollY获取
- scrollBy 和 scrollTo 只能改变View内容的位置而不能改变View在布局中的位置
- mScrollX 和 mScrollY 的单位为像素,右移上移为正,反之为负
- View动画的 translationX 和 translationY,注意View动画是对View的影响做操作,并不能真正改变View的位置参数,包括宽/高,并且如果希望动画结束后状态保留还要设置 fillAfter为true
- 属性动画,Android3.0以上才支持,真正改变属性
ObjectAnimator.ofFloat(targetView, "translationX", 0, 100).setDuration(100).start();
注意:使用View动画可能带来问题,平移后的View将无法响应点击事件,因为点击事件实在View的初始区域注册的
3. 改变布局参数 改变LayoutParams
MarginLayoutParams params = (MarginLaoutParams)mButton.getLayoutParams();
params.width += 100;
params.laftMargin += 100;
mButton.requestLayout();
4. 各种滑动方式比对
- scrollTo/scrollBy:操作简单,适合对View内容的滑动
- 动画:操作简单,适用于没有交互的View和实现复杂的动画效果
- 改变布局参数:操作稍微复杂,适用于有交互的View
1. 使用Scroller
Scroller scroller = new Scroller(mContext);
// 缓慢滚动到指定位置
private void smoothScrollTo(int destX, int destY) {
int scrollX = getScrollX();
int delta = destX - scrollX;
// 1000ms内滑向destX,效果是慢慢滑动
scroller.starlScroll(scrollX, 0, delta, 0, 1000);
invalidate();
}@override
public void computeScroll() {
if (scroller.computeScrollOffset) {
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
再看一下computeScrollOffset方法的实现:
public boolean computeScrollOffset() {
int timePassed = (int)(AnimationUtils.currentAnimationTImeMills() - mStartTime);
if (timePassed < mDuration) {
switch (MODE) {
case SCROLL_MODE:
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + MAth.round(x * mDeltaX);
break;
···
}
}
return true;
}
上面是Scroller的典型使用方法
工作原理:
- 构造一个Scroller对象并调用它的 startScroll 方法时,Scroller内部其实什么也没做,只是保存了我们传递的几个参数;
- 然后调用invalidate方法会导致VIew重绘;
- 在VIew 的draw方法中又会去调用 computeScroll 方法,computeScroll方法在View中是一个空实现,因此需要我们自己去实现,computeScrollOffset 中,每一次重绘距滑动起始时间都会有一个时间间隔,通过这个时间间隔Scroller就可以得到当前VIew的滑动位置,computeScroll中我们去向Scroller获取当前的scrollX 和 scrollY,然后通过scrollTo滑动;
- 接着调用postInvalidate 进行第二次重绘;
- 这一次重绘和第一次一样,还是会导致computeScroll被调用,继续向Scroller获取当前的scrollX 和 scrollY并滑动,如此反复,直至滑动结束。
2. 使用动画 基本实现:
ObjextAnimator.odFloat(targetView, "translationX", 0, 100).setDuration(100).start();
模仿Scroller实现:
final int StartX = 0;
final int deltaX = 100;
ValueAnimator animator = ValueAnimator.ofInt(0, 1).setDuration(1000);
animator.addUppdateListener(new AnimatorUpdateListener() {
@override
public void onAnimatorUpdate(ValueAnimator animator) {
float duration = animator.getAnimatedFraction();
mButton.scrollTo(startX + (int)(deltaX * fraction), 0);
}
})
上述实现本质上没有作用于任何对象,只是在1000ms内在动画的每一帧获取动画完成比例,根据这个比例计算出VIew要滑动的距离,针对VIew内容而非VIew本身
3. 使用延时策略 1)Handler/postDelay 大约1000ms内将VIew向左移动100像素
private static final int MESSAGE_SCROLL_TO = 1;
private static final int FRAME_COUNT = 30;
private static final int DELAYED_TIME = 30;
private int mCount = 0;
@SuppressLint("HandlerLeak")
private Handler mHandler = new Handler() {
public void handleMaeeage(Messaeg msg) {
switch(msg.what) {
case Message_SCROLL_TO: {
mCount ++;
if (mCount <= FRAME_COUNT) {
float fraction = mCount / (float) FRAME_COUNT;
int scrollX = (int)(fraction * 100);
mButton.scrollTo(scrollX, 0);
mHandler.sendEmptyMessageDelayd(MESSAGE_SCROLL_TO, DELAYED_TIME);
}
break;
}
default:
break;
}
};
};
2)Thread/sleep 四、View的事件分发机制
参考:https://www.gcssloop.com/customview/dispatch-touchevent-theory
http://wuxiaolong.me/2015/12/19/MotionEvent/
1. 传递规则 1)三个主要方法
类型 | 相关方法 | Activity | VIewGroup | View |
---|---|---|---|---|
事件分发 | dispatchTouchEvent | ?? | ?? | ?? |
事件拦截 | onInterceptTouchEvent | ? | ?? | ? |
事件消费 | onTouchEvent | ?? | ?? | ?? |
- Activity 作为原始的事件分发者,如果 Activity 拦截了事件会导致整个屏幕都无法响应事件,这肯定不是我们想要的效果。
- View最为事件传递的最末端,要么消费掉事件,要么不处理进行回传,根本没必要进行事件拦截。
public Boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if (onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
} else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
2)事件传递顺序 事件收集之后最先传递给 Activity, 然后依次向下传递,大致如下:
Activity -> PhoneWindow -> DecorView -> ViewGroup -> ... -> View
如果最后分发到View,如果这个View也没有处理事件怎么办,那么这个事件会按照反方向回传,最终传回给Activity,如果最后 Activity 也没有处理,本次事件才会被抛弃:
Activity <- PhoneWindow <- DecorView <- ViewGroup <- ... <- View
3)onTouchEvent中回调顺序
- 单击事件(onClickListener) 需要两个两个事件(ACTION_DOWN 和 ACTION_UP )才能触发,如果先分配给onClick判断,等它判断完,用户手指已经离开屏幕,黄花菜都凉了,定然造成 View 无法响应其他事件,应该最后调用。(最后)
- 长按事件(onLongClickListener) 同理,也是需要长时间等待才能出结果,肯定不能排到前面,但因为不需要ACTION_UP,应该排在 onClick 前面。(onLongClickListener > onClickListener)
- 触摸事件(onTouchListener) 如果用户注册了触摸事件,说明用户要自己处理触摸事件了,这个应该排在最前面。(最前)
- View自身处理(onTouchEvent) 提供了一种默认的处理方式,如果用户已经处理好了,也就不需要了,所以应该排在 onTouchListener 后面。(onTouchListener > onTouchEvent)
onTouchListener > onTouchEvent > onLongClickListener > onClickListener
4)一些结论
- 同一个事件序列从手指接触屏幕到离开屏幕,以down开始,中间含有数目不定的move,最终以up结束
- 正常情况下,一个事件序列只能被一个View拦截并消耗
- 某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTouchEvent返回false),那么同一事件序列中的其他事件都不会再交给它处理,并将事件重新交给它的父元素去处理
- 如果View不消耗除ACTION_DOWN以外的其他事件,那么这个点击事件会消失,父元素的onTouchEvent不会调用,最终这些消失的点击事件会传递给Activity处理
- ViewGroup默认不拦截任何事件
- View没有onInterceptTouchEvent
- View的onTouchEvent默认会消耗事件,除非它是不可点击的
- View的enable不影响onTouchEvent返回值
2. 事件分发机制源码解析 参考:https://www.gcssloop.com/customview/dispatch-touchevent-source
1)Activity 对点击事件的分发过程
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
事件开始交给Activity中的WIndow进行分发,返回flase意味着事件没人处理,那么Activity的 onTouchEvent会被调用
Window是一个抽象类,superDispatchTouchEvent也是抽象方法,其实现类是 PhoneWindow,PhoneWindow中处理:
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
PhoneWindow会将事件传递给DecorView,DecorView就说当前界面的底层容器(即setContentView所设置的View的父容器)
public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks
DecorView 继承自FrameLayout且是Activity根View的父View,所以事件到这里已经传递给顶级View了,即在Activity中通过setContentView所设置的VIew,顶级View也叫根View,一般是VIewGroup
2)顶级View(ViewGroup)对事件的分发过程 事件达到顶级View(一般是一个ViewGroup)后,会调用ViewGroup的dispatchTouchEvent,主要工作:
- 判断自身是否需要(询问 onInterceptTouchEvent 是否拦截),如果需要,调用自己的 onTouchEvent
- 自身不需要或者不确定,则询问 ChildView ,一般来说是调用手指触摸位置的 ChildView
- 如果子 ChildView 不需要则调用自身的 onTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
// 调试用
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(ev, 1);
}// 判断事件是否是针对可访问的焦点视图(很晚才添加的内容,个人猜测和屏幕辅助相关,方便盲人等使用设备)
if (ev.isTargetAccessibilityFocus() && isAccessibilityFocusedViewOrHost()) {
ev.setTargetAccessibilityFocus(false);
}boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;
// 处理第一次ACTION_DOWN.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// 清除之前所有的状态
cancelAndClearTouchTargets(ev);
resetTouchState();
}// 检查是否需要拦截.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
// 询问是否拦截
ev.setAction(action);
// 恢复操作,防止被更改
} else {
intercepted = false;
}
} else {
// 没有目标来处理该事件,而且也不是一个新的事件事件(ACTION_DOWN), 进行拦截。
intercepted = true;
}// 判断事件是否是针对可访问的焦点视图
if (intercepted || mFirstTouchTarget != null) {
ev.setTargetAccessibilityFocus(false);
}// 检查事件是否被取消(ACTION_CANCEL).
final boolean canceled = resetCancelNextUpFlag(this)
|| actionMasked == MotionEvent.ACTION_CANCEL;
final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0;
TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
// 如果没有取消也没有被拦截 (进入事件分发)
if (!canceled && !intercepted) {// 如果事件是针对可访问性焦点视图,我们将其提供给具有可访问性焦点的视图。
// 如果它不处理它,我们清除该标志并像往常一样将事件分派给所有的 ChildView。
// 我们检测并避免保持这种状态,因为这些事非常罕见。
View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus()
? findChildWithAccessibilityFocus() : null;
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
final int actionIndex = ev.getActionIndex();
final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
: TouchTarget.ALL_POINTER_IDS;
// 清除此指针ID的早期触摸目标,防止不同步。
removePointersFromTouchTargets(idBitsToAssign);
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x = ev.getX(actionIndex);
// 获取触摸位置坐标
final float y = ev.getY(actionIndex);
// 查找可以接受事件的 ChildView
final ArrayList preorderedList = buildOrderedChildList();
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
// ▼注意,从最后向前扫描
for (int i = childrenCount - 1;
i >= 0;
i--) {
final int childIndex = customOrder
? getChildDrawingOrder(childrenCount, i) : i;
final View child = (preorderedList == null)
? children[childIndex] : preorderedList.get(childIndex);
// 如果有一个视图具有可访问性焦点,我们希望它首先获取事件,
// 如果不处理,我们将执行正常的分派。
// 尽管这可能会分发两次,但它能保证在给定的时间内更安全的执行。
if (childWithAccessibilityFocus != null) {
if (childWithAccessibilityFocus != child) {
continue;
}
childWithAccessibilityFocus = null;
i = childrenCount - 1;
}// 检查View是否允许接受事件(即处于显示状态(VISIBLE)或者正在播放动画)
// 检查触摸位置是否在View区域内
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}// getTouchTarget 中判断了 child 是否包含在 mFirstTouchTarget 中
// 如果有返回 target,如果没有返回 null
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// ChildView 已经准备好接受在其区域内的事件。
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
// ??已经找到目标View,跳出循环
}resetCancelNextUpFlag(child);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
for (int j = 0;
j < childrenCount;
j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}ev.setTargetAccessibilityFocus(false);
}
if (preorderedList != null) preorderedList.clear();
}if (newTouchTarget == null && mFirstTouchTarget != null) {
// 没有找到 ChildView 接收事件
newTouchTarget = mFirstTouchTarget;
while (newTouchTarget.next != null) {
newTouchTarget = newTouchTarget.next;
}
newTouchTarget.pointerIdBits |= idBitsToAssign;
}
}
}// 分发 TouchTarget
if (mFirstTouchTarget == null) {
// 没有 TouchTarget,将当前 ViewGroup 当作普通的 View 处理。
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// 分发TouchTarget,如果我们已经分发过,则避免分配给新的目标。
// 如有必要,取消分发。
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}// 如果需要,更新指针的触摸目标列表或取消。
if (canceled
|| actionMasked == MotionEvent.ACTION_UP
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
resetTouchState();
} else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
final int actionIndex = ev.getActionIndex();
final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
removePointersFromTouchTargets(idBitsToRemove);
}
}if (!handled && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
}
return handled;
}
3)View对点击事件的处理过程 事件调度:onTouchListener > onTouchEvent > onLongClickListener > onClickListene
public boolean dispatchTouchEvent(MotionEvent event) {
...
boolean result = false;
// result 为返回值,主要作用是告诉调用者事件是否已经被消费。
if (onFilterTouchEventForSecurity(event)) {
ListenerInfo li = mListenerInfo;
/**
* 如果设置了OnTouchListener,并且当前 View 可点击,就调用监听器的 onTouch 方法,
* 如果 onTouch 方法返回值为 true,就设置 result 为 true。
*/
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}/**
* 如果 result 为 false,则调用自身的 onTouchEvent。
* 如果 onTouchEvent 返回值为 true,则设置 result 为 true。
*/
if (!result && onTouchEvent(event)) {
result = true;
}
}
...
return result;
}
onTouchEvent中
public boolean onTouchEvent(MotionEvent event) {
...
final int action = event.getAction();
// 检查各种 clickable
if (((viewFlags & CLICKABLE) == CLICKABLE ||
(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
(viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
switch (action) {
case MotionEvent.ACTION_UP:
...
removeLongPressCallback();
// 移除长按
...
performClick();
// 检查单击
...
break;
case MotionEvent.ACTION_DOWN:
...
checkForLongClick(0);
// 检测长按
...
break;
...
}
return true;
// ??表示事件被消费
}
return false;
}
注意:
- 不论 View 自身是否注册点击事件,只要 View 是可点击的就会消费事件
- 事件是否被消费由返回值决定,true 表示消费,false 表示不消费,与是否使用了事件无关
1. 常见的滑动冲突场景
- 场景1——外部滑动方向和内部滑动方向不一致
- 场景2——外部滑动方向和内部滑动方向一致
- 场景3——上面两种情况的嵌套
- 对于场景1,用户左右滑动时,让外部的View拦截事件(假设外部是左右滑动的),用户上下滑动时,让内部View拦截事件,判断用户是左右还是上下滑动可以依据滑动路径的角度、速度、距离差等
- 对于场景2,一般在业务上找突破点,根据业务规定在某种状态时需要哪一部分View响应用户的滑动
- 场景3 同 场景2
重写onInterceptTouchEvent 方法:
- ACTION_DOWN 这个事件父容器必须返回false
- ACTION_MOVE 这个事件根据需求来决定是否拦截
- ACTION_UP 事件,必须返回false