滑动事件和触摸机制的结合有一定的套路,但也比较的复杂。其套路大概是在 ACTION_MOVE 动作时进行拖动,到了 ACTION_UP 动作进行释放,其中夹杂着一些处理动作坐标,id,速度,touchslop ,scroll 清理等琐碎的事件,但最核心和复杂的地方还是在于处理好触摸事件的传递 。
ViewDragHelper ViewDragHelper 类干预了容器的触摸事件机制,是一个非常好的研究对象。具体干预可以分为两个阶段
拖动阶段:此时使用ViewCompat.offset 方法进行移动。
释放阶段:非必须,依赖于 Scoller 类来实现。
ViewDragHelper 的工作实际非常简单,即获取点击点下的子控件,并随着手指滑动它,手势离开时释放,Callback 可以提供一些参数和信息,也包括处理一些回调。
在处理滑动的时候有两个细节要注意
1.如何判断和获取滑动的步进距离?
ACTION_MOVE事件下的实际步进距离 dx/dy 为两次动作的差值,一般都设定不能超过 touchslop。
此外dx/dy 的取值不能按照真实值,而是以 touchslop 为准,以保持滑动过程的平稳。
1 2 3 4 5 6 7 if (Math.abs(dx) >= touchSlop) { dx = (dx > 0 ) ? touchSlop : -touchSlop; } if (Math.abs(dy) >= touchSlop) { dy = (dy > 0 ) ? touchSlop : -touchSlop; }
2.如何确定实际移动距离?
有了可用的步进距离,还需要看控件是不是实际需要移动这么多距离,移动时CapureView的位置有三个取值
旧值 int oldLeft = toCapture.getLeft();
期望值 int targetLeft = oldLeft + (int) dx;
实际值 int clampedX = mCallback.clampViewPositionHorizontal(oldLeft , dx);
clampViewPositionHorizontal 方法是必须实现的,否则控件只能移动到(0,0)坐标处。
深入触摸事件处理的实现 现在深入讨论下在如下简单使用的情况下,ViewDragHelper 是如何进行触摸事件处理的?
1 2 3 4 5 6 7 8 9 @Override public boolean onInterceptTouchEvent (MotionEvent ev) { return dragHelper.shouldInterceptTouchEvent(ev); } @Override public boolean onTouchEvent (MotionEvent ev) { dragHelper.processTouchEvent(ev); return true ; }
首先明确拦截方法是以状态是否为拖动状态(即STATE_DRAGGING) 为判据的。
1 2 3 4 public boolean shouldInterceptTouchEvent (MotionEvent ev) { return mDragState == STATE_DRAGGING; }
正常点击(即状态为STATE_IDLE,点击动作为 ACTION_DOWN) 时,此方法并不去捕捉View,也不修改状态,自然也就拦截无效,事件向下传递;这时要想生效,只能祈祷下面的控件不要处理事件,使得事件能够传回到 onTouchEvent 方法中进行View的捕捉,并修改状态为 STATE_DRAGGING。
这样当动作转为 ACTION_MOVE 后,拦截机制虽然判定为真,但却没有机会执行了,而是直接执行 onTouchEvent 方法进行拖动,直到释放为止。
这里有两个引申问题
1.实际上拦截方法什么都没干,去掉拦截方法,也一样能够实现拖动效果。如果子控件拦截了事件(如设置了监听器),那么 ViewDragHelper 的这种简单使用方法就失效了,不会产生拖动效果。
2.那么拦截事件的意义是什么呢?
当释放控件或处在释放状态(即STATE_SETTLING)时,发生触摸时,拦截方法将发挥作用,此时会主动进行捕捉View和修改状态,触摸事件被拦截在这一层,其主要意义在于截断了子控件的事件处理 。
此外在动作处于 ACTION_MOVE 时,也会尝试截断事件处理流程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 final int pointerCount = ev.getPointerCount();for (int i = 0 ; i < pointerCount; i++) { final int pointerId = ev.getPointerId(i); final float x = ev.getX(i); final float y = ev.getY(i); final float dx = x - mInitialMotionX[pointerId]; final float dy = y - mInitialMotionY[pointerId]; final View toCapture = findTopChildUnder((int ) x, (int ) y); final boolean pastSlop = toCapture != null && checkTouchSlop(toCapture, dx, dy); if (pastSlop) { final int oldLeft = toCapture.getLeft(); final int targetLeft = oldLeft + (int ) dx; final int newLeft = mCallback.clampViewPositionHorizontal(toCapture, targetLeft, (int ) dx); final int oldTop = toCapture.getTop(); final int targetTop = oldTop + (int ) dy; final int newTop = mCallback.clampViewPositionVertical(toCapture, targetTop, (int ) dy); final int horizontalDragRange = mCallback.getViewHorizontalDragRange( toCapture); final int verticalDragRange = mCallback.getViewVerticalDragRange(toCapture); if ((horizontalDragRange == 0 || horizontalDragRange > 0 && newLeft == oldLeft) && (verticalDragRange == 0 || verticalDragRange > 0 && newTop == oldTop)) { break ; } } if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) { break ; } }
这部分代码只在子控件获取到控制权时才发挥作用,其效果就是把控制权夺取回来。前面已经说过,如果子控件设置了点击事件监听器,ViewDragHelper 默认就无法拦截和处理了,你可以做以下配置解决这个问题,使得子控件的点击事件和滑动同时生效。
1 2 3 4 5 6 7 8 9 @Override public int getViewHorizontalDragRange (View child) { return 1 ; } @Override public int getViewVerticalDragRange (View child) { return 1 ; }
只要上述两个方法有一个不返还 0 就可以完成事件的截断。只是也要避免以下的极端情况
1 2 3 4 @Override public int clampViewPositionHorizontal (View child, int left, int dx) { return left-dx; }
最后还应该注意到,这种简单实现中,onTouchEvent 方法则永远进行处理,这意味着其上的控件永远无法得到处理机会,除非提前使用拦截方法完成拦截。
释放后的滚动 当ACTION_UP和ACTION_CANCEL事件发生时,将进行控件的释放,即
1 2 3 4 5 6 7 8 9 10 private void releaseViewForPointerUp () { mVelocityTracker.computeCurrentVelocity(1000 , mMaxVelocity); final float xvel = clampMag( VelocityTrackerCompat.getXVelocity(mVelocityTracker, mActivePointerId), mMinVelocity, mMaxVelocity); final float yvel = clampMag( VelocityTrackerCompat.getYVelocity(mVelocityTracker, mActivePointerId), mMinVelocity, mMaxVelocity); dispatchViewReleased(xvel, yvel); }
这里将调用onViewReleased回调方法,并将状态设置回 STATE_IDLE。
在回调方法里可以主动释放控件,该效果基于ScrollerCompat 实现,需要smoothSlideViewTo方法与continueSettling 方法配合使用。例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override public void onViewReleased (View releasedChild, float xvel, float yvel) { mHelper.smoothSlideViewTo(releasedChild, 100 , 300 ); invalidate(); } @Override public void computeScroll () { if (mLeftDragger.continueSettling(true )) { ViewCompat.postInvalidateOnAnimation(this ); } }
这里 smoothSlideViewTo 方法实际是要 Scroller 实现的,其参数指定了滑动的终点,而起点则是View的左上角(left/top),至于滑动的时间还与速度速度有关。
这里提一下速度参数的获取和使用
VelocityTracker VelocityTracker计算速度的流程如下
1 2 3 4 5 6 public void addMovement (MotionEvent event) ;public void computeCurrentVelocity (int units) ;public float getYVelocity (int id) ;public float getXVelocity (int id) ;
获取速度分量之后的使用与 scroller 有关,即作为滑动的一个参数。
1 public void fling (int startX, int startY, int velocityX, int velocityY, int minX, int maxX, int minY, int maxY)
额外的细节:边界处理和锁定 因为四边区域是有交叉的,故而应该采用位运算的标记方式来表示它们。
1 2 3 4 5 public static final int EDGE_LEFT = 1 << 0 ;public static final int EDGE_RIGHT = 1 << 1 ;public static final int EDGE_TOP = 1 << 2 ;public static final int EDGE_BOTTOM = 1 << 3 ;public static final int EDGE_ALL = EDGE_LEFT | EDGE_TOP | EDGE_RIGHT | EDGE_BOTTOM;
如何判断点击点(x,y)处于四边之内呢?
1 2 3 4 5 6 7 8 private int getEdgesTouched (int x, int y) { int result = 0 ; if (x < mParentView.getLeft() + mEdgeSize) result |= EDGE_LEFT; if (y < mParentView.getTop() + mEdgeSize) result |= EDGE_TOP; if (x > mParentView.getRight() - mEdgeSize) result |= EDGE_RIGHT; if (y > mParentView.getBottom() - mEdgeSize) result |= EDGE_BOTTOM; return result; }
在触摸事件处理时,ACTION_MOVE 动作只处理点击点在控件内的事件,此时是无法使用边界的;只有在 ACTION_DOWN 动作时才能进行拦截,处理代码如下
1 2 3 4 final int edgesTouched = mInitialEdgesTouched[pointerId];if ((edgesTouched & mTrackingEdges) != 0 ) { mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId); }
这里首先要配置 mTrackingEdges 参数。
1 viewDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_ALL);
而后在回调方法中进行捕捉View
1 2 3 4 @Override public void onEdgeTouched (int edgeFlags, int pointerId) { dragHelper.tryCaptureViewForDrag(getChildAt(0 ), pointerId); }
这样就改变了事件处理的流程,截断了事件的向下传递,改为容器自己处理。
ViewDragHelper的实践:SwipeBackLayout
同类型的库还有ParallaxBackLayout 。
使用 ViewDragHelper 可以快速实现一些容器,例如抽屉控件等,这里介绍一个经典的库 SwipeBackLayout,其效果是使得 Activity 能够滑动关闭,达到这种效果需要三步。
1.使用 ViewDragHelper 定义 SwipeBackLayout,其内部只有一个子控件就是 Activity 的 content 控件。
2.在DecorView中插入 SwipeBackLayout 布局,并将原来Activity 的 content 控件加入到此布局中去。
1 2 3 4 5 6 7 8 9 10 11 12 13 ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView(); ViewGroup decorChild = (ViewGroup) decor.getChildAt(0 ); TypedArray a = activity.getTheme().obtainStyledAttributes(new int []{ android.R.attr.windowBackground }); int background = a.getResourceId(0 , 0 );a.recycle(); decorChild.setBackgroundResource(background); decor.removeView(decorChild); addView(decorChild); setContentView(decorChild); decor.addView(this );
到此为止,内容控件可以被捕捉和滑动了。
3.实现透明效果
将 window 的背景改为透明
1 mActivity.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
正常情况下背景将变成黑色,如果不使用该方法则默认的windowBackground颜色是白色。此时需要采用 translucent 模式,修改主题为
1 2 3 <style name ="AppTheme" parent ="Theme.AppCompat.Light.DarkActionBar" > <item name ="android:windowIsTranslucent" > true</item > </style >
嵌套滑动机制解决主控件 的滑动带动副控件 做跟随滑动的问题,这其实是很不可思议的一件事,因为事件触摸机制会截断事件的处理,正常情况下一次只能滑动一个控件。
使用嵌套滑动时主要实现主控件的逻辑,即 NestedScrollingParent 接口中的方法,一般是进行主控件的滑动。
在一次嵌套滑动事件中,二者处于问答式交互,其一般流程如下
1.调用 startNestedScroll() 方法,子控件会在控件树上不断上溯寻找能够响应嵌套滑动事件的父容器,一旦确定会调用父控件的 onStartNestedScroll 方法和 onNestedScrollAccepted 方法判断是否响应,一旦父控件响应就会启动嵌套滑动。
2.执行 dispatchNestedPreScroll 方法,触发父控件中的嵌套滑动事件回调,此时可以滑动父控件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public boolean dispatchNestedPreScroll (int dx, int dy, int [] consumed, int [] offsetInWindow) { mView.getLocationInWindow(offsetInWindow); int startX = offsetInWindow[0 ]; int startY = offsetInWindow[1 ]; consumed[0 ] = 0 ; consumed[1 ] = 0 ; ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed); mView.getLocationInWindow(offsetInWindow); offsetInWindow[0 ] -= startX; offsetInWindow[1 ] -= startY; return consumed[0 ] != 0 || consumed[1 ] != 0 ; }
这里如果consumed
数组中的值不为0,整个方法返回true
,表示父控件消费了滑动事件。
3.最后一轮对话 dispatchNestedScroll 方法
1 2 3 public boolean dispatchNestedScroll (dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow) ;
要使用嵌套滑动,要在触摸事件方法中进行,以便启动该机制;其次要设置父容器的嵌套处理方法。 例如
1 2 3 4 5 6 7 8 9 public void onNestedPreScroll (View target, int dx, int dy, int [] consumed) { consumed[1 ] = dy/2 ; ViewCompat.offsetTopAndBottom(this , consumed[1 ]); ViewCompat.offsetTopAndBottom(target, -consumed[1 ]); } public void onNestedScroll (View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { ViewCompat.offsetTopAndBottom(this , dyUnconsumed); }
就会得到一个2倍的视差滚动效果,子控件的滚动速度是父控件的一半。
下面我们先研究 ScrollView 和 NestedScrollView 中是如何使用嵌套滑动的。
ScrollView 类是一个研究滑动与触摸的绝佳例子,它拦截触摸事件进行上下滚动,而且能够响应嵌套滑动事件。
ScrollView 的拦截是以 mIsBeingDragged 位来判定的,这里有一些简化步骤。
1 2 3 4 5 6 7 8 9 10 11 12 13 @Override public boolean onInterceptTouchEvent (MotionEvent ev) { final int action = ev.getAction(); if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) return true ; if (super .onInterceptTouchEvent(ev)) return true ; if (getScrollY() == 0 && !canScrollVertically(1 )) return false ; return mIsBeingDragged; }
我们首先说说,在 ACTION_DOWN 和 ACTION_UP 里进行的初始化和回收工作,注意这两个动作里一定是不进行拦截的。
1.动作 ACTION_DOWN 发生时要注意清理旧的滚动以及开启嵌套滑动。
1 2 3 mScroller.computeScrollOffset(); mIsBeingDragged = !mScroller.isFinished(); startNestedScroll(SCROLL_AXIS_VERTICAL);
这里按下时控件在滑动中,会拦截事件进行处理,主要是停止正在进行的滑动,并记录数据
1 2 3 4 5 6 7 8 9 10 if ((mIsBeingDragged = !mScroller.isFinished())) { getParent().requestDisallowInterceptTouchEvent(true ); } if (!mScroller.isFinished()) { mScroller.abortAnimation(); } mLastMotionY = (int ) ev.getY(); mActivePointerId = ev.getPointerId(0 ); startNestedScroll(SCROLL_AXIS_VERTICAL);
要注意的是,这时 mIsBeingDragged 的值为 false,不会发生拦截,事件将向下传递,如果没有人处理,则在 onTouchEvent 方法是这样处理的。
1 2 3 4 5 6 7 if ((mIsBeingDragged = !mScroller.isFinished())) { getParent().requestDisallowInterceptTouchEvent(true ); } if (!mScroller.isFinished()) { mScroller.abortAnimation(); } startNestedScroll(SCROLL_AXIS_VERTICAL);
mIsBeingDragged 的值是 mScroller 是否还在滚动。
2.动作 ACTION_UP 发生时,不进行拦截,但又要完成释放回弹和停止嵌套滑动的动作。
1 2 3 4 5 mIsBeingDragged = false ; if (mScroller.springBack(mScrollX, mScrollY, 0 , 0 , 0 , getScrollRange())) { postInvalidateOnAnimation(); } stopNestedScroll();
3.最主要的事件处理发生在 动作ACTION_MOVE 中,此时将发生拦截。
1 2 3 4 5 6 7 8 9 if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0 ) { mIsBeingDragged = true ; mLastMotionY = y; initVelocityTrackerIfNotExists(); mVelocityTracker.addMovement(ev); mNestedYOffset = 0 ; getParent().requestDisallowInterceptTouchEvent(true ); }
包含嵌套滑动的滚动处理,首先交给父控件进行嵌套滑动,而后子控件自己滚动,最后再交给父控件一次
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 final int y = (int ) ev.getY(activePointerIndex);int deltaY = mLastMotionY - y;if (dispatchNestedPreScroll(0 , deltaY, mScrollConsumed, mScrollOffset)) { deltaY -= mScrollConsumed[1 ]; vtev.offsetLocation(0 , mScrollOffset[1 ]); mNestedYOffset += mScrollOffset[1 ]; } if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) { getParent().requestDisallowInterceptTouchEvent(true ); mIsBeingDragged = true ; if (deltaY > 0 ) { deltaY -= mTouchSlop; } else { deltaY += mTouchSlop; } } if (mIsBeingDragged) { mLastMotionY = y - mScrollOffset[1 ]; final int oldY = mScrollY; final int range = getScrollRange(); final int overscrollMode = getOverScrollMode(); if (overScrollBy(0 , deltaY, 0 , mScrollY, 0 , range, 0 , mOverscrollDistance, true ) && !hasNestedScrollingParent()) { mVelocityTracker.clear(); } final int scrolledDeltaY = mScrollY - oldY; final int unconsumedY = deltaY - scrolledDeltaY; if (dispatchNestedScroll(0 , scrolledDeltaY, 0 , unconsumedY, mScrollOffset)) { mLastMotionY -= mScrollOffset[1 ]; vtev.offsetLocation(0 , mScrollOffset[1 ]); mNestedYOffset += mScrollOffset[1 ]; } else if (canOverscroll) { } }
覆盖滚动 覆盖滚动的触发条件是
1 2 3 final int overscrollMode = getOverScrollMode();boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS || (overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0 );
这里给出的 getScrollRange 为
1 Math.max(0 , child.getHeight() - (getHeight() - mPaddingBottom - mPaddingTop));
覆盖滚动的效果是靠 EdgeGlow 实现的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 final int pulledToY = getScrollY() + deltaY;if (pulledToY < 0 ) { mEdgeGlowTop.onPull((float ) deltaY / getHeight(), ev.getX() / getWidth()); if (!mEdgeGlowBottom.isFinished()) { mEdgeGlowBottom.onRelease(); } } else if (pulledToY > range) { mEdgeGlowBottom.onPull((float ) deltaY / getHeight(), 1 .f - ev.getX() / getWidth()); if (!mEdgeGlowTop.isFinished()) { mEdgeGlowTop.onRelease(); } } if (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished()) { postInvalidateOnAnimation(); }
ScrollView 实际已经与 NestedScrollView 大致上一样了,它同时可以作为嵌套滑动的父容器和子控件。我们主要关注它作为父控件时是如何应答的:
1.能够相应的类型是垂直滑动
1 2 3 4 @Override public boolean onStartNestedScroll (View child, View target, int nestedScrollAxes) { return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0 ; }
而且自己作为子控件将事件继续向上分发
1 2 3 4 5 @Override public void onNestedScrollAccepted (View child, View target, int nestedScrollAxes) { mNestedScrollAxes = axes; startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL); }
2.第一轮问答
1 2 3 4 5 @Override public void onNestedPreScroll (View target, int dx, int dy, int [] consumed) { mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, null ); }
从 onNestedPreScroll 方法的实现上看,当它作为父容器要响应嵌套滑动时,它会将事件转发给它的子控件。
3.第二轮问答
1 2 3 4 5 6 7 public void onNestedScroll (View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { final int oldScrollY = getScrollY(); scrollBy(0 , dyUnconsumed); final int myConsumed = getScrollY() - oldScrollY; final int myUnconsumed = dyUnconsumed - myConsumed; mChildHelper.dispatchNestedScroll(0 , myConsumed, 0 , myUnconsumed, null ); }
注意在第二轮问答时 NestedScrollView 控件本身发生了滚动,距离为传入的参数 dyUnconsumed,这是发起者消费后的余量。 消费距离实际就是 dyUnconsumed,而未消费距离为0。此后将这一数据向子控件继续分发。