ViewDragHelper解析以及侧滑控件实现

在前一篇文章从PhotoView看Android手势监听实践中,介绍了PhotoView这一控件的手势控制的分析,其中有三个主要行为的触发,Drag,Fling,Scale,而在PhotoView的实现中除了Scale采取的是一个ScaleGestureDetector这样的一个高级类,前面两种行为都是依赖原生的手势来判断,十分的麻烦,代码量也很大, 那么这两个有没有比较简单实用的类呢?
结论自然是肯定的,这篇文章要介绍的就是这么一个闪亮的存在,ViewDragHelper。先看一下官方对这个类的一个定义。

ViewDragHelper是一个在自定义ViewGroup中十分实用的类,它提供了一系列有用的操作和状态追踪来帮助用户实现在一个ViewGroup内拖动View或者复位 。

总体设计

ViewDragHelper 只有一个类,但是内部还有一个抽象类CallBack。
CallBack中有一系列方法,用来设置许多属性,可拖动的范围,边缘检测,哪个View触发拖动等等。这个CallBack是在初始化一个ViewDragHelper 时的必要参数。

除了CallBack之外,ViewDragHelper 依然是通过 shouldInterceptTouchEvent和 processTouchEvent 以及设置的属性来设置状态判断拖动,不过这些被封装后就不需要我们自己写了,省时省力,ViewDragHelper 内部实际上是一个小型状态机,在IDLE,DRAGGING,SETTLING三种状态之间切换。

流程图

外部设计图

这个图是我们在使用一个ViewDragHelper 所需要做的事情,ViewDragHelper使用一个静态的方式来创建一个对象

    public static ViewDragHelper create(ViewGroup forParent, float sensitivity, Callback cb) {
        final ViewDragHelper helper = create(forParent, cb);
        helper.mTouchSlop = (int) (helper.mTouchSlop * (1 / sensitivity));
        return helper;
    }

第一个参数就是ParentView的引用,第二个参数是一个触发的灵敏程度,默认为1.0,第三个就是图中的自定义的CallBack。
在CallBack中,我们需要根据自己的需要实现对应的方法,总体来说主要是上图中的几个方法:

tryCaptureView: 在这个方法中,我们会去声明我们想要产生Drag的View,这个方法是有返回值的,只有在返回true的情况下,才有权限去真正的产生Drag的行为,我们直接看这个方法在源码中的调用

    boolean tryCaptureViewForDrag(View toCapture, int pointerId) {
        if (toCapture == mCapturedView && mActivePointerId == pointerId) {
            // Already done!
            return true;
        }
        if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {
            mActivePointerId = pointerId;
            captureChildView(toCapture, pointerId);
            return true;
        }
        return false;
    }

toCapture 也就是我们现在手指所在的View,mCapturedView 就是ViewDragHelper 中当前已经有Drag状态的View,实际上即使已经产生了拖动,这个方法依然会不断的触发,在手指Id和View都相同的情况下,就直接return true,如果是第一次,这里的mCallback.tryCaptureView(toCapture, pointerId) 的返回值决定了是否会走到条件语句之内,因此需要在实现的时候如果想要触发Drag,这个方法一定要返回true。

onEdgeDragStarted:如果我们设置了可以在边缘触摸滑动,那么可以在这个方法中实现一个侧滑的效果,通过手动调用ViewDragHelper的 captureChildView 方法

    public void captureChildView(View childView, int activePointerId) {
        if (childView.getParent() != mParentView) {
            throw new IllegalArgumentException("captureChildView: parameter must be a descendant "
                    + "of the ViewDragHelper's tracked parent view (" + mParentView + ")");
        }

        mCapturedView = childView;
        mActivePointerId = activePointerId;
        mCallback.onViewCaptured(childView, activePointerId);
        setDragState(STATE_DRAGGING);
    }

这个方法可以摆脱前面 tryCaptureView 需要返回true的一个限制,即使返回false,在这里依然能够将传进来的childView的状态置为STATE_DRAGGING。

clampViewPositionVertical: 这个方法还有一个对应方法,这两个方法主要是用来指定DragView的活动范围

clampViewPositionVertical(View child, int top, int dy)

      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);
...
    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);
            ViewCompat.offsetLeftAndRight(mCapturedView, clampedX - oldLeft);
        }
        if (dy != 0) {
            clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
            ViewCompat.offsetTopAndBottom(mCapturedView, clampedY - oldTop);
        }

在ACTION_MOVE的时候,根据移动的距离delta,调用了dragTo的方法,在这里由我们实现的clampViewPositionVertical方法根据一系列参数,返回了一个最后的X,Y坐标,通过ViewCompat的两个方法来实现View的位置变换,从上面的变换可以看出我们需要返回的是View最终能到达的地方。

onViewReleased: 这个就是在手指抬起的时候或者超出边界了会触发,如果想实现一个侧滑菜单,那么在这里可以根据给予的速度的参数来决定是否去打开或者关闭菜单。

除了CallBack之外,还有一个重要的点,那就是ViewDragHelper 怎么与MotionEvent连接起来,我们在创建ViewDragHelper 实例的时候需要传入一个ParentView,这是一个ViewGroup,我们需要drag的view就是这个父控件的子View,所以我们需要在onInterceptTouchEvent的时候采取ViewDragHelper 的shouldInterceptTouchEvent 方法

   return mDragState == STATE_DRAGGING;

这个方法的返回是一个判断语句,判断是否是Drag状态,那么肯定有一个设置状态的地方

                if (toCapture == mCapturedView && mDragState == STATE_SETTLING) {
                    tryCaptureViewForDrag(toCapture, pointerId);
                }

在down和Pointer_down的时候去判断能不能设置这个状态,不过前面就说了,对于边缘检测型,拦不拦无所谓,直接可以绕过tryCaptureView那一关,对于直接Drag的还是需要的,不过事件可能被子View截获了。
除了这个之外,我们还需要实现一个onTouchEvent,ViewDragHelper 也提供了一个对应的方法 processTouchEvent ,这个主要就是用来drag view用的,这里最关键的就是onTouchEvent这个方法的返回值,具体情况具体分析,如果返回true,后续的所有事件就都由这个父控件接送了,那么自然drag行为也就可以触发了。如果不返回true,那么除了down事件外,没有别的事件可以接收了,除非边缘是一个有点击事件的子view。

侧滑实现

分析了那么多,还是模仿一个侧滑的实现,效果十分的简单


b746df2e-d33d-4a11-95c3-eb2e1b2dac76.gif

如果不使用ViewDragHelper,那么这个需要多长的代码不清楚,但是使用ViewDragHelper,这个效果不需要100行。先放代码

public class NavigationView extends LinearLayout {

    private static final String TAG = "NavigationView";
    private static final int RIGHT = 100;
    private static final int MIN_VELOCITY = 300;
    private static float density;
    private ViewDragHelper mDragHelper;
    private View mContent;
    private View mMenu;

    public NavigationView(Context context) {
        this(context, null);
    }

    public NavigationView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public NavigationView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setOrientation(HORIZONTAL);
        mDragHelper = ViewDragHelper.create(this, new CustomCallBack());
        mDragHelper.setEdgeTrackingEnabled(EDGE_LEFT);
        density  = getResources().getDisplayMetrics().density;
    }

    private class CustomCallBack extends ViewDragHelper.Callback {

        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return child == mMenu;
        }

        @Override
        public void onEdgeDragStarted(int edgeFlags, int pointerId) {
            mDragHelper.captureChildView(mMenu,pointerId);
        }

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            int newLeft = Math.max(-child.getWidth(),Math.min(left,0));
            return newLeft;
        }

        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            invalidate();
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            if (xvel > MIN_VELOCITY || releasedChild.getLeft()  >-releasedChild.getWidth() * 0.5) {
                mDragHelper.settleCapturedViewAt(0, releasedChild.getTop());
            }else {
                mDragHelper.settleCapturedViewAt(-releasedChild.getWidth(), releasedChild.getTop());
            }
            invalidate();
        }
    }

    @Override
    public void computeScroll() {
        if (mDragHelper.continueSettling(true)){
            invalidate();
        }
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        int count = getChildCount();
        if(count >= 2){
            //简单写了  直接写死
            mMenu = getChildAt(1);
            mContent = getChildAt(0);
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //如果menu的宽度是match_parent或者超过限制 那么就需要重新设置
        int width = (int) (density * RIGHT);
        if (mMenu.getMeasuredWidth() + width > getWidth()){
            int menuWidthSpec = MeasureSpec.makeMeasureSpec(getWidth() -width,MeasureSpec.EXACTLY);
            mMenu.measure(menuWidthSpec,heightMeasureSpec);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (mMenu != null){
            mMenu.layout(-mMenu.getMeasuredWidth(),t,0,mMenu.getMeasuredHeight());
        }
        if (mContent != null){
            mContent.layout(0,0,mContent.getMeasuredWidth(),mContent.getMeasuredHeight());
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean event = mDragHelper.shouldInterceptTouchEvent(ev);
        return event;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.e(TAG,"onTouchEvent" + event.toString());
        mDragHelper.processTouchEvent(event);
        return true;
    }
}

这里尽量写的简单,但是核心的东西不会少,两个View,一个是侧滑里面的menu,一个是外面的主content。这里直接继承了LinearLayout ,measure时如果宽度过大,也会做一个限制,然后layout到屏幕外面去。

根据前面的方法的分析,这里的逻辑就一目了然了,设置一个左边边缘检测,在 onEdgeDragStarted 上面去drag我们的menu菜单,除此之外,在 onViewReleased 的时候根据速度和当前menu的位置判断后去设置最终滑动的位置,这里是一个Scroller,所有务必实现一个 computeScroll

写的比较的简洁,其中还有很多可以完善的地方,比如添加开闭按钮,判断更准确一点,不过这些都是后续的小细节,这里为的是简单但不失主体。

整个源码在github上: https://github.com/sheepm/ViewDragHelper_Sample

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容

  • ViewDragHelper实例的创建 ViewDragHelper重载了两个create()静态方法public...
    傀儡世界阅读 658评论 0 3
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,815评论 25 707
  • 本篇博客讲解的是自定义View之侧滑面板,应用场景:QQ,知乎,效果图如下 一、内容摘要 了解ViewDragHe...
    JackChen1024阅读 508评论 0 1
  • 坐在班车上,窗外细雨濛濛,灰色笼罩的世界已经看不清它本来的模样,如此刻不知所往的内心。以为越长大,事情就越简单,自...
    小爬吖阅读 233评论 0 0
  • 我叫曹枭,是一个十足的二货。我喜欢骑车,是那种远距离的。我这个人偶尔也会为了装逼而看看书,看的也不是很多——半...
    Cao鸟木阅读 173评论 0 0