0%

滑动与触摸

滑动事件和触摸机制的结合有一定的套路,但也比较的复杂。其套路大概是在 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
//1.计算每一个手指
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);
//2,滑动距离要超过 TouchSlop
final boolean pastSlop = toCapture != null && checkTouchSlop(toCapture, dx, dy);
if (pastSlop) {
//3. 实际想要滑动的距离 newLeft和newTop
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);
//4.水平和垂直滑动范围 horizontalDragRange和verticalDragRange
final int horizontalDragRange = mCallback.getViewHorizontalDragRange(
toCapture);
final int verticalDragRange = mCallback.getViewVerticalDragRange(toCapture);
//5. 屏蔽掉不能滑动的情况
if ((horizontalDragRange == 0 || horizontalDragRange > 0
&& newLeft == oldLeft) && (verticalDragRange == 0
|| verticalDragRange > 0 && newTop == oldTop)) {
break;
}
}
//6.一旦某一个手指的动作符合标准,尝试捕捉View和拦截
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
//callback 中的回调方法
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
mHelper.smoothSlideViewTo(releasedChild, 100, 300); //移动到一个点上
//dragHelper.flingCapturedView(0, 200, 200, 400); 返回到一个区域内
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
//1,带入动作 MotionEvent
public void addMovement(MotionEvent event);
//2,配置时间单位 1000表示每秒速度,1表示每毫秒速度
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删去内容控件,添加 SwipeBackLayout,SwipeBackLayout再添加 内容控件
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>

嵌套滑动(NestedScroll)

嵌套滑动机制解决主控件的滑动带动副控件做跟随滑动的问题,这其实是很不可思议的一件事,因为事件触摸机制会截断事件的处理,正常情况下一次只能滑动一个控件。

使用嵌套滑动时主要实现主控件的逻辑,即 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
//dx表示限定的滑动距离,consumed记录父容器消费的滑动距离,offsetInWindow距离在屏幕上移动的距离
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
//1.如果开启嵌套滑动,且父控件存在,且滑动有效
mView.getLocationInWindow(offsetInWindow);
int startX = offsetInWindow[0];
int startY = offsetInWindow[1];

consumed[0] = 0;
consumed[1] = 0;
//2.执行父控件回调
ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

//3.在滑动前后计算了子控件在屏幕上的偏移位置 offsetInWindow,
//这和父控件的消费量 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
//dxConsumed 是子控件滑动的消费,dxUnconsumed则是留给父容器的未消费距离
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 类是一个研究滑动与触摸的绝佳例子,它拦截触摸事件进行上下滚动,而且能够响应嵌套滑动事件。

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();
//1.如果 ACTION_MOVE 且 mIsBeingDragged 直接拦截
if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) return true;
//2.先正常拦截,避免阻塞子控件中的事件,只有
if (super.onInterceptTouchEvent(ev)) return true;
//3.不能滑动,不拦截
if (getScrollY() == 0 && !canScrollVertically(1)) return false;

//,,,,,,
return mIsBeingDragged;
}

我们首先说说,在 ACTION_DOWN 和 ACTION_UP 里进行的初始化和回收工作,注意这两个动作里一定是不进行拦截的。

1.动作 ACTION_DOWN 发生时要注意清理旧的滚动以及开启嵌套滑动。

1
2
3
mScroller.computeScrollOffset();       
mIsBeingDragged = !mScroller.isFinished();//3.如果滑动完成了,不拦截;仍在滑动,拦截处理
startNestedScroll(SCROLL_AXIS_VERTICAL); //4.请求嵌套滑动

这里按下时控件在滑动中,会拦截事件进行处理,主要是停止正在进行的滑动,并记录数据

1
2
3
4
5
6
7
8
9
10
//1.禁止父容器拦截事件,停止滚动,记录数据
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
//1.根据判定条件进行拦截
if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
mIsBeingDragged = true;
mLastMotionY = y; //更新 mLastMotionY
initVelocityTrackerIfNotExists(); //更新 VelocityTracker
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]; //1.deltaY 减去嵌套滑动已消费的距离
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];//2.mNestedYOffset 记录嵌套滑动已消费的距离
}
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
getParent().requestDisallowInterceptTouchEvent(true);
mIsBeingDragged = true;
if (deltaY > 0) {
deltaY -= mTouchSlop; //3.控制 deltaY 的数值,使其滑动均匀可控
} else {
deltaY += mTouchSlop;
}
}
if (mIsBeingDragged) {
mLastMotionY = y - mScrollOffset[1];
final int oldY = mScrollY;
final int range = getScrollRange();
final int overscrollMode = getOverScrollMode();
//4.处理自己的滚动
if (overScrollBy(0, deltaY, 0, mScrollY, 0, range, 0, mOverscrollDistance, true)
&& !hasNestedScrollingParent()) {
mVelocityTracker.clear();
}
//5.再次发起嵌套滚动
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) {
//6.如果父控件没有消费,则处理覆盖滚动
}
}

覆盖滚动

覆盖滚动的触发条件是

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();
}

嵌套滑动的实践: NestedScrollView

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) {
//此时 consumed 为 null
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。此后将这一数据向子控件继续分发。