5分钟实现简单的PullToRefreshView

之前我们提及过嵌套滑动的概念,不知道大家忘记了没有。当时我们通过自定义Behavior实现了很多新颖的滑动效果。但是如果你选择开源,如果你仅仅是提供一个Behavior出去,那还是很尴尬的,用户需要把CoordinatorLayout什么的都要加到布局文件才能使用。所以今天我将介绍如何摒弃Behavior,直接使用滑动嵌套原理去实现一个下拉刷新、上拉加载更多的视图。
项目在github上,欢迎大家star、fork

效果

先看看效果图


5分钟实现简单的PullToRefreshView_第1张图片
下拉刷新、上拉加载更多

回顾过去

之前我们已经对滑动嵌套的源码进行过分析,这里再啰嗦一下吧,毕竟这个是最关键的部分了。用NestedScrollView为例,RecyclerView等都雷同。

  1. 交互流程
    之前我们说过了,嵌套滑动关键在于NestedScrollingChild与NestedScrollingParent接口的实现。NestedScrollView 实现了NestedScrollingChild接口,而CoordinatorLayout实现了NestedScrollingParent接口。
    既然是涉及到滑动,那么万变不离其宗,肯定从onTouchEvent里触发。以NestedPreScroll方法为例,首先在NestedScrollView的Action_Move中触发
dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)

它实际是调用NestedScrollingChildHelper的dispatchNestedPreScroll方法,继续看ViewParentCompat怎么处理

ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

我们出现了mNestedScrollingParent。不同版本的API实现不同的ViewParentCompat接口,这里我们选择android5.0的ViewParentCompatLollipopImpl。ViewParentCompatLollipopImpl继承ViewParentCompatStubImpl,这里使用我们就可以很清楚的看到CoordinatorLayout的触发时机了

@Override
public void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
        int[] consumed) {
    if (parent instanceof NestedScrollingParent) {
        ((NestedScrollingParent) parent).onNestedPreScroll(target, dx, dy, consumed);
    }
}
  1. onNestedPreScroll与onNestedScroll的区别
    这里我们仔细看下源码,先看看dispatchNestedPreScroll,只有NestedScrollingParent消费了,才能返回true,并且consumed数组的值会被带进来带出去
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
    if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
        if (dx != 0 || dy != 0) { 
            .........................
            ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
            .........................
            return consumed[0] != 0 || consumed[1] != 0;
         }
         .........................
    }
    return false;
}

如果dispatchNestedPreScroll为true,那么deltaY的值会因为mScrollConsumed被消费的具体情况而做修改。当最终的偏移量大于mTouchSlop,则mIsBeingDragged才会被赋值为true,才会执行dispatchNestedScroll方法

if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
    deltaY -= mScrollConsumed[1];
    vtev.offsetLocation(0, mScrollOffset[1]);
    mNestedYOffset += mScrollOffset[1];
}
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
    final ViewParent parent = getParent();
    if (parent != null) {
        parent.requestDisallowInterceptTouchEvent(true);
    }
    mIsBeingDragged = true;
    if (deltaY > 0) {
        deltaY -= mTouchSlop;
    } else {
        deltaY += mTouchSlop;
    }
}

在onNestedScroll中还有很关键的一组变量,那就是unconsumedY和unconsumedX。由于我们仅考虑垂直方向,所以关注点放在unconsumedY上

if (mIsBeingDragged) {
        .............................
        if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0,
            0, true) && !hasNestedScrollingParent()) {
        }
        final int scrolledDeltaY = getScrollY() - oldY;
        final int unconsumedY = deltaY - scrolledDeltaY;
        if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
        }
}

在overScrollByCompat中有onOverScrolled方法,他直接调用super.scorllTo方法,负责将deltaY与getScrollY()计算得到新的newScrollY,也就是当前NestedScrollView自身滚动的距离。同样我只关注垂直方向

boolean overScrollByCompat(int deltaX, int deltaY,
        int scrollX, int scrollY,
        int scrollRangeX, int scrollRangeY,
        int maxOverScrollX, int maxOverScrollY,
        boolean isTouchEvent) {
    ............................
    int newScrollY = scrollY + deltaY;
    if (!overScrollVertical) {
        maxOverScrollY = 0;
    }
    // Clamp values if at the limits and record
    final int top = -maxOverScrollY;
    final int bottom = maxOverScrollY + scrollRangeY;
    boolean clampedY = false;
    if (newScrollY > bottom) {
        newScrollY = bottom;
        clampedY = true;
    } else if (newScrollY < top) {
        newScrollY = top;
        clampedY = true;
    }
    if (clampedY) {
        mScroller.springBack(newScrollX, newScrollY, 0, 0, 0, getScrollRange());
    }
    onOverScrolled(newScrollX, newScrollY, clampedX, clampedY);
    return clampedX || clampedY;
}

所以经过overScrollByCompat方法之后,NestedScrollView就已经发生了滚动。这样unconsumedY的值就明朗了,是新旧两个getScrollY()的差值,也就是NestedScrollView自身消耗值的剩余部分。所以默认条件下,如果NestedScrollView仅仅是自身滑动,deltaY与scrolledDeltaY两个值是相同的,就是unconsumedY为0,滚多少就消耗多少;如果不是自身滚动的话,就会产生unconsumedY不为0的情况,出现未消耗部分,留给NestedScroll方法自行处理。这样就得到之前我们所说的结论了

当dyConsumed>0 && dyUnconsumed==0,代表target自身向上滚动
当dyConsumed==0 && dyUnconsumed>0,代表target自身向上滚动已经结束了,交由Behavior去处理后续向上滚动事件

展望未来

经过刚才的回顾,你应该彻底明白了这个滑动嵌套的流程,现在我们来开始动手了。

  1. 下拉刷新
    之前我们说过,Behavior是由CoordinatorLayout去触发的。如果我们要绕过CoordinatorLayout,就需要实现NestedScrollingParent接口。先看看我们的布局吧


    
        
    
    
        .................................
    
    
        
    

这个没啥好说的,开始撸吧。排排坐吃果果,把头尾先隐藏起来。我直接写死宽高是为了方便演示,实际使用过程中需要自行计算

public class PullToRefreshView extends LinearLayout implements NestedScrollingParent {
    Context context;
    public PullToRefreshView(Context context) {
        this(context, null);
    }
    public PullToRefreshView(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.context=context;
    }
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        //初始化head、foot、本体
        getChildAt(0).layout(0, dp2Px(context, -200), getMeasuredWidth(), 0);
        getChildAt(1).layout(0, 0, getMeasuredWidth(), getMeasuredHeight());
        getChildAt(2).layout(0, getMeasuredHeight(), getMeasuredWidth(), getMeasuredHeight()+dp2Px(context, 200));
    }
}

三个好基友登场

@Overridepublic boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
    return super.onStartNestedScroll(child, target, nestedScrollAxes);
}
@Overridepublic void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
    super.onNestedPreScroll(target, dx, dy, consumed);
}
@Overridepublic void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
    super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
}

我们仅考虑垂直方向

@Overridepublic boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
    return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL)!=0;
}

onNestedPreScroll与onNestedScroll自然要成对的说。
1)不产生偏移
我们的需求肯定是NestedScrollView不到底部或者顶部是不会把Head或者Footer显示出来,所以这时候我们希望dyUnconsumed为0,也就是NestedScrollView完全自主滑动,那么此时就不需要onNestedPreScroll做什么事情,同样onNestedPreScroll也不要处理什么。
2)产生偏移
一般情况下NestedScrollView也不会自己平白无故的移动起来,只有它到达边界之后才会在dyUnconsumed体现出来,这个我们刚才也说过了。所以这里如果dyUnconsumed小于0,那肯定是你向下滑动NestedScrollView产生的。我们知道onNestedPreScroll与onNestedScroll之间的关系,所以第一次触发的位移的那个点肯定是onNestedScroll触发的,因为NestedScrollView的getTop最初值肯定是0。后续真的发生移动之后,肯定操作要交给onNestedPreScroll的,因为他才是决定dyUnconsumed值的关键。试想一下如果你不处理onNestedPreScroll,那么在上拉的过程中事件永远被NestedScrollView吞掉了,head就回不了头了。onNestedPreScroll将事件全部消费,不给NestedScrollView留有消费的余地,这样NestedScrollView就不会自己滚动了,达到了位移的目的

@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
    //下拉刷新
    if (getChildAt(1).getTop()>0) {
        int max=dp2Px(context, 200);
        int min=0;
        consumed[1]=scrollDown(this, dy, min, max);
    }
}
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
    //下拉刷新
    if (dyUnconsumed<0) {
        //滑动范围
        int max=dp2Px(context, 200);
        int min=0;
        scrollDown(this, dyUnconsumed, min, max);
    }
}

真正滚动的方法在scrollDown中,可以自行体会一下滑动的范围,我也稍微加了一点呢阻尼

private int scrollDown(LinearLayout childView, int y, int min, int max) {
    canScrollExtra=true;
    if (childView.getChildAt(1).getTop()-y>max) {
        childView.getChildAt(0).offsetTopAndBottom(max-childView.getChildAt(1).getTop());
        childView.getChildAt(1).offsetTopAndBottom(max-childView.getChildAt(1).getTop());
    }
    else if (childView.getChildAt(1).getTop()-y

当滑动事件结束执行Action_Up时候,执行onStopNestedScroll回调。由于onStopNestedScroll在手势开始时候就会被执行一次,我也查不出原因,就只能使用canScrollExtra控制一下。以head的总高度的一半作为临界点,用来区分到底是刷新还是回弹,分别做不同的动画效果。如果是刷新状态,则isIntercept作为标志位给InterceptTouchEvent作为参考。滑动结束之后,对现有的视图进行复原

@Override
public void onStopNestedScroll(View child) {
    if (canScrollExtra) {
        ValueAnimator valueAnimator=null;
        if (getChildAt(1).getTop()>0) {
            if (Math.abs(getChildAt(1).getTop())==0) {
                return;
            }
            //下拉未到临界点
            if (getChildAt(1).getTop()0) {
                valueAnimator=ValueAnimator.ofInt(0, -dp2Px(context, 200)-getChildAt(0).getTop());
            }
            //下拉已过临界点,模拟刷新过程
            else if (getChildAt(1).getTop()>dp2Px(context, 200)/2) {
                valueAnimator=ValueAnimator.ofInt(0, -dp2Px(context, 200)/2-getChildAt(0).getTop());
                isIntercept=true;
            }
        }
        if (valueAnimator==null) {
            return;
        }
        valueAnimator.setDuration(400);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                if (getChildAt(1).getTop()>0) {
                    getChildAt(0).offsetTopAndBottom((Integer.parseInt(animation.getAnimatedValue().toString())-deltaY));
                    getChildAt(1).offsetTopAndBottom((Integer.parseInt(animation.getAnimatedValue().toString())-deltaY));
                    deltaY=Integer.parseInt(animation.getAnimatedValue().toString());
                }
            }
        });
        valueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                deltaY=0;
                if (isIntercept) {
                    changeText();
                    new Handler().postDelayed(new Runnable() {
                        @Override
                        public void run() {
                            if (getChildAt(1).getTop()>0) {
                                resetRefreshDown();
                            }
                        }
                    }, 2000);
                }
            }
        });
        valueAnimator.start();
    }
    canScrollExtra=false;
}

这里还有一个关键点,需要重写requestLayout。无论你在使用setImageReource还是setText的时候,都会根据情况触发requestLayout方法,而且是getParent()一层层的去触发。那么这里就尴尬了,他会重新执行onLayout,这样就造成视图先复原,然后属性动画搞的错乱

@Override
public void requestLayout() {
}
  1. 上拉加载更多
    这个其实没啥好说的,就是将下拉刷新反过来处理即可。

后记

这个项目仅仅作为滑动嵌套使用发散的demo,并不适合项目中直接使用,由于能力不足还有很多问题我暂时无法解决。希望有大神能够在此想法上有更多的创新,创建出更好的刷新加载库

你可能感兴趣的:(5分钟实现简单的PullToRefreshView)