SwipeDismissBehavior 的源码分析(view 控件横滑消失)

android 中如果要想让某个view横滑消失,一般情况我们需要取自定义这个view,通过触摸事件来移动它的位置,手指松开时判断位移是否达到了隐藏或删除的条件,这个一般比较麻烦,并且不同类型的控件都需要重新写一遍,如果有个抽取好的公共类或方法,适配所有view,那么我们就省力了。google 推出的 SwipeDismissBehavior 类就能满足这个条件,搭配着
CoordinatorLayout 使用,下面就介绍一下它的用法。


xml 布局
<android.support.design.widget.CoordinatorLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#ff0000"/>
        
    <TextView
        android:id="@+id/tv_swip"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="#ff0000"
        android:gravity="center"
        android:layout_gravity="bottom"
        android:text="Hello World 1"
        android:textColor="#ffffff"
        android:textSize="18sp"
        app:layout_behavior="@string/behavior_swipe" />

</android.support.design.widget.CoordinatorLayout>

<string name="behavior_swipe">com.desigin.view.Behavior.SampleSwipeBehavior</string>

public class SampleSwipeBehavior extends SwipeDismissBehavior {

    public SampleSwipeBehavior() {
    }

    public SampleSwipeBehavior(Context context, AttributeSet attrs) {
        super();
    }

}

如此,id为 tv_swip 的 TextView 便可以随着手指的滑动而横滑消失了。上一章讲了,拥有 Behavior 属性的 view,必须是 CoordinatorLayout 的直接子控件才有效,而通过xml方式使用 Behavior 属性,则定义的 Behavior 的构造方法必须支持两个参数的构造,SwipeDismissBehavior 中只有一个无参构造,所以直接使用会报错,因此我们自定义一个类来继承它,写个两个参数的构造方法,调用 SwipeDismissBehavior 的无参构造方法即可。为什么 SwipeDismissBehavior 可以帮助 view 实现滑动消失呢?

上一章分析了, 触摸事件分发,会通过 CoordinatorLayout 中分发,通过判断,会调用 Behavior 中的onInterceptTouchEvent() 方法,根据它返回的值决定是否调用 Behavior 中 onTouchEvent() 方法,我们看看 SwipeDismissBehavior 类中这两个方法

    @Override
    public boolean onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent event) {
        switch (MotionEventCompat.getActionMasked(event)) {
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                // Reset the ignore flag
                if (mIgnoreEvents) {
                    mIgnoreEvents = false;
                    return false;
                }
                break;
            default:
                mIgnoreEvents = !parent.isPointInChildBounds(child,
                        (int) event.getX(), (int) event.getY());
                break;
        }

        if (mIgnoreEvents) {
            return false;
        }

        ensureViewDragHelper(parent);
        return mViewDragHelper.shouldInterceptTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(CoordinatorLayout parent, V child, MotionEvent event) {
        if (mViewDragHelper != null) {
            mViewDragHelper.processTouchEvent(event);
            return true;
        }
        return false;
    }

onInterceptTouchEvent() 方法中 switch 方法,parent.isPointInChildBounds(child, (int) event.getX(), (int) event.getY()) 的意思是判断手指按下时,触摸点有没有在 child 所在位置的范围内,也就说手指按下时,有没有落在 SwipeDismissBehavior 对应的view上面,在xml中指的是 TextView。如果没落在 TextView 上面,mIgnoreEvents 值是取反,值为true,此时,在下一行if判断中,直接 return false,这是为什么呢?上面提到了 onInterceptTouchEvent() 方法会被从 CoordinatorLayout 中调用,如果没触摸到当前 TextView,它是没必要做多余的操作,所以这里来了个判断拦截。看看下面的方法 ensureViewDragHelper(parent),看看它的代码 

    private void ensureViewDragHelper(ViewGroup parent) {
        if (mViewDragHelper == null) {
            mViewDragHelper = mSensitivitySet
                    ? ViewDragHelper.create(parent, mSensitivity, mDragCallback)
                    : ViewDragHelper.create(parent, mDragCallback);
        }
    }
在它里面,创建了一个 ViewDragHelper 对象,然后就是紧接着的 mViewDragHelper.shouldInterceptTouchEvent(event),看到这,就明白了,原来是用了 ViewDragHelper 辅助类,不清楚它源码的朋友可以看看前面的两章。既然 onInterceptTouchEvent() 中使用了 mViewDragHelper,那么 onTouchEvent() 方法中,果不其然,调用了它的 processTouchEvent() 方法。

看看 ViewDragHelper.Callback 的回调方法,先看看 tryCaptureView() 方法,这个是开头方法
    public boolean tryCaptureView(View child, int pointerId) {
        return mActivePointerId == INVALID_POINTER_ID && canSwipeDismissView(child);
    }

    public boolean canSwipeDismissView(@NonNull View view) {
        return true;
    }
mActivePointerId 默认值本身就是 INVALID_POINTER_ID,canSwipeDismissView() 方法默认返回true,则 tryCaptureView() 方法返回true。如果我们想动态控制view是否可以滑动,可以重写 canSwipeDismissView() 方法,控制它的返回值来控制是否可以横滑消失。

    @Override
    public void onViewCaptured(View capturedChild, int activePointerId) {
        mActivePointerId = activePointerId;
        mOriginalCapturedViewLeft = capturedChild.getLeft();

        final ViewParent parent = capturedChild.getParent();
        if (parent != null) {
            parent.requestDisallowInterceptTouchEvent(true);
        }
    }
这个方法是手指按下触发的,此时 mActivePointerId 值修改,并非是INVALID_POINTER_ID,记录 TextView 距离父容器左边的位置 mOriginalCapturedViewLeft,同时调用父容器的请求触摸事件不拦截方法,这样触摸事件会一直分发到 TextView 的 SwipeDismissBehavior 中。

    @Override
    public void onViewDragStateChanged(int state) {
        if (mListener != null) {
            mListener.onDragStateChanged(state);
        }
    }
    private OnDismissListener mListener;
    public interface OnDismissListener {
        public void onDismiss(View view);
        public void onDragStateChanged(int state);
    }
onViewDragStateChanged() 主要是修改状态值,拖拽-滑动-无 等三个状态,这里有个回调,我们看看它的定义,它定义了两个方法,另外一个是消失时的回调,后面再分析。

    @Override
    public void onViewReleased(View child, float xvel, float yvel) {
        mActivePointerId = INVALID_POINTER_ID;

        final int childWidth = child.getWidth();
        int targetLeft;
        boolean dismiss = false;

        if (shouldDismiss(child, xvel)) {
            targetLeft = child.getLeft() < mOriginalCapturedViewLeft ? mOriginalCapturedViewLeft - childWidth :                   mOriginalCapturedViewLeft + childWidth;
            dismiss = true;
        } else {
            targetLeft = mOriginalCapturedViewLeft;
        }

        if (mViewDragHelper.settleCapturedViewAt(targetLeft, child.getTop())) {
            ViewCompat.postOnAnimation(child, new SettleRunnable(child, dismiss));
        } else if (dismiss && mListener != null) {
            mListener.onDismiss(child);
        }
    }
这个方法是手指松开时调用的,牵涉的有几处逻辑,我们好好分析一下。第一行,恢复 mActivePointerId 的值为 INVALID_POINTER_ID,childWidth 是 TextView 的宽,首先是 shouldDismiss() 方法,看看它的源码
    private boolean shouldDismiss(View child, float xvel) {
        if (xvel != 0f) {
            final boolean isRtl = ViewCompat.getLayoutDirection(child) == ViewCompat.LAYOUT_DIRECTION_RTL;
            if (mSwipeDirection == SWIPE_DIRECTION_ANY) {
                return true;
            } else if (mSwipeDirection == SWIPE_DIRECTION_START_TO_END) {
                return isRtl ? xvel < 0f : xvel > 0f;
            } else if (mSwipeDirection == SWIPE_DIRECTION_END_TO_START) {
                return isRtl ? xvel > 0f : xvel < 0f;
            }
        } else {
            final int distance = child.getLeft() - mOriginalCapturedViewLeft;
            final int thresholdDistance = Math.round(child.getWidth() * mDragDismissThreshold);
            return Math.abs(distance) >= thresholdDistance;
        }
        return false;
    }
看看参数中的 xvel,它的意思是手指松开时x轴上的滑动速率,如果手指按下不动,松开时,值为0;如果是向右滑动时松开,值为正数;如果向左滑动时松开,值为负数。先看看值为0的情况,distance 获取的是此时的滑动距离,thresholdDistance 是允许滑动消失的最小距离,mDragDismissThreshold 值默认是0.5,可以通过 setDragDismissDistance() 来修改这个值的大小,最后就是比较 distance 值是否不小于 thresholdDistance。 看看 xvel != 0f 的情况,默认情况下 isRtl 为 false,如果手机设置了左右颠倒,那么值为true, mSwipeDirection 也是个标识,默认值是 SWIPE_DIRECTION_ANY,表示左右滑动皆可;SWIPE_DIRECTION_END_TO_START 意思是只能向左滑动,SWIPE_DIRECTION_START_TO_END 是只能向右滑动,它是怎么产生出这个效果?先看这里,mSwipeDirection == SWIPE_DIRECTION_START_TO_END 中,如果是向右滑动,xvel 大于0,则返回true,同理SWIPE_DIRECTION_END_TO_START 时向左滑动时返回true,其他情况 shouldDismiss() 返回的值都是 false,它的值有什么用?继续看调用的地方,如果返回值为 true,则 child.getLeft() < mOriginalCapturedViewLeft 判断中,如果成立,说明向左滑动,否则是向右滑动,此时求取出TextView最终左边距离父容器的值,根据初始值mOriginalCapturedViewLeft 和 TextView 本身的宽计算出来;如果 shouldDismiss() 返回值为 false,则 targetLeft 值为初始值,也就是原始起点位置。然后线面的判断,使用了mViewDragHelper 的 settleCapturedViewAt() 方法,这个方法在上一章提过,意思是滑到指定的位置,如果已经到了指定位置,则返回false;假设手指松开时,已经把 TextView 滑到 targetLeft 的位置,并且 mListener 回调不为 null,则触发 onDismiss() 回调;如果 TextView 还没到指定位置,看看 SettleRunnable 这个类,会执行它的 run() 方法,
    @Override
    public void run() {
        if (mViewDragHelper != null && mViewDragHelper.continueSettling(true)) {
            ViewCompat.postOnAnimation(mView, this);
        } else {
            if (mDismiss && mListener != null) {
                mListener.onDismiss(mView);
            }
        }
    }
这次用的是 continueSettling() 方法, continueSettling() 方法前面也分析过,它里面会执行位移,直到到达要位移的地方才会返回false, ViewCompat.postOnAnimation(mView,this) 意思是只要满足条件,就不停重复 run() 方法,从这个方法中就可以看出,手指一旦松开,只要满足了 shouldDismiss() 判断,TextView 就会移动消失,同时触发回调。这里有个场景说一下,比如设置了 mSwipeDirection 为 SWIPE_DIRECTION_START_TO_END,表示只能向右滑动,即使我们滑动它,移动的距离超过TextView 自身宽度的一半,如果我们突然向左滑动一下松手,即使松手时位移仍超过了TextView的一半,但这时候 shouldDismiss() 会返回false,所以 TextView 会滑到原点,而不是消失。

    @Override
    public int clampViewPositionHorizontal(View child, int left, int dx) {
        final boolean isRtl = ViewCompat.getLayoutDirection(child) == ViewCompat.LAYOUT_DIRECTION_RTL;
        int min, max;
        if (mSwipeDirection == SWIPE_DIRECTION_START_TO_END) {
            if (isRtl) {
                min = mOriginalCapturedViewLeft - child.getWidth();
                max = mOriginalCapturedViewLeft;
            } else {
                min = mOriginalCapturedViewLeft;
                max = mOriginalCapturedViewLeft + child.getWidth();
            }
        } else if (mSwipeDirection == SWIPE_DIRECTION_END_TO_START) {
            if (isRtl) {
                min = mOriginalCapturedViewLeft;
                max = mOriginalCapturedViewLeft + child.getWidth();
            } else {
                min = mOriginalCapturedViewLeft - child.getWidth();
                max = mOriginalCapturedViewLeft;
            }
        } else {
            min = mOriginalCapturedViewLeft - child.getWidth();
            max = mOriginalCapturedViewLeft + child.getWidth();
        }
        return clamp(min, left, max);
    }
    private static int clamp(int min, int value, int max) {
        return Math.min(Math.max(min, value), max);
    }
clampViewPositionHorizontal() 是自定义获取横向位移的值,这里面if判断的作用是获取TextView可以位移的两端的值,根据 mSwipeDirection 和 isRtl 组合除了集中情况,然后再用 clamp() 方法返回三个数的中位数。前面说到 mSwipeDirection 决定是否可以向左或向右滑动,原因就在这,这里界限了可以滑动的区域,道理比较简单,这里就不多说了。

    @Override
    public int clampViewPositionVertical(View child, int top, int dy) {
        return child.getTop();
    }
这个是决定竖直方向的位移,这里返回的是固定值,TextView 本身距离的 top,所以高度不变,因此TextView只能横向平移了。

    @Override
    public void onViewPositionChanged(View child, int left, int top, int dx, int dy) {
        final float startAlphaDistance = mOriginalCapturedViewLeft + child.getWidth() * mAlphaStartSwipeDistance;
        final float endAlphaDistance = mOriginalCapturedViewLeft + child.getWidth() * mAlphaEndSwipeDistance;
        if (left <= startAlphaDistance) {
            ViewCompat.setAlpha(child, 1f);
        } else if (left >= endAlphaDistance) {
            ViewCompat.setAlpha(child, 0f);
        } else {
            final float distance = fraction(startAlphaDistance, endAlphaDistance, left);
            ViewCompat.setAlpha(child, clamp(0f, 1f - distance, 1f));
        }
    }
这个方法是位移的监听,前两行代码是定义了两个值,是说开始和结束透明度距离的值,由 mAlphaStartSwipeDistance 和 mAlphaEndSwipeDistance 变量控制,默认是0和0.5,它俩也可以通过方法设置新值,但方法中有限制,会限制在0和1之间;if判断 left <= startAlphaDistance 中,说明是向左移动,此时透明度为1,即不透明;left >= endAlphaDistance 则显透明度为0,百分之百透明,这里 endAlphaDistance 意思就是超过了这个值就是全透明,默认值是 TextView 的一半; else 中,fraction() 方法是计算位移的百分比
    static float fraction(float startValue, float endValue, float value) {
        return (value - startValue) / (endValue - startValue);
    }
然后用1减去百分比的值 distance,再比较它和0以及1之间的中位数,则是TextView的透明度了。


注意,这里只是横滑消失,TextView本身并没有从ViewGroup中移除掉,如果想在消失时把TextView也移除,我们需要写上监听方法,举个例子

    private void initSwipe() {
        TextView tv = (TextView) findViewById(R.id.tv_swip);
        ViewGroup.LayoutParams params = tv.getLayoutParams();
        if(params instanceof CoordinatorLayout.LayoutParams){
            CoordinatorLayout.LayoutParams p= (CoordinatorLayout.LayoutParams) params;
            CoordinatorLayout.Behavior behavior = p.getBehavior();
            if(behavior instanceof SwipeDismissBehavior){
                SwipeDismissBehavior sb= (SwipeDismissBehavior) behavior;
                sb.setListener(new SwipeDismissBehavior.OnDismissListener() {
                    @Override
                    public void onDismiss(View view) {
                        ViewGroup parent = (ViewGroup) view.getParent();
                        if(parent != null){
                            parent.removeView(view);
                        }
                    }

                    @Override
                    public void onDragStateChanged(int state) {

                    }
                });
            }
        }
    }
这样就可以了。

前几章分析 Snackbar 的代码,有个方法 showView() 是展示时调用,

    final void showView() {
        if (mView.getParent() == null) {
            final ViewGroup.LayoutParams lp = mView.getLayoutParams();

            if (lp instanceof CoordinatorLayout.LayoutParams) {

                final Behavior behavior = new Behavior();
                behavior.setStartAlphaSwipeDistance(0.1f);
                behavior.setEndAlphaSwipeDistance(0.6f);
                behavior.setSwipeDirection(SwipeDismissBehavior.SWIPE_DIRECTION_START_TO_END);
                behavior.setListener(new SwipeDismissBehavior.OnDismissListener() {
                    @Override
                    public void onDismiss(View view) {
                        dispatchDismiss(Callback.DISMISS_EVENT_SWIPE);
                    }

                    @Override
                    public void onDragStateChanged(int state) {
                        switch (state) {
                            case SwipeDismissBehavior.STATE_DRAGGING:
                            case SwipeDismissBehavior.STATE_SETTLING:
                                SnackbarManager.getInstance().cancelTimeout(mManagerCallback);
                                break;
                            case SwipeDismissBehavior.STATE_IDLE:
                                SnackbarManager.getInstance().restoreTimeout(mManagerCallback);
                                break;
                        }
                    }
                });
                ((CoordinatorLayout.LayoutParams) lp).setBehavior(behavior);
            }

            mParent.addView(mView);
        }
        ...
    }

    final class Behavior extends SwipeDismissBehavior<SnackbarLayout> {
        @Override
        public boolean onInterceptTouchEvent(CoordinatorLayout parent, SnackbarLayout child,
                MotionEvent event) {
            if (parent.isPointInChildBounds(child, (int) event.getX(), (int) event.getY())) {
                switch (event.getActionMasked()) {
                    case MotionEvent.ACTION_DOWN:
                        SnackbarManager.getInstance().cancelTimeout(mManagerCallback);
                        break;
                    case MotionEvent.ACTION_UP:
                    case MotionEvent.ACTION_CANCEL:
                        SnackbarManager.getInstance().restoreTimeout(mManagerCallback);
                        break;
                }
            }
            return super.onInterceptTouchEvent(parent, child, event);
        }
    }
    
这个方法中,它是要嵌套在 CoordinatorLayout 中,这里给它设置了 Behavior,同时也有监听回调,Behavior 继承 SwipeDismissBehavior,这里重写了 onInterceptTouchEvent() 方法,意思是手指按到 Snackbar 时,调用  cancelTimeout() 方法,取消Snackbar要消失的时间限制,手指抬起时,restoreTimeout() 恢复时间限制,也就是说如果我们的 Snackbar 是显示3秒后消失,只要我们手指按到 Snackbar 上面,3秒后它还会存在,不会消失;手指松开后开始计时,3秒后 Snackbar 消失。 我们重新看 showView() 方法,behavior 设置连个位移透明度的值是 0.1 和 0.6,同时 setSwipeDirection() 设置的值是 SWIPE_DIRECTION_START_TO_END,也就是说只能向右滑动,并且滑动在 Snackbar 本身宽度0.1倍之内,不会有透明
度变化,位移到0.6倍时,变为全透明;再看看它的 setListener() 设置的回调方法,onDismiss() 消失回调,调用 dispatchDismiss(Callback.DISMISS_EVENT_SWIPE),这个方法是把 Snackbar 从它的父容器中删除;onDragStateChanged() 是拖动状态改变监听,拖拽或自身滑动时 cancelTimeout() 取消时间限制,恢复到自然状态时 restoreTimeout() 恢复时间限制


 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值