• ViewDragHelper的使用


      我19年一整年都没写过博客,说实话没写的欲望,现在找到了动机,因为我发现让我愿意研究的东西,很大一部分因为它有意思,没什么兴趣的知识,除非工作需要,真的不愿意碰。今天介绍的是ViewDragHelper这个工具类。它在你自定义viewGroup时,帮你解决子view拖动、定位、状态跟踪。这是官方的解释,当然,我用大白话在复述一下:子view想要自由飞翔,肯定得先经过父view的允许,而父view把做决定的权利全部交给了ViewDragHelper。虽然这个helper类代码很长,有1500多行,但搞清楚了它开放给我们的几个回调,以及一丁点对事件分发的理解,就可以写出一个让你成就感满满的控件。今天的目标:写一个右滑就多出两个子view的控件(大约150行代码就行)。

           

      这个例子是仿github上项目写的,原项目地址https://github.com/daimajia/AndroidSwipeLayout。当然这是精简版的,别人的代码总是望而生畏!ViewDragHelper从拦截到处理事件的整个过程,只公布了一个回调类给我们使用,我们可以从其中选一些对我们有用的去实现,这里,我把我写这个控件实现的类列举出来:

    public abstract boolean tryCaptureView(@NonNull View child, int pointerId);

    1.当你拖动子view,这个方法肯定是要实现的,而且必须返回true,表示你要捕获你拖动的子view,接下来它的位置信息、拖动速度等一系列参数才会被记录,并返回给你。

     

    public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {return 0;}

    2.前面提过,你捕获控件后,helper会返回给你一些数据,这个方法返回给你的就是控件水平位置信息。 重点理解left这个值。写来写去,就这个left值迷惑人!!!请慢慢品味下面这句话:以child父布局的左上角顶点为坐标原点,以右为x轴正方向,下为y轴正方向, left值等于child水平方向划过的像素数(从左往右,像素数为正,反之为负)与它自身的mLeft值的和。撇开数值的结果,通俗的来讲就是你这次移动之后,child的水平位置应该在哪里!为什么是应该呢,因为这个方法走完,紧接着系统会拿该方法返回值作为view新的x坐标(即mLeft值)。那么在系统告诉你view移动后,应该所处的位置与你最终返回的位置之间,你可以做边界判断。例如:子view初始位置mLeft = 0,如果我将view向左滑动20px,那么此方法left就会返回给我-20,而我又不想拿这个值作为子view的新的x坐标。那我返回0,就可以让子view水平还在原位置。以下两个例子是left值的计算方法:

      例子1:子view视图的左顶点就在父布局的坐标原点处,当你手指从左往右滑动10个像素,left就等于像素数+10 加上view距离坐标原点横坐标0,结果就是10;

      例子2:父布局paddingleft 为20像素,如果单位是dp,最终也是转为px计算的。子view的mleft = 20,当你手指从右往左滑动10个像素,left就等于像素数-10+20=10;

      left理解通透之后,dx就好说了,还记得刚提到的可正可负的像素数吗,哈哈,dx就是它!综上,可以得出一个公式:left = view.getLeft()+dx.

     

    3.clampViewPositionVertical和上一个方法就是双胞胎兄弟,这个我就不多介绍了。更多的文字留给更神秘的方法。

    public void onViewPositionChanged(@NonNull View changedView, int left, int top, int dx, int dy) 

    4.这个方法的解释我觉得直接看源代码更好理解:

     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);
            }
    
            if (dx != 0 || dy != 0) {
                final int clampedDx = clampedX - oldLeft;
                final int clampedDy = clampedY - oldTop;
                mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY,
                        clampedDx, clampedDy);
            }
        }

      当view被拖动,会调用这个dragTo,这个方法将以上3个方法的执行顺序以及参数传递,描述的非常清楚,可以看到onViewPositionChanged()参数中的left,top分别是前两个方法返回给我们的,末尾的dx,dy对我们移动view也有用,待会可以看到。

     

    public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {

    5.顾名思义,这个方法就是当我们手指离开view时调用的,xvel ,yvel分别是手指离开view时,view在x,y轴上的速度,这里的速度是指每秒划过的像素数。这个方法执行后,假如我们想让view恢复至初始位置,就可以在这里面调用,根据速度,我们可以做些速度判断,了解用户到底想不想滑动,还是匀速滑动,埋个伏笔,待会写控件时,这个方法里面可以做些文章。

     

    public int getViewHorizontalDragRange(@NonNull View child) {}

    6.这个函数的使用和子view的点击事件关联性很大,同时结合事件分发,才能完整的将子view的点击事件合理的处理,所以这个方法我在第二篇单独讲它的使用,现在你可以不重写它,今天主要目标,让我们的控件滑起来!

     

      这几个回调函数介绍完,看看xml布局,我们继承水平的LinearLayout去实现。

     <com.lq.counter.swipeLayout.MyLinearSwipeLayout
            android:id="@+id/sample1"
            android:layout_width="match_parent"
            android:layout_height="120dp"
            android:orientation="horizontal">
            <LinearLayout
                android:id="@+id/bottom_layout"
                android:background="@mipmap/sceen"
                android:orientation="horizontal"
                android:visibility="visible"
                android:layout_width="match_parent"
                android:layout_height="match_parent">
            </LinearLayout>
    
            <LinearLayout
                android:id="@+id/bottom_wrapper"
                android:background="#66ddff00"
                android:layout_width="160dp"
                android:orientation="horizontal"
                android:layout_height="match_parent">
                <TextView
                    android:id="@+id/tv1"
                    android:layout_width="80dp"
                    android:layout_height="match_parent"
                    android:text="删除"
                    android:background="@mipmap/wind"
                    />
                <TextView
                    android:layout_width="80dp"
                    android:layout_height="match_parent"
                    android:text="收藏"
                    android:background="@mipmap/kaer"
                    />
            </LinearLayout>
        </com.lq.counter.swipeLayout.MyLinearSwipeLayout>

     

      父布局里面有两个子LinearLayout,第一个我们称为surface,它宽度为match_parent,是可见的布局,第二个我们称为bottom,它在屏幕之外,是隐藏的布局(这里是最基本的概念)。

    1.首先在我们SwipeLayout的构造方法中初始化ViewDragHelper:helper = ViewDragHelper.create(this,callback);

    2.准备好5个待会要用的方法:

     @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            return helper.shouldInterceptTouchEvent(ev);
        }
    
        @Override
        public boolean onTouchEvent(MotionEvent event) {
            helper.processTouchEvent(event);
            return true;
        }
    
        private View getSurfaceView(){
            return getChildAt(0);
        }
    
        private View getBottomView(){
            return getChildAt(1);
        }
    
        private enum State{
            CLOSE,
            OPEN
        }

    这里可以看到将控件的拦截和处理全都放权给了viewDragHelper,当然了,当你遇到子view点击事件莫名其妙的失效或者产生时,你就要在拦截处理里面找突破口,不过今天我们不涉及子view的点击事件处理,只是为了完成滑动展示两个隐藏子view就行。 

     3.开始重写Callback

     private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
            private State state;
            @Override
            public boolean tryCaptureView(@NonNull View child, int pointerId) {
                if (getSurfaceView().getLeft() == 0){
                    state = State.CLOSE;
                }else {
                    state = State.OPEN;
                }
                return true;
            }
            
        };

    注意:getSurfaceView.getLeft==0,这么写是基于父布局paddingleft = 0来写的,不过不要紧,这里在捕获子view时,先记录了bottomLayout 是展示还是隐藏的,待会会用到这个状态。

      接着给两个子view水平滑动设置边界:

     @Override
            public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
                if (child == getSurfaceView()) {
                    if (left > 0) {
                        left = 0;
                    }
                    if (left < -getBottomView().getMeasuredWidth()) {
                        left = -getBottomView().getMeasuredWidth();
                    }
                } else {
                    int marleft = getSurfaceView().getMeasuredWidth() - getBottomView().getMeasuredWidth();
                    if (left < marleft){
                        left = marleft;
                    }
                }
                return left;
            }

    surface有两处边界,bittomLayout只有一处边界,理解他们各自的临界状态,可以通过画些草图,看bottomLayout完全展示和完全隐藏这两种极端情况。

      在回过头看看上方的dragTo()方法,你会发现在调用了clampViewPositionHorizontal 之后,子view就会移动到新设置好的位置,但有个问题,既然我拖动的子view移动了,另一个子view却依旧在原地,怎么办,这时

    onViewPositionChanged()就可以解决这个问题,我们让另一个view也跟着移动。
     @Override
            public void onViewPositionChanged(@NonNull View changedView, int left, int top, int dx, int dy) {
                if (changedView == getSurfaceView()){
                    getBottomView().offsetLeftAndRight(dx);
                }else {
                    getSurfaceView().offsetLeftAndRight(dx);
                }
                invalidate();
            }

     

     为什么使用dx,我把dragTo的代码再给大家看一次,并标注了一下,就更清楚了,一个子view移动多少距离,另一个子view也紧跟着在相同方向移动相同的距离,这样整体看起来父布局就整个在滑动:



    如果你写到这里,其实我们的view已经可以滑起来,但你会感觉手感欠佳,比如bottomLayout会展示一部分,一部分还在屏幕外,我想快速滑动一小段距离就把整个bottomLayout给展示出来,而不是滑动一整个
    隐藏view的宽度才能看到它。对对对!这些都是缺点,接下来,今天介绍的最后一个回调onViewRelased 将解决这些问题。
      
    @Override
            public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
                float minV = helper.getMinVelocity()*5;
                float fraction = state == State.CLOSE ? 0.25f:0.75f;
                if (Math.abs(xvel) > minV){
                    if (state == State.CLOSE){
                        if (xvel > 0 ){
                            close();
                        }else {
                            open();
                        }
                    }else {
                        if (xvel >0){
                            close();
                        }else {
                            open();
                        }
                    }
                }else {
                    //匀速
                    if(Math.abs(getSurfaceView().getLeft()) > getBottomView().getMeasuredWidth()*fraction){
                        open();
                    }else {
                        close();
                    }
                }
                invalidate();
            }
     1.这里有最小速度阈值,默认是100px/s,我乘以5是为了降低速度的敏感度。当大于最小速度,我们可以认为用户快速地滑动了一下,那么根据当前状态,可以分两组情况:
        现在是关闭,如果用户快速右滑,xvel>0,那么就是关闭,如果左滑呢,那么就是打开。
        现在是关闭,推理同上。
    2.小于最小速度,那么就是匀速滑动,或慢慢滑动,这时不再以速度作为参考标准,而以surfaceLayout滑动的距离与bottomLayout的占比fraction的大小比较作为用户意图的评判标准。分两种情况:
        现在是关闭,此时fraction = 0.25,我们判断如果surface的x坐标超过了bottomLayout宽度的四分之一,那么就是打开,当然,我们此时使用的surface的x的绝对值,而这个值其实是不会大于0的,
    因为在水平移动时,mLeft已经做了边界处理。
        现在是打开,此时fraction = 0.75;这时surface隐藏在屏幕左边的区域大小恰好就是bottomLayout整个的宽度,当用户左滑时,getSuefaceView的横坐标绝对值没有改变,还是bottomLayout的
    宽度,所以还是打开,当用户右滑时,surface的mleft在bottomLayout的宽度比例1.0至0.75区间内,都可以认为维持现状,即open,一旦到了[0.75,0]区间,那么就认为是关闭。


    接下来看open与close的实现:
       private void close(){
            helper.smoothSlideViewTo(getSurfaceView(), 0, 0);
        }
    
        private void open(){
            helper.smoothSlideViewTo(getSurfaceView(),-getBottomView().getMeasuredWidth(),0);
        }
     
    这两个方法都调用了smoothSlideViewTo,它的作用就是将你的view平滑地滚动到指定的位置。到了这里,不知道你是否留意到,我滚动的view都是surfaceLayout,为什么bottomLayout不去也调这个方法,
    难道还让它待在原地吗,其实,我在open和close后面都加了一行invalidate,它让我们的父布局重新layout一次,你把surfaceLayout移到坐标原点处,那么按照LinearLayout的布局特征,它会把另一个
    子view的布局参数也挪到surfaceLayout后头。而且,这个方法它本身只是设置好了view的终点位置,真正触发滚动,还得用invalidate。它的实现跟scroller是类似的,在第一次重绘时,调用computeScroll,
    在computeScroll里面判断是否已经移到终点,没有的话接着invalidate,invalidate里面又会去重绘。。。这样一直持续下去,直至,computeScroll里面认定view已经到达终点,就不再调invalidate。
      @Override
        public void computeScroll() {
            if (helper.continueSettling(true)){
                ViewCompat.postInvalidateOnAnimation(this);
            }
        }
    
    
    这里我们用continueSetting来判断动画是否应该继续,为什么用它呢,api文档里提示了:


    前面说过每次重绘都会调用computeScroll,而这个方法是空实现,所以我们就在它里面判断是否要继续执行动画,返回值为ture就是继续执行动画,当然了,continueSetting()这个方法为什么传true,
    因为这个方法前头有一段感人肺腑的话:Set this to true if you are calling this method from{@link android.view.View#computeScroll()},让我节约了不少脑细胞。还有一点,如果继续执行动画,
    ViewCompat.postInvalidateOnAnimation(this)换成invalidate也可以。最后是完整代码,真的没有150行!!!
    public class SwipeLayout extends LinearLayout {
        private ViewDragHelper helper;
        public SwipeLayout(Context context) {
            this(context,null);
        }
    
        public SwipeLayout(Context context, @Nullable AttributeSet attrs) {
            this(context, attrs,0);
        }
    
        public SwipeLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            helper = ViewDragHelper.create(this,callback);
        }
    
        private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
            private State state;
            @Override
            public boolean tryCaptureView(@NonNull View child, int pointerId) {
                if (getSurfaceView().getLeft() == 0){
                    state = State.CLOSE;
                }else {
                    state = State.OPEN;
                }
                return true;
            }
    
            @Override
            public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
                if (child == getSurfaceView()) {
                    if (left > 0) {
                        left = 0;
                    }
                    if (left < -getBottomView().getMeasuredWidth()) {
                        left = -getBottomView().getMeasuredWidth();
                    }
                } else {
                    int marleft = getSurfaceView().getMeasuredWidth() - getBottomView().getMeasuredWidth();
                    if (left < marleft){
                        left = marleft;
                    }
                }
                return left;
            }
    
            @Override
            public void onViewPositionChanged(@NonNull View changedView, int left, int top, int dx, int dy) {
                if (changedView == getSurfaceView()){
                    getBottomView().offsetLeftAndRight(dx);
                }else {
                    getSurfaceView().offsetLeftAndRight(dx);
                }
                invalidate();
            }
    
            @Override
            public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
                float minV = helper.getMinVelocity()*5;
                float fraction = state == State.CLOSE ? 0.25f:0.75f;
                if (Math.abs(xvel) > minV){
                    if (state == State.CLOSE){
                        if (xvel > 0 ){
                            close();
                        }else {
                            open();
                        }
                    }else {
                        if (xvel >0){
                            close();
                        }else {
                            open();
                        }
                    }
                }else {
                    //匀速
                    if(Math.abs(getSurfaceView().getLeft()) > getBottomView().getMeasuredWidth()*fraction){
                        open();
                    }else {
                        close();
                    }
                }
                invalidate();
            }
        };
    
        private void close(){
            helper.smoothSlideViewTo(getSurfaceView(), 0, 0);
        }
    
        private void open(){
            helper.smoothSlideViewTo(getSurfaceView(),-getBottomView().getMeasuredWidth(),0);
        }
    
        @Override
        public void computeScroll() {
            if (helper.continueSettling(true)){
                ViewCompat.postInvalidateOnAnimation(this);
            }
        }
    
        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            return helper.shouldInterceptTouchEvent(ev);
        }
    
        @Override
        public boolean onTouchEvent(MotionEvent event) {
            helper.processTouchEvent(event);
            return true;
        }
    
        private View getSurfaceView(){
            return getChildAt(0);
        }
    
        private View getBottomView(){
            return getChildAt(1);
        }
    
        private enum State{
            CLOSE,
            OPEN
        }
    }
      至此,这个控件滑一滑是没问题的,但点一点是没什么反应的,下一篇,我们给这个控件加上点击事件,还是基于这个类。相信,基于这个简单控件的使用,dragViewHelper的基本使用是没太大问题的。

  • 相关阅读:
    VR全景项目外包团队— VR/AR相关领域介绍和VR全景案例
    虚拟现实外包公司— VR开发编辑器意义重大 印证VR不仅服务于用户
    全景VR视频游戏外包公司:技术分享使用U3D+CB制作VR游戏
    承接Unity3D外包公司 — 技术分享
    承接cardboard外包,unity3d外包(北京动软— 谷歌CARDBOARD真强大)
    VR外包团队:长年承接VR虚拟现实外包(应用、游戏、视频、漫游等)
    北京全景视频外包公司:长年承接VR全景视频外包
    北京VR视频外包团队:全景VR视频科普
    全景VR视频外包公司:长年承接VR全景视频外包(技术分享YouTube的360全景视频)
    Unity3D外包团队——技术分享U3D全景漫游(三)
  • 原文地址:https://www.cnblogs.com/shu94/p/12757399.html
Copyright © 2020-2023  润新知