Android适用不同子视图的刷新和加载控件

简述

其实想法很简单,为了能够在绝大部分场景使用一个通用的控件来进行上拉加载和下拉刷新,避免因为Listview切换RecyclerView之类的情况导致需要重新写一套代码。

效果图

先看一下基础的效果,demo做得比较简陋


效果图.gif

思路

1.自定义一个ViewGroup
2.ViewGroup里面应该有三个控件,一个是下拉刷新的时候要显示的视图,一个是内容控件,一个是上拉加载的时候要显示的视图
3.然后按照一定的方式进行视图的摆放:

视图摆放.png

4.那么在竖直滑动的时候,需要处理的就是记录当前的偏移量,只要偏移量达到一定的程度,触发一些对应的回调即可

从xml中加载

目前使用的时候希望做的是在xml中使用,因为如果addView的话不符合设计,在LayoutInflater加载完布局的时候,会进行onFinishInflate回调,所以说在这里进行判断,即可知道xml中设置的子视图情况。

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        int childCount = getChildCount();
        switch (childCount) {
            case 1://这种时候默认只有一个内容视图
                mContentView = getChildAt(0);
                break;
            case 2://默认优先支持顶部刷新
                mContentView = getChildAt(0);
                mHeaderView = getChildAt(1);
                break;
            case 3:
                mContentView = getChildAt(0);
                mHeaderView = getChildAt(1);
                mFooterView = getChildAt(2);
                break;
            default:
                throw new IllegalArgumentException("必须包括1到3个子视图");
        }
        checkHeaderAndFooterAndAddListener();
    }

测量

测量的时候主要考虑margin即可,其它方面正常测量即可

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        measureChildWithMargins(mContentView, widthMeasureSpec, 0, heightMeasureSpec, 0);
        MarginLayoutParams lp = null;
        if (null != mHeaderView) {
            measureChildWithMargins(mHeaderView, widthMeasureSpec, 0, heightMeasureSpec, 0);
            lp = (MarginLayoutParams) mHeaderView.getLayoutParams();
            mHeaderHeight = mHeaderView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
        }
        if (null != mFooterView) {
            measureChildWithMargins(mFooterView, widthMeasureSpec, 0, heightMeasureSpec, 0);
            lp = (MarginLayoutParams) mFooterView.getLayoutParams();
            mFooterHeight = mFooterView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
        }
    }

目前不支持wrap_content的模式,因为正常使用来说基本上可以认为高度都是固定的。
ViewGroup默认的onMeasure只会计算并且设置自身的测量宽高,所以说需要额外测量ViewGroup里面的子视图,而在这里,里面最多就三个子视图。

布局

布局的初始状态如上图,因为布局本身存在滑动的状态,那么也就是说偏移量本身也要作为布局的考量:

偏移量说明.png

图中蓝色和灰色部分重叠的部分其实就是偏移量,这个标示当前ViewGroup从初始位置移动的偏移量。

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int left, top;
        MarginLayoutParams lp;
        lp = (MarginLayoutParams) mContentView.getLayoutParams();
        left = (getPaddingLeft() + lp.leftMargin);
        if (mOption.isContentFixed()) {
            top = (getPaddingTop() + lp.topMargin);
        }else{
            top = (getPaddingTop() + lp.topMargin) + mCurrentOffset;
        }
        mContentView.layout(left, top, left + mContentView.getMeasuredWidth(), top + mContentView.getMeasuredHeight());
        if (null != mHeaderView) {
            lp = (MarginLayoutParams) mHeaderView.getLayoutParams();
            left = (getPaddingLeft() + lp.leftMargin);
            top = (getPaddingTop() + lp.topMargin) - mHeaderHeight + mCurrentOffset;
            mHeaderView.layout(left, top, left + mHeaderView.getMeasuredWidth(), top + mHeaderView.getMeasuredHeight());
        }
        if (null != mFooterView) {
            lp = (MarginLayoutParams) mFooterView.getLayoutParams();
            left = (getPaddingLeft() + lp.leftMargin);
            top = (b - t - getPaddingBottom() + lp.topMargin) + mCurrentOffset;
            mFooterView.layout(left, top, left + mFooterView.getMeasuredWidth(), top + mFooterView.getMeasuredHeight());
        }
    }

其实从一些场景上面考虑,比方说SwipeRefreshLayout这种视图内容不变,有刷新组件进入的情况也是非常常见的,所以说一共支持两种布局方式。
1.布局固定模式,这种模式下偏移量不影响内容视图的布局位置即可,偏移量只会影响头部和底部视图。
2.布局跟随滑动模式,这种模式下偏移量会影响所有子视图的布局。

手势拦截

先看事件的拦截,因为我认为这个布局一般位于顶层,所以我没有默认添加禁止父布局拦截事件,需要的可以自行添加。
默认的情况下我也没有支持fling操作,因为我认为绝大多数情况下滑动的主体应该是内容视图。
对于这个控件来说,重点只是是处理滑动的手势,其实拦截事件的逻辑也非常简单,结合当前视图能否上拉/下拉和当前滑动手势的方向判断即可。

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        if (!isEnabled() || !hasHeaderOrFooter() || isRefreshing || isLoading || isNestedScrolling) {
            return false;
        }
        switch (MotionEventCompat.getActionMasked(event)) {
            case MotionEvent.ACTION_MOVE:
                int x = (int) event.getX();
                int y = (int) event.getY();
                int deltaY = (y - mLastPoint.y);
                int dy = Math.abs(deltaY);
                int dx = Math.abs(x - mLastPoint.x);
                Log.d(getClass().getSimpleName(), "dx-->" + dx + "--dy-->" + dy + "--touchSlop-->" + mTouchSlop);
                if (dy > mTouchSlop && dy >= dx) {
                    canUp = mOption.canUpToDown();
                    canDown = mOption.canDownToUp();
                    Log.d(getClass().getSimpleName(), "canUp-->" + canUp + "--canDown-->" + canDown + "--deltaY-->" + deltaY);
                    canUpIntercept = (deltaY > 0 && canUp);
                    canDownIntercept = (deltaY < 0 && canDown);
                    return canUpIntercept || canDownIntercept;
                }
                return false;
        }
        mLastPoint.set((int) event.getX(), (int) event.getY());
        return false;
    }

手势处理

当确定要拦截手势之后,接着就是处理手势,从功能上面来说,主要就是滑动的时候移动视图和松手的时候尝试触发刷新这两块。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!isEnabled() || !hasHeaderOrFooter() || isRefreshing || isLoading || isNestedScrolling) {
            return false;
        }
        switch (MotionEventCompat.getActionMasked(event)) {
            case MotionEvent.ACTION_MOVE:
                isOnTouch = true;
                updatePos((int) (event.getY() - mLastPoint.y));
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                isOnTouch = false;
                if (mCurrentOffset > 0) {
                    tryPerformRefresh();
                } else if(mCurrentOffset < 0){
                    tryPerformLoading();
                }
                break;
        }
        mLastPoint.set((int) event.getX(), (int) event.getY());
        return true;
    }

可以看到,主要就是在MOVE的时候进行了视图位置变化的处理,接着看逻辑:

private void updatePos(int deltaY) {
        if (!hasHeaderOrFooter() || deltaY == 0) {//不需要偏移
            return;
        }
        if (isOnTouch) {
            if (!canUp && (mCurrentOffset + deltaY > 0)) {//此时偏移量不应该>0
                deltaY = (0 - mCurrentOffset);
            } else if (!canDown && (mCurrentOffset + deltaY < 0)) {//此时偏移量不应该<0
                deltaY = (0 - mCurrentOffset);
            }
        }
        mPrevOffset = mCurrentOffset;
        mCurrentOffset += deltaY;
        mCurrentOffset = Math.max(Math.min(mCurrentOffset, mOption.getMaxDownOffset()), mOption.getMaxUpOffset());
        deltaY = mCurrentOffset - mPrevOffset;
        if (deltaY == 0) {//不需要偏移
            return;
        }
        callUIPositionChangedListener(mPrevOffset, mCurrentOffset);
        if (mCurrentOffset >= mOption.getRefreshOffset()) {
            callCanRefreshListener();
        } else if (mCurrentOffset <= mOption.getLoadMoreOffset()) {
            callCanLoadMoreListener();
        }
        if (!mOption.isContentFixed()) {
            mContentView.offsetTopAndBottom(deltaY);
        }
        if (null != mHeaderView) {
            mHeaderView.offsetTopAndBottom(deltaY);
        }
        if (null != mFooterView) {
            mFooterView.offsetTopAndBottom(deltaY);
        }
        invalidate();
    }

其实主要是做几件事情:
1.确定当前视图最多可以的滑动距离,从而得出实际滑动距离
2.进行一系列的回调,这个后面会说
3.通过View的offsetTopAndBottom来进行竖直方向的移动,如果是内容固定模式,则内容视图不应该移动
这样就可以实现视图的上下滑动

回调

实际上这个控件的核心应该是在回调处理当中,因为无论是怎么滑动,最重要的就是实现滑动过程中的交互和触发刷新之类的回调,这样可以让使用的人实现不同的效果。
首先看回调接口

public interface IRefreshListener {
    void onBeforeRefresh();//当前偏移量没有达到刷新的标准时松手,然后头部开始回弹的回调
    void onRefreshBegin();//开始刷新的回调
    void onUIPositionChanged(int oldOffset, int newOffset, int refreshOffset);//视图滑动过程中的回调
    void onRefreshComplete();//刷新完成的回调
    void onCanRefresh();//当前偏移量已经超过刷新的标准的时候,还在滑动的话会触发的回调
}

这里是顶部刷新的回调,底部加载的回调和这个类似。
使用的时候主要是顶部和底部视图通过实现这个接口,根据不同的状态实现样式上面的变化即可。

刷新处理

因为刷新回调处理和底部其实是类似的,这里只说明刷新
重点看一下顶部刷新回调触发的条件:

    private void tryPerformRefresh() {
        if (isOnTouch || isRefreshing || isNestedScrolling) {//触摸中或者刷新中不进行回调
            return;
        }
        if (mCurrentOffset >= mOption.getRefreshOffset()) {
            startRefreshing();
        } else {//没有达到刷新条件,还原状态
            mScroller.trySmoothScrollToOffset(0);
            if(mCurrentOffset > 0) {
                callBeforeRefreshListener();
            }
        }
    }

其实就是当前偏移量超过刷新触发标准的时候开始刷新处理,然后在里面进行回调
默认的情况下顶部刷新和底部加载同时只能回调一个,并且回弹的时候默认是通过Scroller来进行缓慢滑动

    private void startRefreshing() {
        isRefreshing = true;
        callRefreshBeginListener();
        mScroller.trySmoothScrollToOffset(mOption.getRefreshOffset());
    }

1.标记当前刷新中
2.进行刷新开始回调
3.因为当前偏移量可能大于刷新触发的大小,这里会通过缓慢滑动回到刷新触发的位置
在一般的场景中,这里的回调会进行网络请求,然后在请求完成后要恢复原状,那么这个时候应该手动通知视图

    public void refreshComplete() {
        if (!isRefreshing) {
            return;
        }
        callRefreshCompleteListener();
        postDelayed(new Runnable() {
            @Override
            public void run() {
                if(null != getContext()) {
                    isRefreshing = false;
                    mScroller.trySmoothScrollToOffset(0);
                }
            }
        },mOption.getRefreshCompleteDelayed());
    }

1.进行刷新完成回调
2.根据设置的参数进行延迟完成回调,这里可以用于在刷新完成之后显示1s的刷新完成提示之类的需求
3.最终的处理就是标志当前未处于刷新中,并且通过缓慢移动将视图恢复原样

缓慢滑动

缓慢滑动的意思就是在一定时间间隔内从某一个位置滑动到另一个位置,这样对于用户的体验会好很多。
在Android中一般通过Scroller作为计算器,通过Scroller可以比较方便的进行分段和计算,那么需要做的就是在一些特定的时机里面进行处理。
实际上就是把一段时间的移动分割成为非常多的一小段的移动,至于移动多少,这个在Scroller里面有计算。

    private class ScrollerWorker implements Runnable {
        public static final int DEFAULT_SMOOTH_TIME = 400;//ms
        public static final int AUTO_REFRESH_SMOOTH_TIME = 200;//ms,自动刷新和自动加载时布局弹出时间
        private int mSmoothScrollTime;
        private int mLastY;//上次的Y坐标偏移量
        private Scroller mScroller;//间隔计算执行者
        private Context mContext;//上下文
        private boolean isRunning;//当前是否运行中

        public ScrollerWorker(Context mContext) {
            this.mContext = mContext;
            mScroller = new Scroller(mContext);
            mSmoothScrollTime = DEFAULT_SMOOTH_TIME;
        }

        public void setSmoothScrollTime(int mSmoothScrollTime) {
            this.mSmoothScrollTime = mSmoothScrollTime;
        }

        @Override
        public void run() {
            boolean isFinished = (!mScroller.computeScrollOffset() || mScroller.isFinished());
            if (isFinished) {
                if(mScroller.getCurrY() != mLastY){//Scroller会在一些情况下突然结束,这里就是处理这个情况
                    checkScrollerAndRun();
                }
                end();
            } else {
                checkScrollerAndRun();
            }
        }

        private void checkScrollerAndRun(){
            int y = mScroller.getCurrY();
            int deltaY = (y - mLastY);
            boolean isDown = ((mPrevOffset == mOption.getRefreshOffset()) && deltaY > 0);
            boolean isUp = ((mPrevOffset == mOption.getLoadMoreOffset()) && deltaY < 0);
            if (isDown || isUp) {//不需要进行多余的滑动
                end();
                return;
            }
            updatePos(deltaY);
            mLastY = y;
            post(this);
        }

        /**
         * 尝试缓慢滑动到指定偏移量
         *
         * @param targetOffset 需要滑动到的偏移量
         */
        public void trySmoothScrollToOffset(int targetOffset) {
            if (!hasHeaderOrFooter()) {
                return;
            }
            endScroller();
            removeCallbacks(this);
            mLastY = 0;
            int deltaY = (targetOffset - mCurrentOffset);
            mScroller.startScroll(0, 0, 0, deltaY, mSmoothScrollTime);
            isRunning = true;
            post(this);
        }

        /**
         * 结束Scroller
         */
        private void endScroller() {
            if (!mScroller.isFinished()) {
                mScroller.forceFinished(true);
            }
            mScroller.abortAnimation();
        }

        /**
         * 停止并且还原滑动工作
         */
        public void end() {
            removeCallbacks(this);
            endScroller();
            isRunning = false;
            mLastY = 0;
        }

    }

这里就是通过post不断的进行回调,然后Scroller不断的进行计算,在有效滑动的过程中,通过之前的滑动方法来进行一小段距离的滑动,最后产生的效果就是一段时间的缓慢移动。

嵌套滑动

有的时候可能内容视图是RecyclerView、NestedScrollView这些支持嵌套滑动的视图,那么作为父视图可能也要接受嵌套滑动会好一点。

    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        //只接收竖直方向上面的嵌套滑动
        boolean isVerticalScroll = (nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL);
        boolean canTouchMove = isEnabled() && hasHeaderOrFooter();
        return !disabledNestedScrolling && isVerticalScroll && canTouchMove;
    }

    @Override
    public void onStopNestedScroll(View child) {
        if(disabledNestedScrolling){
            return;
        }
        mParentHelper.onStopNestedScroll(child);
        if (isNestedScrolling) {
            isNestedScrolling = false;
            isOnTouch = false;
            if (mCurrentOffset >= mOption.getRefreshOffset()) {
                startRefreshing();
            } else if(mCurrentOffset <= mOption.getLoadMoreOffset()){
                startLoading();
            } else {//没有达到刷新条件,还原状态
                mScroller.trySmoothScrollToOffset(0);
                if(mCurrentOffset < 0){
                    callBeforeLoadMoreListener();
                }else if(mCurrentOffset > 0){
                    callBeforeRefreshListener();
                }
            }
        }
    }

    @Override
    public void onNestedScrollAccepted(View child, View target, int axes) {
        mParentHelper.onNestedScrollAccepted(child, target, axes);
    }

    @Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        if(disabledNestedScrolling){
            return;
        }
        if (isNestedScrolling) {
            canUp = mOption.canUpToDown();
            canDown = mOption.canDownToUp();
            int minOffset = canDown?mOption.getMaxUpOffset():0;
            int maxOffset = canUp?mOption.getMaxDownOffset():0;
            int nextOffset = (mCurrentOffset - dy);
            int sureOffset = Math.min(Math.max(minOffset,nextOffset),maxOffset);
            int deltaY = sureOffset - mCurrentOffset;
            consumed[1] = (-deltaY);
            updatePos(deltaY);
        }
        dispatchNestedPreScroll(dx, dy, consumed, null);
    }

    @Override
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
        if(disabledNestedScrolling){
            return;
        }
        boolean canTouch = !isLoading && !isRefreshing && !isOnTouch;
        if (dyUnconsumed != 0 && canTouch) {
            canUp = mOption.canUpToDown();
            canDown = mOption.canDownToUp();
            boolean canUpToDown = (canUp && dyUnconsumed < 0);
            boolean canDownToUp = (canDown && dyUnconsumed > 0);
            if(canUpToDown || canDownToUp){
                isOnTouch = true;
                isNestedScrolling = true;
                updatePos(-dyUnconsumed);
                dyConsumed = dyUnconsumed;
                dyUnconsumed = 0;
            }
        }
        dispatchNestedScroll(dxConsumed,dxUnconsumed,dyConsumed,dyUnconsumed,null);
    }

这里自定义了一个标记来作为是否可以进行嵌套滑动(并没有使用系统的)
1.只处理竖直方向的嵌套滑动
2.当前只有在子视图还有没消费的偏移量的前提下,并且当前可以触发刷新的条件下才进行嵌套滑动,比方说RecyclerView滑动到顶部,然后接着滑,此时顶部视图就会出现
3.一旦开始嵌套滑动,后续子视图在滑动之前,滑动偏移量都会被当前视图先使用
4.嵌套滑动结束后,要进行刷新回调等的判断,从而进行一些回调或者缓慢滑动回一些位置的操作,类似手指松开

总结

目前个人已经在项目中使用,主要是两个场景:
1.首页的顶部刷新,首页本身是一个沉浸式交互的页面,内容主题是FrameLayout,但是其实重点是里面的RecyclerView,这个时候使用这个视图就非常方便
2.列表加载,用于在加载完成后直接在头部提示用户当前加载了多少新的数据
可能还有很多细节没有说到以及一些实现上细节的漏洞,有点兴趣的可以去看源码以及源码里面的demo
最后附上源码地址:https://github.com/dda135/PullRefreshLayout

推荐阅读更多精彩内容