利用NestedScrolling机制改造SwipeRefreshLayout

参考文章:

https://segmentfault.com/a/1190000002873657
http://blog.csdn.net/al4fun/article/details/53888990

一、NestedScrolling机制

吐槽:之前笔者在设计的时候,想在ViewPager的页面上实现仿微信的左滑删除,但是怎么都实现不了,因为其中跟ViewPager的滑动冲突了,当时才疏学浅(现在也是),进了很多坑,比如滑动的拦截、滑动事件在Down之后会跳过判断等,在没有系统学习过这方面知识的情况下以大败告终。
所以,谷歌人性化地推出了这个机制,滑动之前和爸爸商量一下,一切多么融洽,和之前盲人摸象的方式相比人性化多了。

1.滑动流程
子view获取到点击事件后,询问父亲是否需要配合滑动,然后每一次滑动之前都会询问父亲,并记录下父亲消耗的滑动距离,在上面完成后才进行自己自身的滑动。

2.接口

NestedScrollingChild

//开始滑动
public boolean startNestedScroll(int axes);
//停止滑动
public void stopNestedScroll();
//在滑动前,进行滑动事件分配(询问),consumed是父亲消耗的滑动距离,offsetInWindow
//是父亲在窗口中进行的相应的移动,子view需要根据这个进行自身调整(需要的话)
//区别于下面的,在这里可以进行父亲预备处理
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);
//滑动后滑动事件的分配,子view询问父亲是否需要在滑动后消耗事件
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);

//惯性滚动相关
public boolean dispatchNestedPreFling(float velocityX, float velocityY);
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

NestedScrollingParent

//当子view开始滑动时调用,可以在这里选择是否要与子view嵌套滑动,从而返回boolean
//其中target是发起滑动的对象,child是包含target的子view,nestedScrollAxes是方向标志位
//SCROLL_AXIS_HORIZONTAL 或 SCROLL_AXIS_VERTICAL
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);
//老实说我觉得这个方法在有了上面的onStartNestedScroll之后就有点鸡肋了
public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);
//结束滑动时调用
public void onStopNestedScroll(View target);

//在子滑动之前调用
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);
//在子滑动之后调用    
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);

//当惯性嵌套滚动时被调用
public boolean onNestedPreFling(View target, float velocityX, float velocityY);
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);

3.帮助类

NestedScrollingChildHelper
NestedScrollingParentHelper

故名思义,上面的帮助类帮助我们处理了上面父子接口的方法。它们帮助我们实现了逻辑上的方法,在一些情况下我们只希望处理子接口或者父接口,为了对接可以在另一个接口使用帮助类(比如下面的实例,改造SwipeRefreshLayout,我们更希望作为父亲处理子view事件而滑动自身,对于上层组件(父)不是很关心,就可以使用NestedScrollingParentHelper来方便编程。

借用网上的一张图,可以看到两个接口之间的对应关系:

利用NestedScrolling机制改造SwipeRefreshLayout_第1张图片

图片来源:https://segmentfault.com/a/1190000002873657


二、实例演示:改造SwipeRefreshLayout

1.目的:
SwipeRefreshLayout就是一个实现了NestedScrolling机制的控件,可以方便的实现下拉刷新。现在我们想加上上拉刷新功能,可以反着做,给下方加一个可拉动的控件(小圆圈),然后处理它的滑动事件。为了能兼顾上层,我们再外面还加了常规的CoordinatorLayout和AppBarLayout作为测试。

成果:
利用NestedScrolling机制改造SwipeRefreshLayout_第2张图片

2.准备工作
改造之前当然要先把人家之前的成果准备好。
利用NestedScrolling机制改造SwipeRefreshLayout_第3张图片
首先当然是把我们的SwipeRefreshLayout移过来,可以换一个名字避免以后的冲突。然后布局中要用到两个控件,一个是CircleImageView,也就是显示的小圆圈,附带阴影功能;一个是MaterialProgressDrawable,用于在CircleImageView显示进程(颜色滑动)。在移动的时候SwipeRefreshLayout会报错,报错时把东西移动过来就行了。
然后阅读源码,我们暂时只处理NestedScrolling机制,所以一般的移动流程比如onTouchEvent可以先放着,以后为了和其他不带NestedScrolling控件兼容的时候再改进。

3.开始改造工作
(1)参数测量
一个主要的问题就是,我们下面的圆圈(加载圈,以下简称圆圈)要放在哪?原生的直接放在中间然后上去一个圆圈身位的地方,所以下面圆圈水平位置一样,竖直的话就放在屏幕下方。综合测试出这样的距离比较好:

 WindowManager wm = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
        int windowHeight = wm.getDefaultDisplay().getHeight();
               mOriginalOffsetBottom = windowHeight - mCircleDiameter/2;

所以相应的位置放置我们就这样:

mCircleViewBottom.layout((width / 2 - circleBottomWidth / 2), mCurrentTargetOffsetBottom - circleBottomHeight,
                (width / 2 + circleBottomWidth / 2), mCurrentTargetOffsetBottom);

(2)作为父控件配合滑动

  • a.是否配合滑动
 @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        //target 发起滑动的字view,可以不是当前view的直接子view
        //child 包含target的直接子view
        //返回true表示要与target配套滑动,为true则下面的accepted也会被调用
       //mReturningToStart是为了配合onTouchEvent的,这里我们不扩展
        return isEnabled() && !mReturningToStart && !mRefreshing && !mRefreshingBottom  && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;//竖直方向
    }

这里我们只增加了一个上拉刷新标志位

 @Override
    public void onNestedScrollAccepted(View child, View target, int axes) {
        Log.e(LOG_TAG,"onNestedScrollAccepted,axes="+axes);
        // Reset the counter of how much leftover scroll needs to be consumed.
        mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes);
        // Dispatch up to the nested parent
        startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL);//调用自己child接口的方法,实现向上传递
        mTotalUnconsumed = 0;
        //
        mTotalUnconsumedBottom = 0;
        mNestedScrollInProgress = true;

    }

这里也是,只是增加了mTotalUnconsumedBottom ,这是我们上拉刷新的未消费路程。

  • b.滑动之前

我们可以看到源码中的onNestedPreScroll有这么一段处理:

if (dy > 0 && mTotalUnconsumed > 0 ) {//向下拖dy小于0,所以这是为了处理拖circle到一半然后又缩回去的情况
            if (dy > mTotalUnconsumed) {//拖动的很多,大于未消费的
                consumed[1] = dy - (int) mTotalUnconsumed;
                mTotalUnconsumed = 0;
            } else {//拖动一点,我们全部用给自己
                mTotalUnconsumed -= dy;
                consumed[1] = dy;
            }
            moveSpinner(mTotalUnconsumed);//move 到这个位置
        }

在滑动前先判断,我们未消费滑动路程是否还有,有则判断方向,如果是滑动的反方向,也就是我们再下拉刷新一半的时候又往回拉,这时做出处理,选择消费当前滑动路程。所以我们可以写出下方的拖动预处理:

if(dy<0 && mTotalUnconsumedBottom > 0 )
        {
             if(-dy>mTotalUnconsumedBottom)//如果拖动的很多,就先给圆圈,然后还给子控件
            {
                consumed[1]= -(int) mTotalUnconsumedBottom;
                mTotalUnconsumedBottom = 0;
                mBottomIsScrolling = false;
            }else{//否则,全给父控件
                mTotalUnconsumedBottom +=dy;
                consumed[1]=dy;//          
                  }
            moveBottomSpinner(mTotalUnconsumedBottom);
        }

这里有个坑要提醒下,一开始笔者自作聪明,觉得consumed参数应该传绝对值,导致后来往回滑的时候子控件跑得飞快(可以想想为什么),所以这里消费了负的路程就传回负的路程,可以看看源码中NestedScrollingChildHelper的实现。

  • c.正式滑动

这个反而比较容易,只需要加上判断当前子控件还能不能往上滑。

if(dy > 0 && !canChildScrollDown())
        {
            mTotalUnconsumedBottom +=dy;
            moveBottomSpinner(mTotalUnconsumedBottom);
            mBottomIsScrolling = true;
        }

滑动处理:

 private void moveBottomSpinner(float overscrollTop) {
        mProgressBottom.showArrow(true);
        float originalDragPercent = overscrollTop / mTotalDragDistance;
        float dragPercent = Math.min(1f, Math.abs(originalDragPercent));
        float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3;//这个不理解
        float extraOS = Math.abs(overscrollTop) - mTotalDragDistance;//这样不就是负的吗//就是负的//可以是正的
        float slingshotDist = mUsingCustomStart ? mSpinnerOffsetEnd - mOriginalOffsetTop
                : mSpinnerOffsetEnd;///mSpinnerOffsetEnd 默认是拉到最底的可能位置 ,和mTotalDragDistance一开始初始化是相同的
        float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2)
                / slingshotDist);
        float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow(
                (tensionSlingshotPercent / 4), 2)) * 2f;//tensionSlingshotPercent = x ,x/4-(x/4)^2,再*2,最多0.5
        float extraMove = (slingshotDist) * tensionPercent * 2;//这个是模拟后来的拖动,最多slingshotDist

        int targetY = mOriginalOffsetBottom - (int) ((slingshotDist * dragPercent) + extraMove);

大概解释一下,我们的滑动时分段的,在mTotalDragDistance滑动之前是线性的,在这之后会做一个加速的处理,最多延伸一个mSpinnerOffsetEnd

在实际的view位置改变中,我们使用的是

 setTargetOffsetTopAndBottomForBottom(targetY-mCurrentTargetOffsetBottom, true /* requires update */);

这个方法,里面是采用

ViewCompat.offsetTopAndBottom

来改变view的位置的。

  • d.结束滑动

如果手指离开的时候,拖动距离不为零,那么我们要做判断,做出相应的处理

if(mTotalUnconsumedBottom > 0 )
        {
            finishSpinnerBottom(mTotalUnconsumedBottom);
            mTotalUnconsumedBottom = 0;
            mBottomIsScrolling = false;
        }
  private void finishSpinnerBottom(float overscrollTop) {                
  if (overscrollTop > mTotalDragDistance) {
            setRefreshingBottom(true, true);
        } else {
            // cancel refresh
            mRefreshingBottom = false;
            mProgressBottom.setStartEndTrim(0f, 0f);
            AnimationListener listener = null;
            if (!mScale) {
                listener = new AnimationListener() {

                    @Override
                    public void onAnimationStart(Animation animation) {
                    }

                    @Override
                    public void onAnimationEnd(Animation animation) {
                        if (!mScale) {
                           // startScaleDownAnimation(null);
                            startScaleDownAnimationBottom(null);//倒着转
                        }
                    }

                    @Override
                    public void onAnimationRepeat(Animation animation) {
                    }

                };
            }
            animateOffsetToStartPositionBottom(mCurrentTargetOffsetBottom,listener);
            mProgressBottom.showArrow(false);
        }
    }

可以看到主要有两种处理,以mTotalDragDistance为界限,超过这个滑动距离我们就显示刷新,没有的话就动画回到原点。

(3)作为子控件配合滑动
我们知道,AppBarLayout和CoordinatorLayout会配合滑动,子view往上滑的时候会隐藏,如果不做处理,在下端圆圈滑动到一半的时候往回滑会把AppBar又拖出来,消费滑动事件,所以我们选择拦截,在下部圆圈滑动的时候优先处理滑动:

@Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
                //先拦截
        if(mBottomIsScrolling && mTotalUnconsumedBottom>0 &&dy<0)       {
            Log.e("fish","父:dispatchNestedPreScroll,mTotalUnconsumedBottom="+mTotalUnconsumedBottom+",dy=="+dy);
            if(-dy>=mTotalUnconsumedBottom)//向下拖动很大
            {
                moveBottomSpinner(mTotalUnconsumedBottom);
            }else {
                moveBottomSpinner(-dy);
                mTotalUnconsumedBottom -= dy;
                dy = 0;
            }
        }
        return mNestedScrollingChildHelper.dispatchNestedPreScroll(
                dx, dy, consumed, offsetInWindow);
    }

到这里,我们的控件就基本完成了,当然谷歌出版的控件,动画效果是不能少的,它的美观也体现在这里,由于基本是能模仿的,所以这里不再多讲。这个控件的改造主要麻烦在它的动画衔接以及滑动处理机制(加速等),剩下的都很好理解,建议大家动手试一试。

项目地址:https://github.com/SGZoom/DailyWidget/tree/master/widgetpro

笔者只是一名大学生,文章跟代码还有很多不足之处,欢迎大家帮忙指出错误与不足,谢谢~


更新:2017-03-24
感谢评论区huowutong朋友的指正与修改,原来的程序存在bug,即上拉刷新的时候还能下拉刷新,在逻辑上疏漏了这一点,很感激!

你可能感兴趣的:(Android)