一个简单的下拉刷新控件(SimpleRefreshLayout)

因为项目需要,所以要有符合UI的下拉刷新控件(你懂的)。但是实在不想用第三方的,为什么呢,因为我感觉太大了,那些是货真价实的一个库,吓到宝宝了,我只是想要一个下拉刷新而已。
所以,我决定自己写一个。系统的SwipeRefreshLayout写的很好,兼容性也好(废话)。所以就想着能不能改一下,没有什么是继承解决不了的,如果有,就复制出来然后直接改。所以我就复制出来直接改了。
以上就是前因后果。不多说,我们先上图。

CircelMaterialModel-Demo.gif

SimplePullModel-Demo.gif

好了,如果你看到这里,说明你对我这个东西还是感兴趣的,在此,表示感谢。
先放项目地址:码云 Github
以下正文


这是一个基于SwipeRefreshLayout的,简单的下拉刷新容器。
之所以命名为简单,原因是

  • Layout结构非常简单,主体仅仅只有1个类,不到1000行(包括注释)。
  • 使用及自定义非常简单,只需要实现相应的方法,即可做到绝大多数效果。

结构说明:

  1. 本项目基于SwipeRefreshLayout改写,将SwipeRefreshLayout的代码抽象为滑动处理部分(主体)和刷新头部分(Head)。
  2. 将主体的状态管理全部交由Head控制,主体仅仅将捕获的手势结果传递到Head,然后得到相应的状态结果。
  3. Head负责刷新头的样式,内容体的位置(是否跟随下拉),是否可以刷新等控制。
  4. Head覆盖于内容体上层,允许覆盖整个SimpleRefreshLayout,因此可以做到很多的刷新效果,比如省略部分UI设计中的刷新提示Dialog。
  5. 本项目并未做上拉加载更多实现,但是提供相应的解决方案,并且加入兼容。

引入Android Studio方法:

compile 'liang.lollipop.simplerefreshlayout:SimpleRefreshLayout:1.0.2'

使用方法:

simpleRefreshLayout
    .setRefreshView(BaseRefreshView view) //设置刷新头
    .xxxx //设置刷新头的相关属性

以下为使用默认的简单刷新头实现:

simpleRefreshLayout
    .setRefreshView(new SimplePullModel(this))//设置刷新头为简易刷新模式
    .setPullDownHint("下拉进行刷新")//设置下拉时的提示语
    .setPullReleaseHint("松手开始刷新")//设置提示释放的提示语
    .setRefreshingHint("正在进行刷新")//设置正在刷新的提示语
    .setHintColor(Color.GRAY)//设置刷新提示的文字颜色
    .setProgressColors(Color.GRAY,Color.TRANSPARENT);//设置刷新进度条的颜色(数组)

注意,除了setRefreshView(BaseRefreshView view) 方法,其他方法均由刷新头提供,因此,更换刷新头,将会有不同的参数设置项以及刷新效果。

代码介绍:

setRefreshView(BaseRefreshView view) 这是SimpleRefreshLayout对外最主要的方法,此方法用于初始化SimpleRefreshLayout,同时做到了更换刷新样式而不需要修改布局文件以及其他代码的目的。此方法请在界面测量前调用并完成初始化。建议封装为公用的方法,当业务需求变化时,可以一处修改,所有界面的刷新样式全部改变。

    public <T extends BaseRefreshView> T setRefreshView(T view) {
       //如果已经存在刷新头
       if(mBaseRefreshView!=null){
           //那么去掉历史控件的刷新接口引用
           mBaseRefreshView.refreshListener = null;
           //去除历史控件的Body控制引用
           mBaseRefreshView.targetViewScroll = null;
           //移除刷新控件
           removeView(mBaseRefreshView);
       }
       //保存新控件引用
       mBaseRefreshView = view;
       //关联刷新接口引用
       mBaseRefreshView.refreshListener = mListener;
       //关联Body控制引用
       mBaseRefreshView.targetViewScroll = this;
       //添加到Layout中
       addView(mBaseRefreshView);
       //返回控件,以方便参数设置
       return view;
   }

自定义刷新头:

  1. 继承SimpleRefreshLayout.BaseRefreshView
  2. 实现抽象方法,必要时可以重写相关方法
        /**
        * 下拉刷新的控制方法,实际显示的View需要实现此方法,
        * 用于实现对用户操作的反馈,建议将变化过程细化,增加跟随手指的感觉
        * 并且给予足够明显并且有明显暗示性的显示,告知用户当前状态
        * @param pullPixels 下拉的实际距离
        * @param totalDragDistance 设定的触发刷新距离
        * @param originalDragPercent 下拉距离与目标距离间的百分比
        */
       protected abstract void onPullToRefresh(float pullPixels,float totalDragDistance,float originalDragPercent);

       /**
        * 此方法将在结束下拉之后触发,实现或者重写次方法,
        * 将可以在松手后将View复位或者进行其他相关设置
        * @param pullPixels 下拉的实际距离
        * @param totalDragDistance 设定的触发刷新距离
        * @param originalDragPercent 下拉距离与目标距离间的百分比
        */
       protected abstract void onFinishPull(float pullPixels,float totalDragDistance,float originalDragPercent);

以上为Git项目上readme.MD的内容。
以下为简书内容。

先说项目基础:

SwipeRefreshLayout
这个类有1200多行,但是别怕,很简单,听我慢慢忽悠介绍。

public class SwipeRefreshLayout extends ViewGroup implements NestedScrollingParent , NestedScrollingChild {
  ...
}

以上为SwipeRefreshLayout的开始,我们可以看到,它继承自ViewGroup,也就是说,他是容器,而且是最基础的,换句话说,就是什么都没有。

这不是重点,重点是后面的两个接口:NestedScrollingParent,NestedScrollingChild 。

这两个是什么呢?这个就要说到android.support.design.widget.CoordinatorLayout了。这个我叫他协调布局,他的作用也比较干脆,就是协调,相信很多大佬已经玩腻了,不过为了下面的内容,所以简单说一下,他就是 通过像Touch事件一样的传递方式(或者说冒泡事件),将一个滑动操作从Touch捕获的View那里一层层的抛出来,然后一层层的View来决定自己当前适合不适合滑动。 细节不读多说,如果有兴趣,可以搜索一下。

NestedScrollingParent , NestedScrollingChild 就是这个嵌套滑动事件的接口,从名字可以看出来,一个是嵌套滑动父类,一个是嵌套滑动孩子。这就代表了两个身份,前者代表你是支持嵌套滑动的容器,后者代表你是支持嵌套滑动的子View。

那么SwipeRefreshLayout实现这两个接口目的就很明显了,这也是他可以在多层嵌套滑动中从容处理业务的原因。
且说一下这两个接口的内容:

public interface NestedScrollingParent {
  public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);
  public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);
  public void onStopNestedScroll(View target);
  public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);
  public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);
  public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);
  public boolean onNestedPreFling(View target, float velocityX, float velocityY);
  public int getNestedScrollAxes();
}

public interface NestedScrollingChild {
  public void setNestedScrollingEnabled(boolean enabled);
  public boolean isNestedScrollingEnabled();
  public boolean startNestedScroll(int axes);
  public void stopNestedScroll();
  public boolean hasNestedScrollingParent();
  public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);
  public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);
  public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
  public boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

有没有吓到?一大片的内容?这么多接口!哈哈!要的就是你那种表情。
先别慌,你稍微看一下名字,来来回回不过是开始滑动,正在滑动,滑动后,停止滑动,被抛起来。
所以没啥,只是状态定的比较多,其间不过是来来回回的层层调用,说多了都烦。
不过这个也是点一下就好了,凑字数的。重点是SwipeRefreshLayout怎么实现的。

    @Override
   public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
       return isEnabled() && !mReturningToStart && !mRefreshing
               && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
   }

这是说,状态要在可用状态,不是什么其他状态,这是正常的。
那么还有这种代码:

   @Override
   public int getNestedScrollAxes() {
       return mNestedScrollingParentHelper.getNestedScrollAxes();
   }

现在有没有想掀桌子,这又是什么?
其实这就是嵌套滑动的默认实现帮助类,构造器那里有个实例化操作。


public SwipeRefreshLayout(Context context, AttributeSet attrs) {
     super(context, attrs);
     ...
     mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
     mNestedScrollingChildHelper = new NestedScrollingChildHelper(this);
     ...
}    

这里我们讲讲重写的那几个方法:

    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        return isEnabled() && !mReturningToStart && !mRefreshing
                && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

这个方法属于 NestedScrollingParent 的,代码说的就是,如果我是启用状态,并且没有在收回顶部的过程中,不在刷新状态,滑动方向为纵向,这些都满足了,那么我就要消耗滑动事件,否则这次滑动我不处理。

    @Override
    public void onNestedScrollAccepted(View child, View target, int 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);
        mTotalUnconsumed = 0;
        mNestedScrollInProgress = true;
    }

这个方法属于 NestedScrollingParent 的,是滑动开始时被调用。 也很简单,
1 就是用默认的Helper处理滑动事件。
2 自己去主动调用开始嵌套滑动的方法(身为滑动孩子身份,内部实现是Helper处理)。
3 重置未处理滑动距离(其实就是下拉头的下拉距离,这里不细说)。
4 设置嵌套滑动状态为true。

    @Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        // If we are in the middle of consuming, a scroll, then we want to move the spinner back up
        // before allowing the list to scroll
        if (dy > 0 && mTotalUnconsumed > 0) {
            if (dy > mTotalUnconsumed) {
                consumed[1] = dy - (int) mTotalUnconsumed;
                mTotalUnconsumed = 0;
            } else {
                mTotalUnconsumed -= dy;
                consumed[1] = dy;
            }
            moveSpinner(mTotalUnconsumed);
        }

        // If a client layout is using a custom start position for the circle
        // view, they mean to hide it again before scrolling the child view
        // If we get back to mTotalUnconsumed == 0 and there is more to go, hide
        // the circle so it isn't exposed if its blocking content is moved
        if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0
                && Math.abs(dy - consumed[1]) > 0) {
            mCircleView.setVisibility(View.GONE);
        }

        // Now let our nested parent consume the leftovers
        final int[] parentConsumed = mParentScrollConsumed;
        if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) {
            consumed[0] += parentConsumed[0];
            consumed[1] += parentConsumed[1];
        }
    }

这个方法属于 NestedScrollingParent 的,从名字上看是嵌套滑动前,其实应该说是子View滑动操作开始前,因为此时已经有了滑动事件,但是子View未开始处理。
这里需要稍微说明一下,为什么要重写这个方法?因为这个方法会在子View滑动前执行,那么可以根据情况,消耗部分。下面我用我的语言解释一下,代码片段中也有原版的解释。
1 如果子View滑动距离>0,并且未消耗距离也是>0。(前面说过未消耗距离其实就是头部移动距离)。那么两者抵消一下,并且各自重置。然后头部位置调整一下,滑动拦截结果放到返回值里。
2 如果头部的默认位置被重置过(通过方法设置头部的偏移量了),并且需要移动到顶部,那么就直接隐藏头部。
3 最后将父类的滑动结果累加到返回值上面。
那么这个过程就结束了,总结起来,其实就是在滑动开始前,将我这边的状态和他那边合并一下。

    @Override
    public void onStopNestedScroll(View target) {
        mNestedScrollingParentHelper.onStopNestedScroll(target);
        mNestedScrollInProgress = false;
        // Finish the spinner for nested scrolling if we ever consumed any
        // unconsumed nested scroll
        if (mTotalUnconsumed > 0) {
            finishSpinner(mTotalUnconsumed);
            mTotalUnconsumed = 0;
        }
        // Dispatch up our nested parent
        stopNestedScroll();
    }

这个方法属于 NestedScrollingParent 的,是滑动结束时调用。做的很简单:
1 调用Helper的方法先处理一遍。
2 重置嵌套滑动状态。
3 如果刷新头不是收起的,那么调用一下结束滑动方法(finishSpinner(float y) 方法在手指事件中,手指抬起时也会调用。细节此处跳过,因为不属于滑动处理部分。)。然后再置空滑动距离。
4 最后,调用身为子View身份的停止滑动方法。

    @Override
    public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed,
            final int dxUnconsumed, final int dyUnconsumed) {
        // Dispatch up to the nested parent first
        dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                mParentOffsetInWindow);

        // This is a bit of a hack. Nested scrolling works from the bottom up, and as we are
        // sometimes between two nested scrolling views, we need a way to be able to know when any
        // nested scrolling parent has stopped handling events. We do that by using the
        // 'offset in window 'functionality to see if we have been moved from the event.
        // This is a decent indication of whether we should take over the event stream or not.
        final int dy = dyUnconsumed + mParentOffsetInWindow[1];
        if (dy < 0 && !canChildScrollUp()) {
            mTotalUnconsumed += Math.abs(dy);
            moveSpinner(mTotalUnconsumed);
        }
    }

这个方法属于 NestedScrollingParent 的最后一个需要说明的方法。就是正儿八经处理滑动的方法。
1 告诉父类,我要滑动了,要拦着我的就来啊!
2 然后,就是累加滑动距离,然后移动刷新头了。

以上,就是作为一个父亲(偷笑),所做的全部工作(部分交给Helper的方法未提及)。

接下来是身为人子的责任了!
......
好了,以上为 NestedScrollingChild 的全部处理。哈哈,是不是以为我偷懒没写?不是,是所有方法都交给Helper了,简单的说就是,我什么都不做,使用默认处理,“全听父亲大人安排”(有没有很像古代女孩子被许配时候的话?)。


以上是关于嵌套滑动处理的全部代码。

所以啊,前面那些接口方法都是个玩笑,直接复制粘贴就好了,如果有兴趣,可以了解一下。如果没有,那就算了。
这些接口中,只有几个方法,判断了一下状态,其他的全部是调用Helper的方法,那么我们算一下:
1239-150 = 1089
再扣除一点注释,也就只剩下900行左右。

好了,跳过其他代码,我们先说手指处理。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    ensureTarget();

    final int action = MotionEventCompat.getActionMasked(ev);
    int pointerIndex;
    if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
        mReturningToStart = false;
    }
    if (!isEnabled() || mReturningToStart || canChildScrollUp() || mRefreshing || mNestedScrollInProgress) {
         // Fail fast if we're not in a state where a swipe is possible
        return false;
    }
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true);
            mActivePointerId = ev.getPointerId(0);
            mIsBeingDragged = false;
            pointerIndex = ev.findPointerIndex(mActivePointerId);
            if (pointerIndex < 0) {
               return false;
            }
            mInitialDownY = ev.getY(pointerIndex);
        break;
        case MotionEvent.ACTION_MOVE:
            if (mActivePointerId == INVALID_POINTER) {
                 Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
                 return false;
            }
            pointerIndex = ev.findPointerIndex(mActivePointerId);
            if (pointerIndex < 0) {
                 return false;
            }
            final float y = ev.getY(pointerIndex);
            startDragging(y);
        break;
        case MotionEventCompat.ACTION_POINTER_UP:
            onSecondaryPointerUp(ev);
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            mIsBeingDragged = false;
            mActivePointerId = INVALID_POINTER;
        break;
    }
    return mIsBeingDragged;
}

好了,凑字数贴代码完了,这是源码,除了空行,其他的一行没有删除。也不多,就算多也没啥,我来解释一下。


首先 ensureTarget(); 这个是获取内容体,我们先跳过。
接下来是获取当前手指事件,得到action。
然后修改mReturningToStart 状态,当正在返回头部状态,并且当前是按下,那么就变成false。
再接下来,判断当前是否可以用,是否在刷新等等,如果是就返回。
接下来是重头戏,一个switch:

  • 按下的时候:

将刷新头移动到起点位置(屏幕外)。
获取并保存当前手指触点id。
拖拽状态设置为false。
获取当前触点ID的序号,如果小于0,就返回false,放弃本次事件。(不理解什么情况会被触发)
最后记录按下位置。

  • 移动的时候:

判断手指触点ID是否为空,如果是那么就放弃本次事件。
获取当前触点ID的序号,如果小于0,就返回false,放弃本次事件。(不理解什么情况会被触发)
获取手指位置。
调用 startDragging(y); 。这个方法其实就是根据按下位置和当前位置的距离,判断是否符合启动条件,保存位置,然后修改为拖拽状态为true(上面的按下事件中,被修改为false)。

  • 其中一个手指抬起时:(ACTION_POINTER_UP会在多个手指同时按下时,其中一个手指抬起时触发,但不是最后一个手指。)

只有一个 onSecondaryPointerUp(ev); 。内容也很单纯,仅仅是判断抬起来的那个手指是否是绑定的那个手指ID,如果是,那么就切换为另一个最早按下的手指。

  • 手指抬起:
  • 手势取消:

这两个事件被认定为同一个状态。
仅仅是设置拖拽状态为false。
取消记录的手指ID。

最后,返回当前的拖拽状态(如果是拖拽状态就捕获事件,否则就是放弃事件)。


以上,56行,剩余850行。

接下来是onTouchEvent

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        final int action = ev.getActionMasked();
        int pointerIndex = -1;

        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
            mReturningToStart = false;
        }

        if (!isEnabled() || mReturningToStart || canChildScrollUp()
                || mRefreshing || mNestedScrollInProgress) {
            // Fail fast if we're not in a state where a swipe is possible
            return false;
        }

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mActivePointerId = ev.getPointerId(0);
                mIsBeingDragged = false;
                break;

            case MotionEvent.ACTION_MOVE: {
                pointerIndex = ev.findPointerIndex(mActivePointerId);
                if (pointerIndex < 0) {
                    Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
                    return false;
                }

                final float y = ev.getY(pointerIndex);
                startDragging(y);

                if (mIsBeingDragged) {
                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
                    if (overscrollTop > 0) {
                        moveSpinner(overscrollTop);
                    } else {
                        return false;
                    }
                }
                break;
            }
            case MotionEvent.ACTION_POINTER_DOWN: {
                pointerIndex = ev.getActionIndex();
                if (pointerIndex < 0) {
                    Log.e(LOG_TAG,
                            "Got ACTION_POINTER_DOWN event but have an invalid action index.");
                    return false;
                }
                mActivePointerId = ev.getPointerId(pointerIndex);
                break;
            }

            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                break;

            case MotionEvent.ACTION_UP: {
                pointerIndex = ev.findPointerIndex(mActivePointerId);
                if (pointerIndex < 0) {
                    Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id.");
                    return false;
                }

                if (mIsBeingDragged) {
                    final float y = ev.getY(pointerIndex);
                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
                    mIsBeingDragged = false;
                    finishSpinner(overscrollTop);
                }
                mActivePointerId = INVALID_POINTER;
                return false;
            }
            case MotionEvent.ACTION_CANCEL:
                return false;
        }

        return true;
    }

以上为onTouchEvent的完整源码,有木有很眼熟啊,这根本就是复制粘贴嘛!(掀桌)。确实是这样的。但是秉承着严谨的态度,我们还是一行行的看。

首先,获取事件类型。声明手指的ID,并且赋值为-1。
然后如果是返回头部的状态并且是按下操作,就取消这个状态。
接着判断是否可以进行手势操作(onInterceptTouchEvent() 上一样).
紧跟着就是重头戏,switch!

  • 手指按下时:

获取当前手指id,并且保存。
重置拖拽状态。

  • 手指移动时:

获取当前绑定手指的序号(通过按下时保存的ID)。
判断序号是否在有效范围,如果没有,放弃本次事件。
获取当前手指位置,检查并决定是否设置为拖拽状态。
如果是拖拽状态,那么就计算拖拽距离,并且发送给方法 moveSpinner(float y) ,这个方法我们现在不管,因为它是移动刷新头的,等下说。
如果条件判断不符合,那么也是放弃本次事件。

  • 如果有新的手指按下(ACTION_POINTER_DOWN 表示当前已经有起码一个手指时,即不是第一个按下的手指,这时会被调用)

检查获取当前手指序号,如果无效,那么放弃本次事件。
如果有效,那么替换并绑定新按下的手势ID。

  • 如果有手指抬起(ACTION_POINTER_UP会在多个手指同时按下时,其中一个手指抬起时触发,但不是最后一个手指。)

同 onInterceptTouchEvent() 一样,仍然是检查抬起来的是否是绑定的手指,如果是,那么就替换为目前最先按下的手指。

  • 手指抬起时(与上面的有所区别,这个是最后一个手指被抬起)

还是检查绑定的手指是否有效,无效就放弃本次事件。
获取手指位置,计算滑动距离。
重置拖拽状态。
调用结束拖拽的方法,finishSpinner(float y) 这个方法是告诉刷新头,拖拽已经结束。然后该刷新就刷新,不该刷新就缩回去。不过这个方法的细节我们现在也不说,因为这是刷新头的事情。
最后,将绑定的手指ID置空。

最后的最后,返回true,表示:我的事件,绝对是我的!除了上面那些意外情况,你们谁也别想抢我的!


以上,77行,剩余770行。
上面这些就是关于拖拽处理的全部了,没错,只有这些,其他的都是一些零碎。
我们总结一下:

  • 嵌套滑动
    • 作为父View身份时,把你吃不下的滑动距离,拿给刷新头。
    • 作为子View身份时,把直接的滑动先给父View审一下,剩下的我再自己留着。
  • 手指滑动
    • 我该拿就拿,不该拿就不拿,拿到事件,绑定手指。
    • 如果给我能接受的,那么我就用了,不能接受的,宝宝拒绝。

那么说说我抽取出来的 SimpleRefreshLayout
其实把上面那些代码复制粘贴到一个类里,就搞定了!
好了,完事!下面我说说前面的那些埋的坑,这些属于刷新头部分。
因为SwipeRefreshLayout 中代码是混在一起的,所以我挑出来说。

抛开一系列的动画,真正决定刷新状态,决定Head状态的只有两个方法:moveSpinner(float y) , finishSpinner(float y) 。前者是移动刷新头,后者是释放刷新头。前者就是变化刷新头样式的,后者才是决定刷新状态和刷新头归属的。

相信看了前面的处理,已经多次提及这两个方法,而对于那种内容体跟随下拉效果的实现,应该有了眉目,没错,就是应该在 moveSpinner(float y) 实现。

我们还是看一下方法实现:

    private void moveSpinner(float overscrollTop) {
        mProgress.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;
        float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2)
                / slingshotDist);
        float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow(
                (tensionSlingshotPercent / 4), 2)) * 2f;
        float extraMove = (slingshotDist) * tensionPercent * 2;

        int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove);
        // where 1.0f is a full circle
        if (mCircleView.getVisibility() != View.VISIBLE) {
            mCircleView.setVisibility(View.VISIBLE);
        }
        if (!mScale) {
            mCircleView.setScaleX(1f);
            mCircleView.setScaleY(1f);
        }

        if (mScale) {
            setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance));
        }
        if (overscrollTop < mTotalDragDistance) {
            if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA
                    && !isAnimationRunning(mAlphaStartAnimation)) {
                // Animate the alpha
                startProgressAlphaStartAnimation();
            }
        } else {
            if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) {
                // Animate the alpha
                startProgressAlphaMaxAnimation();
            }
        }
        float strokeStart = adjustedPercent * .8f;
        mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart));
        mProgress.setArrowScale(Math.min(1f, adjustedPercent));

        float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f;
        mProgress.setProgressRotation(rotation);
        setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop);
    }

不管你有没有看懂,你都可以大概的有个概念,就是:它真的什么都没做,只是在改变样式。而我们再看看另一个方法:

    private void finishSpinner(float overscrollTop) {
        if (overscrollTop > mTotalDragDistance) {
            setRefreshing(true, true /* notify */);
        } else {
            // cancel refresh
            mRefreshing = false;
            mProgress.setStartEndTrim(0f, 0f);
            Animation.AnimationListener listener = null;
            if (!mScale) {
                listener = new Animation.AnimationListener() {

                    @Override
                    public void onAnimationStart(Animation animation) {
                    }

                    @Override
                    public void onAnimationEnd(Animation animation) {
                        if (!mScale) {
                            startScaleDownAnimation(null);
                        }
                    }

                    @Override
                    public void onAnimationRepeat(Animation animation) {
                    }

                };
            }
            animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener);
            mProgress.showArrow(false);
        }
    }

上面的代码更加简单,如果达到了刷新条件,那么就刷新(方法里面也是动画处理),要么就用动画让刷新头归位。

好了,那么就简单了。如果我们写个View,让他包含这两个方法,然后让外面的类继承它,那我们是不是就可以拥有无数的样式了?完美(脑补王祖蓝+金星)!所以我写了个基类。下面直接贴整个类,长代码预警:

    public static abstract class BaseRefreshView extends FrameLayout implements ValueAnimator.AnimatorUpdateListener{

        protected long targetViewAnimatorDuration = 200;

        protected float mTotalDragDistance = -1;
        protected float mTargetViewOffset = 0;
        protected OnRefreshListener refreshListener;
        protected boolean mRefreshing = false;
        protected ScrollCallBack targetViewScroll;

        protected ValueAnimator targetViewAnimator;

        protected void setTotalDragDistance(float mTotalDragDistance) {
            this.mTotalDragDistance = mTotalDragDistance;
        }

        public BaseRefreshView(Context context) {
            this(context,null);
        }

        public BaseRefreshView(Context context, @Nullable AttributeSet attrs) {
            this(context, attrs,0);
        }

        public BaseRefreshView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            final DisplayMetrics metrics = getResources().getDisplayMetrics();
            mTotalDragDistance = DEFAULT_PULL_TARGET * metrics.density;

            targetViewAnimator = ValueAnimator.ofFloat(0,1);
            targetViewAnimator.setDuration(targetViewAnimatorDuration);
            targetViewAnimator.addUpdateListener(this);
        }

        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            if(animation==targetViewAnimator){
                mTargetViewOffset = (float) animation.getAnimatedValue();
                targetScrollTo(mTargetViewOffset);
            }
        }

        protected void targetScrollTo(float offsetY) {
            if(targetViewScroll!=null){
                targetViewScroll.scrollTo(offsetY);
            }
        }

        protected void targetScrollWith(float offsetY) {
            if(targetViewScroll!=null){
                targetViewScroll.scrollWith(offsetY);
            }
        }

        protected void targetLockedScroll() {
            if(targetViewScroll!=null){
                targetViewScroll.lockedScroll();
            }
        }

        protected void targetResetScroll() {
            if(targetViewScroll!=null){
                targetViewScroll.resetScroll();
            }
        }

        /**
         * 下拉刷新的方法,此方法将直接接受SimpleRefreshLayout的调用
         * 主控件View的位置变化以及刷新显示控件的状态变化都是由此方法开始
         * @param overscrollTop 下拉的距离
         */
        protected void pullRefresh(float overscrollTop){
            //计算当前下拉的百分比
            float originalDragPercent = overscrollTop / mTotalDragDistance;
            //得到目标View的位置变化
            mTargetViewOffset = targetViewOffset(overscrollTop,mTotalDragDistance,originalDragPercent);
            targetScrollTo(mTargetViewOffset);
            onPullToRefresh(overscrollTop,mTotalDragDistance,originalDragPercent);
        }

        protected boolean finishPull(float overscrollTop){
            //计算当前下拉的百分比
            float originalDragPercent = overscrollTop / mTotalDragDistance;
            //触发结束下拉的方法
            onFinishPull(overscrollTop,mTotalDragDistance,originalDragPercent);
            //最后调用一次获取目标View的高度
            mTargetViewOffset = targetViewFinishOffset(overscrollTop,mTotalDragDistance,originalDragPercent);
            targetScrollTo(mTargetViewOffset);
            //返回是否用于刷新的方法
            mRefreshing = canRefresh(overscrollTop,mTotalDragDistance,originalDragPercent);
            if(mRefreshing){
                setRefreshing(true);
            }
            return mRefreshing;
        }

        /**
         * 是否开始刷新的确定方法,重写次方法用于决定是否开始刷新动作
         * @param pullPixels 下拉的实际距离
         * @param totalDragDistance 设定的触发刷新距离
         * @param originalDragPercent 下拉距离与目标距离间的百分比
         * @return 返回是否开始刷新,如果是true,那么认为是确认为刷新状态,
         * OnRefreshListener将得到触发
         */
        protected boolean canRefresh(float pullPixels,float totalDragDistance,float originalDragPercent){
            return pullPixels>totalDragDistance;
        }

        /**
         * 返回当前的状态,用于告诉其他部件,确认刷新状态
         * @return 返回刷新状态
         */
        protected boolean isRefreshing(){
            return mRefreshing;
        }

        /**
         * 下拉刷新的控制方法,实际显示的View需要实现此方法,
         * 用于实现对用户操作的反馈,建议将变化过程细化,增加跟随手指的感觉
         * 并且给予足够明显并且有明显暗示性的显示,告知用户当前状态
         * @param pullPixels 下拉的实际距离
         * @param totalDragDistance 设定的触发刷新距离
         * @param originalDragPercent 下拉距离与目标距离间的百分比
         */
        protected abstract void onPullToRefresh(float pullPixels,float totalDragDistance,float originalDragPercent);

        /**
         * 此方法将在结束下拉之后触发,实现或者重写此方法,
         * 将可以在松手后将View复位或者进行其他相关设置
         * @param pullPixels 下拉的实际距离
         * @param totalDragDistance 设定的触发刷新距离
         * @param originalDragPercent 下拉距离与目标距离间的百分比
         */
        protected abstract void onFinishPull(float pullPixels,float totalDragDistance,float originalDragPercent);

        /**
         * 开始刷新的方法,实现或重写此方法,
         * 用于展示自定义的加载动画方法
         */
        protected void setRefreshing(boolean refreshing){
            if(refreshing && !mRefreshing){
                callOnRefresh();
            }
            mRefreshing = refreshing;
        }

        /**
         * 这是用来控制主控件View高度变化的方法,在下拉动作触发的时候,
         * 将会调用此方法并且移动主控件View
         * @param pullPixels 下拉的实际距离
         * @param totalDragDistance 设定的触发刷新距离
         * @param originalDragPercent 下拉距离与目标距离间的百分比
         * @return 主控件的位置,此位置为最终位置,并非变化距离。并且此距离为像素距离
         */
        protected float targetViewOffset(float pullPixels,float totalDragDistance,float originalDragPercent){
            //默认不跟随滑动
            return 0;
        }
        /**
         * 这是用来控制主控件View高度变化的方法,在松手动作触发的时候,
         * 将会调用此方法并且移动主控件View
         * @param pullPixels 下拉的实际距离
         * @param totalDragDistance 设定的触发刷新距离
         * @param originalDragPercent 下拉距离与目标距离间的百分比
         * @return 主控件的位置,此位置为最终位置,并非变化距离。并且此距离为像素距离
         */
        protected float targetViewFinishOffset(float pullPixels,float totalDragDistance,float originalDragPercent){
            //默认不跟随滑动
            return 0;
        }

        /**
         * 主动触发刷新监听器,
         * 用于特殊情况下,
         * 主动触发刷新
         */
        protected void callOnRefresh(){
            if(refreshListener!=null){
                refreshListener.onRefresh();
            }
        }

        /**
         * 此方法用于重置显示状态
         */
        protected void reset(){
        }
    }

上面就是全部代码了,因为多次修改,导致有些无用代码,不过不影响啦。
SimpleRefreshLayout 中是什么调用的呢?

    private void pullRefresh(float overscrollTop){
        mRefreshView.pullRefresh(overscrollTop);
    }

    private void finishPull(float overscrollTop){
        if(mRefreshView.finishPull(overscrollTop)&&mListener!=null)
            mListener.onRefresh();
    }

就是这样干脆。说起来不过是,将滑动事件拆开来,然后在基类里面分别变成不同的方法,而 SimpleRefreshLayout 里面,根据 BaseRefreshView 的反馈结果,来更改状态。

以上就是这个 SimpleRefreshLayout 的全部内容。可以看出来,东西真的很少,不过我感觉实用性还是有的。

最后的最后,我说一下为什么没有去实现上拉加载更多。

  1. 从交互角度,你都知道我到底了,你早干嘛去了?还要我拉一下?
    为什么不监听滚动呢?当快要滚动到底部的时候,就开始加载。当真的滚动到底部的时候。新的一页已经加载好了。

  2. 从代码角度,加了上拉加载更多,你就真的省很多事情了?
    并不,因为不管是哪种封装方法,对于业务代码来说,其实没差。

那么到底差在哪里?懒呗,希望包办!对此,我在 SimpleRefreshLayout 中做了个小兼容,可以设置上拉加载更多的方法,不过仅限于 RecyclerView
有兴趣的朋友可以看看。

好了,至此敬礼,感谢您能从百忙之中来看我的简书,在此,小良深表荣幸,在此,在此感谢。
如果您又好的建议或者好玩的刷新头,欢迎跟我说。
好了,2017年的第一篇简书到此结束。谢谢。鞠躬。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容