Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2019-12-05:谈一谈ViewDragHelper的工作原理? #204

Open
MoJieBlog opened this issue Dec 5, 2019 · 2 comments
Open

2019-12-05:谈一谈ViewDragHelper的工作原理? #204

MoJieBlog opened this issue Dec 5, 2019 · 2 comments

Comments

@MoJieBlog
Copy link
Collaborator

No description provided.

@Moosphan Moosphan changed the title 2019-15-05:谈一谈ViewDragHelper的工作原理? 2019-12-05:谈一谈ViewDragHelper的工作原理? Dec 6, 2019
@LineCutFeng
Copy link

LineCutFeng commented Feb 25, 2020

ViewDragHelper类,是用来处理View边界拖动相关的类,比如我们这里要用的例子—侧滑拖动关闭页面(类似微信),该功能很明显是要处理在View上的触摸事件,记录触摸点、计算距离、滚动动画、状态回调等,如果我们自己手动实现自然会很麻烦还可能出错,而这个类会帮助我们大大简化工作量。
该类是在Support包中提供,所以不会有系统适配问题,下面我们就来看看他的原理和使用吧。

1.初始化

private ViewDragHelper(Context context, ViewGroup forParent, Callback cb) {
        ...
        mParentView = forParent;//BaseView
        mCallback = cb;//callback
        final ViewConfiguration vc = ViewConfiguration.get(context);
        final float density = context.getResources().getDisplayMetrics().density;
        mEdgeSize = (int) (EDGE_SIZE * density + 0.5f);//边界拖动距离范围
        mTouchSlop = vc.getScaledTouchSlop();//拖动距离阈值
        mScroller = new OverScroller(context, sInterpolator);//滚动器
    }
  • mParentView是指基于哪个View进行触摸处理

  • mCallback是触摸处理的各个阶段的回调

  • mEdgeSize是指在边界多少距离内算作拖动,默认为20dp

  • mTouchSlop指滑动多少距离算作拖动,用的系统默认值

  • mScroller是View滚动的Scroller对象,用于处理释触摸放后,View的滚动行为,比如滚动回原始位置或者滚动出屏幕

2.拦截事件处理
该类提供了boolean shouldInterceptTouchEvent(MotionEvent)方法,通常我们需要这么写:

override fun onInterceptTouchEvent(ev: MotionEvent?) =
            dragHelper?.shouldInterceptTouchEvent(ev) ?: super.onInterceptTouchEvent(ev)

该方法用于处理mParentView是否拦截此次事件

public boolean shouldInterceptTouchEvent(MotionEvent ev) {
        ...
        switch (action) {
            ...
            case MotionEvent.ACTION_MOVE: {
                if (mInitialMotionX == null || mInitialMotionY == null) break;
                // First to cross a touch slop over a draggable view wins. Also report edge drags.
                final int pointerCount = ev.getPointerCount();
                for (int i = 0; i < pointerCount; i++) {
                    final int pointerId = ev.getPointerId(i);
                    // If pointer is invalid then skip the ACTION_MOVE.
                    if (!isValidPointerForActionMove(pointerId)) continue;
                    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);
                    ...
                    //判断pointer的拖动边界
                    reportNewEdgeDrags(dx, dy, pointerId);
                    ...
                }
                saveLastMotion(ev);
                break;
            }
            ...
        }
        return mDragState == STATE_DRAGGING;
}

拦截事件的前提是mDragState为STATE_DRAGGING,也就是正在拖动状态下才会拦截,那么什么时候会变为拖动状态呢?当ACTION_MOVE时,调用reportNewEdgeDrags方法:

private void reportNewEdgeDrags(float dx, float dy, int pointerId) {
        int dragsStarted = 0;
  			//判断是否在Left边缘进行滑动
        if (checkNewEdgeDrag(dx, dy, pointerId, EDGE_LEFT)) {
            dragsStarted |= EDGE_LEFT;
        }
        if (checkNewEdgeDrag(dy, dx, pointerId, EDGE_TOP)) {
            dragsStarted |= EDGE_TOP;
        }
        ...
        if (dragsStarted != 0) {
            mEdgeDragsInProgress[pointerId] |= dragsStarted;
          	//回调拖动的边
            mCallback.onEdgeDragStarted(dragsStarted, pointerId);
        }
}

private boolean checkNewEdgeDrag(float delta, float odelta, int pointerId, int edge) {
        final float absDelta = Math.abs(delta);
        final float absODelta = Math.abs(odelta);
				//是否支持edge的拖动以及是否满足拖动距离的阈值
        if ((mInitialEdgesTouched[pointerId] & edge) != edge  || (mTrackingEdges & edge) == 0
                || (mEdgeDragsLocked[pointerId] & edge) == edge
                || (mEdgeDragsInProgress[pointerId] & edge) == edge
                || (absDelta <= mTouchSlop && absODelta <= mTouchSlop)) {
            return false;
        }
        if (absDelta < absODelta * 0.5f && mCallback.onEdgeLock(edge)) {
            mEdgeDragsLocked[pointerId] |= edge;
            return false;
        }
        return (mEdgeDragsInProgress[pointerId] & edge) == 0 && absDelta > mTouchSlop;
}

可以看到,当ACTION_MOVE时,会尝试找到pointer对应的拖动边界,这个边界可以由我们来制定,比如侧滑关闭页面是从左侧开始的,所以我们可以调用setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT)来设置只支持左侧滑动。而一旦有滚动发生,就会回调callback的onEdgeDragStarted方法,交由我们做如下操作:

override fun onEdgeDragStarted(edgeFlags: Int, pointerId: Int) {
                super.onEdgeDragStarted(edgeFlags, pointerId)
                dragHelper?.captureChildView(getChildAt(0), pointerId)
            }

我们调用了ViewDragHelper的captureChildView方法:

public void captureChildView(View childView, int activePointerId) {
        mCapturedView = childView;//记录拖动view
        mActivePointerId = activePointerId;
        mCallback.onViewCaptured(childView, activePointerId);
        setDragState(STATE_DRAGGING);//设置状态为开始拖动
}

此时,我们就记录了拖动的View,并将状态置为拖动,那么在下次ACTION_MOVE的时候,该mParentView就会拦截事件,交由自己的onTouchEvent方法处理拖动了!

3.拖动事件处理
该类提供了void processTouchEvent(MotionEvent)方法,通常我们需要这么写:

override fun onTouchEvent(event: MotionEvent?): Boolean {
        dragHelper?.processTouchEvent(event)//交由ViewDragHelper处理
        return true
}

该方法用于处理mParentView拦截事件后的拖动处理:

public void processTouchEvent(MotionEvent ev) {
        ...
        switch (action) {
            ...
            case MotionEvent.ACTION_MOVE: {
                if (mDragState == STATE_DRAGGING) {
                    // If pointer is invalid then skip the ACTION_MOVE.
                    if (!isValidPointerForActionMove(mActivePointerId)) break;
                    final int index = ev.findPointerIndex(mActivePointerId);
                    final float x = ev.getX(index);
                    final float y = ev.getY(index);
                    //计算距离上次的拖动距离
                    final int idx = (int) (x - mLastMotionX[mActivePointerId]);
                    final int idy = (int) (y - mLastMotionY[mActivePointerId]);
                    dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);//处理拖动
                    saveLastMotion(ev);//记录当前触摸点
                }...
                break;
            }
            ...
            case MotionEvent.ACTION_UP: {
                if (mDragState == STATE_DRAGGING) {
                    releaseViewForPointerUp();//释放拖动view
                }
                cancel();
                break;
            }...
        }
}

(1)拖动
ACTION_MOVE时,会计算出pointer距离上次的位移,然后计算出capturedView的目标位置,进行拖动处理

private void dragTo(int left, int top, int dx, int dy) {
        int clampedX = left;
        int clampedY = top;
        final int oldLeft = mCapturedView.getLeft();
        final int oldTop = mCapturedView.getTop();
        if (dx != 0) {
            clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);//通过callback获取真正的移动值
            ViewCompat.offsetLeftAndRight(mCapturedView, clampedX - oldLeft);//进行位移
        }
        if (dy != 0) {
            clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
            ViewCompat.offsetTopAndBottom(mCapturedView, clampedY - oldTop);
        }

        if (dx != 0 || dy != 0) {
            final int clampedDx = clampedX - oldLeft;
            final int clampedDy = clampedY - oldTop;
            mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY,
                    clampedDx, clampedDy);//callback回调移动后的位置
        }
}

通过callback的clampViewPositionHorizontal方法决定实际移动的水平距离,通常都是返回left值,即拖动了多少就移动多少

通过callback的onViewPositionChanged方法,可以对View拖动后的新位置做一些处理,如:

override fun onViewPositionChanged(changedView: View?, left: Int, top: Int, dx: Int, dy: Int) {
  super.onViewPositionChanged(changedView, left, top, dx, dy)
    //当新的left位置到达width时,即滑动除了界面,关闭页面
    if (left >= width && context is Activity && !context.isFinishing) {
      context.finish()
    }
}

(2)释放
而ACTION_UP动作时,要释放拖动View

private void releaseViewForPointerUp() {
        ...
        dispatchViewReleased(xvel, yvel);
}

private void dispatchViewReleased(float xvel, float yvel) {
        mReleaseInProgress = true;
        mCallback.onViewReleased(mCapturedView, xvel, yvel);//callback回调释放
        mReleaseInProgress = false;
        if (mDragState == STATE_DRAGGING) {
            // onViewReleased didn't call a method that would have changed this. Go idle.
            setDragState(STATE_IDLE);//重置状态
        }
}

通常在callback的onViewReleased方法中,我们可以判断当前释放点的位置,从而决定是要回弹页面还是滑出屏幕:

override fun onViewReleased(releasedChild: View?, xvel: Float, yvel: Float) {
  super.onViewReleased(releasedChild, xvel, yvel)
    //滑动速度到达一定值时直接关闭
    if (xvel >= 300) {//滑动页面到屏幕外,关闭页面
      dragHelper?.settleCapturedViewAt(width, 0)
    } else {//回弹页面
      dragHelper?.settleCapturedViewAt(0, 0)
    }
  //刷新,开始关闭或重置动画
  invalidate()
}

如滑动速度大于300时,我们调用settleCapturedViewAt方法将页面滚动出屏幕,否则调用该方法进行回弹

(3)滚动
ViewDragHelper的settleCapturedViewAt(left,top)方法,用于将capturedView滚动到left,top的位置

public boolean settleCapturedViewAt(int finalLeft, int finalTop) {
  return forceSettleCapturedViewAt(finalLeft, finalTop,
                                   (int) mVelocityTracker.getXVelocity(mActivePointerId),
                                   (int) mVelocityTracker.getYVelocity(mActivePointerId));
}

private boolean forceSettleCapturedViewAt(int finalLeft, int finalTop, int xvel, int yvel) {
  //当前位置
  final int startLeft = mCapturedView.getLeft();
  final int startTop = mCapturedView.getTop();
  //偏移量
  final int dx = finalLeft - startLeft;
  final int dy = finalTop - startTop;
  ...
  final int duration = computeSettleDuration(mCapturedView, dx, dy, xvel, yvel);
  //使用Scroller对象开始滚动
  mScroller.startScroll(startLeft, startTop, dx, dy, duration);
	//重置状态为滚动
  setDragState(STATE_SETTLING);
  return true;
}

其内部使用的是Scroller对象:是View的滚动机制,其回调是View的computeScroll()方法,在其内部通过Scroller对象的computeScrollOffset方法判断是否滚动完毕,如仍需滚动,需要调用invalidate方法进行刷新

ViewDragHelper据此提供了一个类似的方法continueSettling,需要在computeScroll中调用,判断是否需要invalidate

public boolean continueSettling(boolean deferCallbacks) {
  if (mDragState == STATE_SETTLING) {
    //是否滚动结束
    boolean keepGoing = mScroller.computeScrollOffset();
    //当前滚动值
    final int x = mScroller.getCurrX();
    final int y = mScroller.getCurrY();
    //偏移量
    final int dx = x - mCapturedView.getLeft();
    final int dy = y - mCapturedView.getTop();
		//便宜操作
    if (dx != 0) {
      ViewCompat.offsetLeftAndRight(mCapturedView, dx);
    }
    if (dy != 0) {
      ViewCompat.offsetTopAndBottom(mCapturedView, dy);
    }
		//回调
    if (dx != 0 || dy != 0) {
      mCallback.onViewPositionChanged(mCapturedView, x, y, dx, dy);
    }
    //滚动结束状态
    if (!keepGoing) {
      if (deferCallbacks) {
        mParentView.post(mSetIdleRunnable);
      } else {
        setDragState(STATE_IDLE);
      }
    }
  }
  return mDragState == STATE_SETTLING;
}

在我们的View中:

override fun computeScroll() {
  super.computeScroll()
    if (dragHelper?.continueSettling(true) == true) {
      invalidate()
    }
}

@aositeluoke
Copy link

aositeluoke commented Dec 4, 2020

一、状态变更之shouldInterceptTouchEvent

  前提是拖动的子View设置了点击事件且重写了getViewHorizontalDragRange和getViewVerticalDragRange方法,两方法返回值均大于0,mDragState才能变更为STATE_DRAGGING

public boolean shouldInterceptTouchEvent(@NonNull MotionEvent ev) {
        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                for (int i = 0; i < pointerCount; i++) {
                   //给子View设置点击事件后,是否可滑动关键点
                    final boolean pastSlop = toCapture != null && checkTouchSlop(toCapture, dx, dy);
                   //处理边缘拖动
                    reportNewEdgeDrags(dx, dy, pointerId);
                   if (mDragState == STATE_DRAGGING) {
                        // Callback might have started an edge drag
                        break;
                    }
                   //当拖动目标可点击时,经过几次move事件后,pastSlop为true,在tryCaptureViewForDrag->captureChildView方法中修改mDragState为STATE_DRAGGING
                    if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
                        break;
                    }
                }
                break;
            }
        }
        return mDragState == STATE_DRAGGING;
    }

checkTouchSlop(View child, float dx, float dy)

private boolean checkTouchSlop(View child, float dx, float dy) {
        if (child == null) {
            return false;
        }
        //当给子View设置可点击时,一定要重写getViewHorizontalDragRange和getViewVerticalDragRange,否则他们默认返回0,导致此方法返回false
        final boolean checkHorizontal = mCallback.getViewHorizontalDragRange(child) > 0;
        final boolean checkVertical = mCallback.getViewVerticalDragRange(child) > 0;

        if (checkHorizontal && checkVertical) {
            return dx * dx + dy * dy > mTouchSlop * mTouchSlop;//给子View设置点击事件后,跟踪发现此处会调用
        } else if (checkHorizontal) {
            return Math.abs(dx) > mTouchSlop;
        } else if (checkVertical) {
            return Math.abs(dy) > mTouchSlop;
        }
        return false;
    }

二、状态变更之processTouchEvent

  在此方法中修改状态的前提是拖动目标无点击事件,当down事件传递到目标View时,它不做处理导致它的父View的onTouchEvent得到运行,由于我们在onTouchEvent执行了ViewDragHelper的processTouchEvent,因此,状态切换操作来到了这里

public void processTouchEvent(@NonNull MotionEvent ev) {
        final int action = ev.getActionMasked();
        final int actionIndex = ev.getActionIndex();

        if (action == MotionEvent.ACTION_DOWN) {
            // Reset things for a new event stream, just in case we didn't get
            // the whole previous stream.
            cancel();
        }

        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(ev);

        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                final float x = ev.getX();
                final float y = ev.getY();
                final int pointerId = ev.getPointerId(0);
                final View toCapture = findTopChildUnder((int) x, (int) y);//拿到触摸处的子View

                saveInitialMotion(x, y, pointerId);

                // Since the parent is already directly processing this touch event,
                // there is no reason to delay for a slop before dragging.
                // Start immediately if possible.
                //【状态切换】如果tryCapturedView返回true的话,回调onViewCaptured方法且修改状态为STATE_DRAGGING
                tryCaptureViewForDrag(toCapture, pointerId);

                final int edgesTouched = mInitialEdgesTouched[pointerId];
                if ((edgesTouched & mTrackingEdges) != 0) {
                    mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
                }
                break;
            }

            case MotionEvent.ACTION_POINTER_DOWN: {
                final int pointerId = ev.getPointerId(actionIndex);
                final float x = ev.getX(actionIndex);
                final float y = ev.getY(actionIndex);

                saveInitialMotion(x, y, pointerId);

                // A ViewDragHelper can only manipulate one view at a time.
                if (mDragState == STATE_IDLE) {
                    // If we're idle we can do anything! Treat it like a normal down event.

                    final View toCapture = findTopChildUnder((int) x, (int) y);
                    tryCaptureViewForDrag(toCapture, pointerId);

                    final int edgesTouched = mInitialEdgesTouched[pointerId];
                    if ((edgesTouched & mTrackingEdges) != 0) {
                        mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
                    }
                } else if (isCapturedViewUnder((int) x, (int) y)) {
                    // We're still tracking a captured view. If the same view is under this
                    // point, we'll swap to controlling it with this pointer instead.
                    // (This will still work if we're "catching" a settling view.)

                    tryCaptureViewForDrag(mCapturedView, pointerId);
                }
                break;
            }
            
            case MotionEvent.ACTION_MOVE: {
               //拖动状态下,偏移目标View
                if (mDragState == STATE_DRAGGING) {
                    // If pointer is invalid then skip the ACTION_MOVE.
                    if (!isValidPointerForActionMove(mActivePointerId)) break;

                    final int index = ev.findPointerIndex(mActivePointerId);
                    final float x = ev.getX(index);
                    final float y = ev.getY(index);
                    final int idx = (int) (x - mLastMotionX[mActivePointerId]);
                    final int idy = (int) (y - mLastMotionY[mActivePointerId]);

                   //处理偏移
                    dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);

                    saveLastMotion(ev);
                } else {
                    //不是拖动状态下,处理边缘拖动
                    // Check to see if any pointer is now over a draggable view.
                    final int pointerCount = ev.getPointerCount();
                    for (int i = 0; i < pointerCount; i++) {
                        final int pointerId = ev.getPointerId(i);

                        // If pointer is invalid then skip the ACTION_MOVE.
                        if (!isValidPointerForActionMove(pointerId)) continue;

                        final float x = ev.getX(i);
                        final float y = ev.getY(i);
                        final float dx = x - mInitialMotionX[pointerId];
                        final float dy = y - mInitialMotionY[pointerId];
                        //回调onEdgeDragStarted
                        reportNewEdgeDrags(dx, dy, pointerId);
                        if (mDragState == STATE_DRAGGING) {
                            // Callback might have started an edge drag.
                            break;
                        }

                        final View toCapture = findTopChildUnder((int) x, (int) y);
                        if (checkTouchSlop(toCapture, dx, dy)
                                && tryCaptureViewForDrag(toCapture, pointerId)) {
                            break;
                        }
                    }
                    saveLastMotion(ev);
                }
                break;
            }

            case MotionEvent.ACTION_POINTER_UP: {
                final int pointerId = ev.getPointerId(actionIndex);
                if (mDragState == STATE_DRAGGING && pointerId == mActivePointerId) {
                    // Try to find another pointer that's still holding on to the captured view.
                    int newActivePointer = INVALID_POINTER;
                    final int pointerCount = ev.getPointerCount();
                    for (int i = 0; i < pointerCount; i++) {
                        final int id = ev.getPointerId(i);
                        if (id == mActivePointerId) {
                            // This one's going away, skip.
                            continue;
                        }

                        final float x = ev.getX(i);
                        final float y = ev.getY(i);
                        if (findTopChildUnder((int) x, (int) y) == mCapturedView
                                && tryCaptureViewForDrag(mCapturedView, id)) {
                            newActivePointer = mActivePointerId;
                            break;
                        }
                    }

                    if (newActivePointer == INVALID_POINTER) {
                        // We didn't find another pointer still touching the view, release it.
                        releaseViewForPointerUp();
                    }
                }
                clearMotionHistory(pointerId);
                break;
            }

            case MotionEvent.ACTION_UP: {
                //拖动状态下释放View,回调CallBack中的onViewReleased
                if (mDragState == STATE_DRAGGING) {
                    releaseViewForPointerUp();
                }
                cancel();
                break;
            }

            case MotionEvent.ACTION_CANCEL: {
                if (mDragState == STATE_DRAGGING) {
                   //回调onViewReleased
                    dispatchViewReleased(0, 0);
                }
                cancel();
                break;
            }
        }
    }

三、主要工作流程

  • 边界检测,回调onEdgeDragStarted,我们可以在这里滑动我们的View
  • 状态切换,mDragState切换为STATE_DRAGGING分为有点击事件和无点击事件两种
  • move事件中,回调对应的方法并且调用offsetTopAndBottom和offsetLeftAndRight移动View
  • up事件中,回调onViewReleased方法,咱们可以在这里调用ViewDragViewHelper的settleCapturedViewAt方法滑动View

按下时,获取触摸点的View,如果此View不为空且tryCaptureView返回true,则修改状态为拖动状态,move时,如果当前状态为拖动状态,那么会回调clampViewPositionHorizontal和clampViewPositionVertical获取最终的left和top,最后调用offsetTopAndBottom和offsetLeftAndRight偏移View,如果这里的状态不是拖动状态,那么就会处理边界拖动检测下一步可能会回调onEdgeDragStarted方法,up事件来临时,如果是拖动状态,那么就会回调onViewRelease方法,在这个方法里,我们可以处理滑动操作,比如mViewDragHelper.settleCapturedViewAt结合computeScroll一起使用

四、参考链接

Android ViewDragHelper完全解析 自定义ViewGroup神器

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants