Android使用Scroller实现下拉刷新控件

  最近看到Scroller的具体原理,看到书上实现下拉刷新的例子觉得很有意思,想着自己也来动手写写看,也算是理解一下Scroller,顺便也能锻炼一下自己自定义ViewGroup的技能。我会着重讲一讲实现的关键代码和实现的原理,在这次实现中我们主要锻炼了两个能力:
  1、使用Scroller实现View的滑动;
  2、自定义ViewGroup

1、实现原理

  下拉刷新这一动作的主要实现就是利用Scroller可以把View的内容移动的能力,移动整个ViewGroup,把已经布局好的HeaderView(下拉刷新拉下来显示的部分)移动到看不见的位置,然后再监听触摸事件,当做下拉动作时,再根据下拉距离一点点地把HeaderView移动下来,这样就能够实现下拉刷新的动作。
  如下图所示,蓝色区域表示屏幕可以看到的部分,这是没有利用Scroller移动之前布局的样子,是可以看到HeaderView的,也就是可以看到下拉刷新显示的部分
  布局
  当初始化控件时,利用Scroller在y轴方向向上滚动HeadrView的高度的距离,这样你的HeaderView就看不见了,也就是到了最初的状态,如下图所示:
  隐藏
  之后你再利用触摸事件监听,对下拉动作进行监听并且根据手指位移,利用Scroller在y轴向下滑动ViewGroup,这样就可以实现下拉刷新部分的显示和隐藏,从而实现下拉刷新操作,如下图所示
  下拉

2、实现关键代码

  首先,我们要明确一点就是我们的下拉刷新控件所包含的ContentView应该是使用的开发者关心的,我们只是要针对下拉操作去做,而不去关心到底ContentView是什么,所以ContentView肯定是一个泛型类,并且继承自View;第二个就是我们的控件既然要能够容纳HeaderView和ContentView,那么肯定是一个ViewGroup,所以控件要继承自ViewGroup,下面我们按几个过程来分析代码:

1、初始化

public abstract class RefreshLayoutBase<T extends View> extends ViewGroup

  这是我们下拉刷新控件类的定义,首先它是一个抽象类,因为针对ContentView的设置和isTop函数(稍后会说到)都是使用的开发者具体去实现的,所以肯定是抽象函数,那么下拉刷新控件类一定是一个抽象类。

    //Scroller
    private Scroller mScroller;

    //下拉显示的头部视图
    private View mHeaderView;

    //初始上滑距离
    private int mInitScrollY;

    //内容视图,用户自行设置
    protected T mContentView;

    //上次触摸事件Y坐标
    private int mLastY;

    //下拉操作的每次滑动偏移量
    private int mYOffset; 

    //提示的文本
    private TextView tipTextView;

    //箭头ImageView
    private ImageView arrowImageView;

    //等待ImageView(有动画)
    private ImageView waitImageView;

    //刷新成功之后显示的ImageView
    private ImageView successImageView;

    //刷新失败之后显示的ImageView
    private ImageView failureImageView;

    /**
     * 刷新回调Listener和set函数
     */
    private OnRefreshListener mRefreshListener;

    public void setOnRefreshListener(OnRefreshListener listener){
        this.mRefreshListener = listener;
    }

    /**
     * 刷新状态枚举:刷新中、初始状态、下拉刷新(已拉动)、释放刷新(已拉动)
     */
    private enum RefreshState {
        REFRESHING_STATE,
        IDLE_STATE,
        PULL_TO_REFRESH,
        RELEASE_TO_REFRESH
    }

    //刷新状态,初始为初始状态
    private RefreshState mState = RefreshState.IDLE_STATE;

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

    public RefreshLayoutBase(Context context, AttributeSet attrs){
        this(context, attrs, 0);
    }

    public RefreshLayoutBase(Context context, AttributeSet attrs, int defStyle){
        super(context, attrs);
        mScroller = new Scroller(context);

        //设置内容视图
        setContentView(context);
        //设置头部视图
        setHeaderView(context);
        //添加用户设置的内容视图
        addView(mContentView);
    }

  属性和控件我都做了注释,一目了然,我们来看一看构造函数,首先创建了滑动控制器Scroller,之后调用了setContentView(context),还记得我说过ContentView是使用的开发者自行去设置的吗,就是说ConentView是可变的,它可能是一个TextView,可能是一个RecyclerView等等,是未知的,留给子类去具体化,所以它是一个抽象函数:  

//设置内容视图,留给子类实现
protected abstract void setContentView(Context context);

  而HeaderView是固定的,通过setHeaderView(context)函数来进行设置,我们看一看它的实现:

    /**
     * 初始化头部视图
     * @param context context
     */
    protected void setHeaderView(Context context){
        mHeaderView = LayoutInflater.from(context).inflate(R.layout.pull_to_refresh_header, this, false);
        addView(mHeaderView);

        //find the widget of headerView
        tipTextView = (TextView) mHeaderView.findViewById(R.id.pull_to_refresh_text);
        arrowImageView = (ImageView) mHeaderView.findViewById(R.id.refresh_arrow_image);
        waitImageView = (ImageView) mHeaderView.findViewById(R.id.wait_circuit_image);
        successImageView = (ImageView) mHeaderView.findViewById(R.id.refresh_success_image);
        failureImageView = (ImageView) mHeaderView.findViewById(R.id.refresh_failure_image);
    }

  其实就是根据设定好的布局进行HeaderView的inflate,并且调用addView把HeaderView添加到布局中,再对一些需要用到的控件进行初始化,就完成了HeaderView的初始化工作。

2、测量和布局

  在完成了初始化工作之后,就要进行一个关键步骤,就是测量和布局,这是自定义ViewGroup必备的,实际上就是实现onMeasure和onLayout两个函数,实现代码如下:

    /**
     * 测量工作,宽度为用户设置的宽度,高度为HeaderView和ContentView高度之和
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int width = MeasureSpec.getSize(widthMeasureSpec);
        Log.d("test", "Width :" + width);

        int childCount = getChildCount();

        int finalHeight = 0;
        for (int i = 0; i < childCount; i++){
            View child = getChildAt(i);
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            finalHeight += child.getMeasuredHeight();
        }

        setMeasuredDimension(width, finalHeight);
    }

    /**
     * 布局工作,从上到下布局
     */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int left = getPaddingLeft();
        int top = getPaddingTop();
        int childCount = getChildCount();
        Log.d("test", "ChildCount is:" + childCount);
        for (int i = 0; i < childCount; i++){
            View child = getChildAt(i);
            child.layout(left, top, child.getMeasuredWidth(), child.getMeasuredHeight() + top);
            top += child.getMeasuredHeight();
            Log.d("test", "Child" + i + "height: " + child.getMeasuredHeight());
        }
        mInitScrollY = mHeaderView.getMeasuredHeight() + getPaddingTop();
        scrollTo(0, mInitScrollY);
    }

  在测量onMeasure中,我们将宽度设置为用户设置的宽度,高度则为所有子视图的高度之和(实际上就是HeaderView和ContentView的高度)。
  在布局onLayout中,我们从上到下布局子视图,也就是我在原理中讲的那样布局,之后做了一个关键操作:

mInitScrollY = mHeaderView.getMeasuredHeight() + getPaddingTop();
scrollTo(0, mInitScrollY);

  这就是我们在原理中说的隐藏HeaderView的操作,首先计算HeaderView的高度,然后利用scrollTo在y轴上上滑一段和HeaderView高度一样的(mInitScrollY)的距离,这样就起到了隐藏HeaderView的效果,也就是进入了初始状态,这样我们就完成了测量和布局,我们的下拉刷新控件也正式进入了初始状态(IDLE_STATE)。

3、下拉动作监听和刷新处理

  如原理第三张图所示,当用户下拉时,我们要根据用户下拉的距离来滑动控件,以达到HeaderView慢慢显示的效果,并且在此效果的基础上还要实现文本提示改变等等控件的改变,下面来看代码:

    /**
     * 在下拉操作,并且ContentView位于顶端时拦截触摸事件
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        final int action = ev.getAction();
        if(action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP){
            return false;
        }

        switch (action){
            case MotionEvent.ACTION_DOWN:
                mLastY = (int) ev.getRawY();
                break;

            case MotionEvent.ACTION_MOVE:
                if(isTop() && ev.getRawY() - mLastY > 0){
                    return true;
                }
                break;
        }
        //其余情况都不会拦截
        return false;
    }

  熟悉自定义View的应该对这个函数都不陌生,onInterceptTouchEvent 就是ViewGroup是否进行触摸事件拦截的函数,如果返回true表示ViewGroup会对此触摸事件进行拦截,那么ViewGroup就会对触摸事件进行处理,不会传递到其子视图上进行处理。那么何时应该由我们的下拉刷新控件进行处理呢?应该满足两个情况:
  1、下拉动作(即触摸事件是下拉,Y轴方向位移大于0);
  2、ContentView位于顶部,这是因为如果你的ContentView是一个本身可以滑动的控件,这也是下拉刷新常见的控件,比如ListView,那么只有你的ListView位于最上端的时候你才能够进行下拉刷新操作。
  所以我们在DOWN事件发生时记录了坐标,在MOVE事件时判断Y轴位移的大小并且调用isTop函数来判断ContentView是否位于顶端:isTop() && ev.getRawY() - mLastY > 0,而isTop由于是针对未知的ContentView的判断,自然也留给子类去进行实现:

    /**
     * 判断ContentView是否位于顶部,留给子类实现
     */
    protected abstract boolean isTop();

  在判断是否进行拦截之后,就要对拦截到的符合情况的触摸事件进行处理了,而我们都知道触摸事件的处理函数是onTouchEvent,代码如下:

    /**
     * 处理符合条件的触摸事件,进行刷新逻辑和控件状态改变逻辑
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mState == RefreshState.REFRESHING_STATE)
            return true;
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                mLastY = (int) event.getRawY();
                break;

            case MotionEvent.ACTION_MOVE:
                int currentY = (int) event.getRawY();
                mYOffset = currentY - mLastY;
                changeScrollY(mYOffset);
                mLastY = currentY;
                break;

            case MotionEvent.ACTION_UP:
                int curScrollY = getScrollY();
                if(curScrollY < mInitScrollY / 4){
                    refresh();
                } else {
                    recoverToInitState();
                }
                break;

            /**
             * 监听取消事件,防止下拉过程中锁屏之后不复原的BUG,在锁屏时恢复初始状态
             */
            case MotionEvent.ACTION_CANCEL:
                recoverToInitState();
                break;
        }

        return true;
    }

  首先我判断是否在刷新状态,如果进入了刷新状态,那么就应该不能做任何操作(我是这么想的,不过不知道这个逻辑有没有问题),所以如果是刷新状态就直接消耗触摸事件并返回。如果不是就要根据触摸事件的类型来进行相应的处理:

ACTION_DOWN事件

  如果是DOWN事件,就记录下触摸事件Y轴的坐标,作为mLastY;

ACTION_MOVE事件

  如果是MOVE事件,就计算当前触摸事件的Y轴坐标与之前的mLastY所记录的值的差值,之后调用changeScrollY进行控件在Y轴上的滑动以达到随着手指下拉操作,HeaderView逐渐显示的效果,每次滑动之后要把mLastY设置为当前值,为下一次滑动做准备,其实就是根据手指下拉过程中每两次MOVE事件之间的坐标差进行滑动,由于坐标差非常小,所以滑动起来很流畅,mLastY记录的就是上一次的坐标值。
  让我们看一下具体是如何进行滑动的:

    /**
     * 根据每两次MOVE之间的Y轴坐标差值,在Y轴上进行控件的移动,移动的距离就是差值
     * @param distance 移动距离
     */
    private void changeScrollY(int distance){
        int curY = getScrollY();
        Log.d("test", "Height is:" + mHeaderView.getHeight());
        if (distance > 0 && curY - distance > getPaddingTop()) {
            // 下拉过程
            scrollBy(0, -distance);
        } else if (distance < 0 && curY - distance <= mInitScrollY) {
            // 上滑过程
            scrollBy(0, -distance);
        }

        int slop = mInitScrollY / 4;
        if(curY > 0 && curY < slop){
            mState = RefreshState.RELEASE_TO_REFRESH;
        } else if (curY > 0 && curY > slop){
            mState = RefreshState.PULL_TO_REFRESH;
        }
        changeWidgetState();
    }

  滑动也有两种情况,就是下拉和上滑:
  1、当你进行下拉时,触摸事件坐标减去上一次坐标为正值,也就是distance大于0的情况,这种情况的临界点在哪里呢?当你下拉时,最多不能把HeaderView上面的部分拉下来,也就是下图的这种情况就不能再向下拉了:
  这里写图片描述
  所以需要达到的要求就是distance > 0 && curY - distance > getPaddingTop(),这个理解了我们在看上滑的情况;
  2、当你上滑是,也就是你下拉时又可以把它推回去,也就是上滑操作,和下拉相反,这时distance是小于0的,它的临界点在哪呢?当然是初始状态了,就是我们隐藏时的状态,不能再让HeaderView上去了,也就是下图这里说明的情况,你的scrollY滑动之后不能大于mInitScrollY,这种情况下就不能再上滑了:
  这里写图片描述
  相信看完了滑动如何实现,应该对下拉刷新这个动作一目了然了,实现滑动还不行,我们还需要对控件的状态做一些改变,比如下拉到某个位置,就要显示成释放即可刷新等,这些逻辑全和状态有关,所以当你的curY超过或者小于临界值slop事,就要对状态进行改变,即设置mState的值,然后调用changeWidgetState() 进行控件状态的改变,让我们来看看代码,具体逻辑都很简单,就用详细说明了:

    /**
     * 根据当前状态设置HeaderView的子控件
     */
    private void changeWidgetState(){
        switch (mState){
            case PULL_TO_REFRESH:
                tipTextView.setText("下拉刷新");
                arrowImageView.setRotation(0);
                break;

            case RELEASE_TO_REFRESH:
                tipTextView.setText("释放立即刷新");
                arrowImageView.setRotation(180);
                break;


            case REFRESHING_STATE:
                arrowImageView.setVisibility(INVISIBLE);
                waitImageView.setVisibility(VISIBLE);
                startWaitAnimation();
                tipTextView.setText("正在刷新...");
                break;
        }
    }
ACTION_UP事件

  让我们回到onTouchEvent函数,说完了DOWN和MOVE事件,下面就要说一说UP事件了,当用户松开手指时,如果HeaderView显示超过3/4,就要进行刷新操作,如果没有超过,那么就取消了操作,那么控件就要回到初始状态。刷新操作会调用refresh函数:

    /**
     * 刷新操作
     */
    private void refresh(){
        mState = RefreshState.REFRESHING_STATE;
        //刷新滑动到固定位置
        mScroller.startScroll(getScrollX(), getScrollY(),
                0, mInitScrollY / 2 - getScrollY());
        invalidate();
        changeWidgetState();
        if(mRefreshListener != null && mState == RefreshState.REFRESHING_STATE){
            mRefreshListener.onRefresh();
        }
    }
ACTION_CANCEL事件

  这里我们多监听了一个MotionEvent.ACTION_CANCEL是为什么呢?是因为当用户下拉时,还未释放的情况下如果关闭了屏幕,这种情况如果不考虑,再打开屏幕的时候就会出现下滑到一半的BUG,所以监听此种情况并进行恢复初始状态的操作,以避免这种BUG:

/**
* 监听取消事件,防止下拉过程中锁屏之后不复原的BUG,在锁屏时恢复初始状态
*/
case MotionEvent.ACTION_CANCEL:
    recoverToInitState();
    break;

4、刷新完成处理

  刷新完成之后,开发者需要调用completeRefresh或者failRefresh来告知控件刷新完成或者刷新失败,以告知使用者刷新的结果,并且将控件回归到初始状态:(我这里只贴出完成刷新的代码)

    public void completeRefresh(){
        tipTextView.setText("刷新成功");
        waitImageView.setVisibility(INVISIBLE);
        successImageView.setVisibility(VISIBLE);


        /**
         * A problem here to be solved!
         * 当调用设置为VISIBLE的时候,其自动Scroll到了最上面的位置???理由不清楚
         */
        mScroller.startScroll(getScrollX(), getScrollY(),
                0, mInitScrollY / 2 - getScrollY());
        invalidate();

        mState = RefreshState.IDLE_STATE;
        this.postDelayed(new Runnable() {
            @Override
            public void run() {
                recoverToInitState();
            }
        }, 400);
    }

  在完成刷新这里遇到一个BUG,就是如果在这里调用setVisibility(VISIBLE)函数,将我成功的图标显示出来,Scroller就会自动滚动到最上方的初始状态,并且不报任何错误,我也不知道为什么…只能再滑动下来解决这个问题,这个问题还要慢慢研究以判断到底是为什么,这个解决方式太丑陋了一点。
  完成刷新之后利用postDelayed延迟0.4秒左右再复原到初始状态,以让用户看到刷新的结果,recoverToInitState在之前也使用过,就是当用户手指位置未到临界点就松开时复原,代码如下:

private void recoverToInitState(){
        Log.d("test", "Scroll is:" + getScrollY() + "");
        mScroller.startScroll(getScrollX(), getScrollY(),
                0, mInitScrollY - getScrollY());
        this.invalidate();
        //successImageView.setVisibility(INVISIBLE);
        this.postDelayed(new Runnable() {
            @Override
            public void run() {
                arrowImageView.setVisibility(VISIBLE);
                waitImageView.setVisibility(INVISIBLE);
                successImageView.setVisibility(INVISIBLE);
                failureImageView.setVisibility(INVISIBLE);
            }
        }, 100);
    }

  也是利用了一个弹性滑动,滑动到初始位置,并且对HeaderView的子控件做了一下复原操作。

3、使用

  使用其实你懂了原理之后应该觉得很容易,其实就是继承RefreshLayoutBase之后实现需要子类覆写的函数即可,我们就以一个RefreshTextView为例,简单使用一下,实现代码如下:

public class MyRefreshTextView extends RefreshLayoutBase<TextView>{

    public MyRefreshTextView(Context context){
        super(context);
    }

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

    @Override
    protected void setContentView(Context context) {
        mContentView = new TextView(context);
        ((TextView) mContentView).setText("TextView");
        mContentView.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
    }

    @Override
    protected boolean isTop() {
        return true;
    }
}

  其实就是覆写两个函数而已,一个是setContentView,用于设置你的具体内容视图;一个是isTop,用于判断是否位于顶部,使用时只需要创建并添加RefreshTextView即可:

        LinearLayout linearLayout = (LinearLayout)findViewById(R.id.id_layout_main);
        refreshTextView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT));
        refreshTextView.setOnRefreshListener(new OnRefreshListener() {
            @Override
            public void onRefresh() {
                refreshTextView.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        refreshTextView.completeRefresh();
                    }
                }, 1500);
            }
        });
        linearLayout.addView(refreshTextView);

效果截图:
  这里写图片描述
  这里写图片描述
  这里写图片描述
  这里写图片描述

4、总结

  通过自己实现下拉刷新控件,我们很好的锻炼了自定义ViewGroup和使用Scroller实现弹性滑动的能力,相似的我们还可以利用此技术实现上拉加载等多种丰富的控件。
  源代码已上传Github:https://github.com/FrankLee96/MyPullRefresh/tree/master
  

如果觉得我的文章里有任何错误,欢迎评论指正!如果觉得写得好也欢迎大家留言或者点赞,一起进步、一起学习!

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值