35

ViewDragHelper的使用

 4 years ago
source link: http://www.cnblogs.com/shu94/p/12757399.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

3a2A3az.png!webbaMVJfv.png!web

这个例子是仿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的基本使用是没太大问题的。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK