一种无痕过渡下拉刷新控件的实现思路

3,175 阅读11分钟

一种无痕过渡下拉刷新控件的实现思路

相信大家已经对下拉刷新熟悉得不能再熟悉了,市面上的下拉刷新琳琅满目,然而有很多在我看来略有缺陷,接下来我将说明一下存在的缺陷问题,然后提供一种思路来解决这一缺陷,废话不多说!往下看嘞!

1.市面一些下拉刷新控件普遍缺陷演示

以直播吧APP为例:

第1种情况:

滑动控件在初始的0位置时,手势往下滑动然后再往上滑动,可以看到滑动到初始位置时滑动控件不能滑动。

原因:
下拉刷新控件响应了触摸事件,后续的一系列事件都由它来处理,当滑动控件到顶端的时候,滑动事件都被下拉刷新控件消费掉了,传递不到它的子控件即滑动控件,因此滑动控件不能滑动。

这里写图片描述

第2种情况:

滑动控件滑动到某个非0位置时,这时下拉回0位置时,可以看到下拉刷新头部没有被拉出来。

原因:
滑动控件响应了触摸事件,后续的一系列事件都由它来处理,当滑动控件到顶端的时候,滑动事件都被滑动控件消费掉了,父控件即下拉刷新控件消费不了滑动事件,因此下拉刷新头部没有被拉出来。

这里写图片描述

可能大部分人觉得无关痛痒,把手指抬起再下拉就可以了,but对于强迫症的我而言,能提供一个无痕过渡才是最符合操作逻辑的,因此接下来我来讲解下实现的思路。

2.实现的思路讲解

2.1.事件分发机制简介(来源于Android开发艺术探索)

dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent方法的关系伪代码

public boolean dispatchTouchEvent(MotionEvent ev) { 
    boolean consume = false;
    if(onInterceptTouchEvent(ev)) { 
        consume = onTouchEvent(ev);
    } else { 
        consume = child.dispatchTouchEvent(ev); 
    }
    return consume; 
}

1.由代码可知若当前View拦截事件,就交给自己的onTouchEvent去处理,否则就丢给子View继续走相同的流程。
2.事件传递顺序:Activity -> Window -> View,如果View都不处理,最终将由Activity的onTouchEvent
处理,是一种责任链模式的实现。
3.正常情况,一个事件序列只能被一个View拦截且消耗。
4.某个View一旦决定拦截,这一个事件序列只能由它处理,并且它的onInterceptTouchEvent不会再被调用
5.不消耗ACTION_DOWN,则事件序列都会由其父元素处理。

2.2.一般下拉刷新的实现思路猜想

首先,下拉刷新控件作为一个容器,需要重写onInterceptTouchEvent和onTouchEvent这两个方法,然后在onInterceptTouchEvent中判断ACTION_DOWN事件,根据子控件的滑动距离做出判断,若还没滑动过,则onInterceptTouchEvent返回true表示其拦截事件,然后在onTouchEvent中进行下拉刷新的头部显示隐藏的逻辑处理;若子控件滑动过了,不拦截事件,onInterceptTouchEvent返回false,后续其下拉刷新的头部显示隐藏的逻辑处理就无法被调用了。

2.3.无痕过渡下拉刷新控件的实现思路

从2.2中可以看出,要想无痕过渡,下拉刷新控件不能拦截事件,这时候你可能会问,既然把事件给了子控件,后续拉刷新头部逻辑怎么实现呢?

这时候就要用到一般都忽略的事件分发方法dispatchTouchEvent了,此方法在ViewGroup默认返回true表示分发事件,即使子控件拦截了事件,父布局的dispatchTouchEvent仍然会被调用,因为事件是传递下来的,这个方法必定被调用。

所以我们可以在dispatchTouchEvent时对子控件的滑动距离做出判断,在这里把下拉刷新的头部的逻辑处理掉,同时在函数调用return super.dispatchTouchEvent(event) 前把event的action设置为ACTION_CANCEL,这样子子控件就不会响应滑动的操作。

3.代码实现

3.1.确定需求

  • 需要适配任意控件,例如RecyclerView、ListView、ViewPager、WebView以及普通的不能滑动的View
  • 不能影响子控件原来的事件逻辑
  • 暴露方法提供手动调用刷新功能
  • 可以设置禁止下拉刷新功能

3.2.代码讲解

需要的变量

public class RefreshLayout extends LinearLayout {
    // 隐藏的状态
    private static final int HIDE = 0;
    // 下拉刷新的状态
    private static final int PULL_TO_REFRESH = 1;
    // 松开刷新的状态
    private static final int RELEASE_TO_REFRESH = 2;
    // 正在刷新的状态
    private static final int REFRESHING = 3;
    // 正在隐藏的状态
    private static final int HIDING = 4;
    // 当前状态
    private int mCurrentState = HIDE;

    // 头部动画的默认时间(单位:毫秒)
    public static final int DEFAULT_DURATION = 200;

    // 头部高度
    private int mHeaderHeight;
    // 内容控件的滑动距离
    private int mContentViewOffset;
    // 最小滑动响应距离
    private int mScaledTouchSlop;
    // 记录上次的Y坐标
    private float mLastMotionY;
    // 记录一开始的Y坐标
    private float mInitDownY;
    // 响应的手指
    private int mActivePointerId;

    // 是否在处理头部
    private boolean mIsHeaderHandling;
    // 是否可以下拉刷新
    private boolean mIsRefreshable = true;

    // 内容控件是否可以滑动,不能滑动的控件会做触摸事件的优化
    private boolean mContentViewScrollable = true;
    // 头部,为了方便演示选取了TextView
    private TextView mHeader;

    // 容器要承载的内容控件,在XML里面要放置好
    private View mContentView;

    // 值动画,由于头部显示隐藏
    private ValueAnimator mHeaderAnimator;

    // 刷新的监听器
    private OnRefreshListener mOnRefreshListener;

初始化时创建头部执行显示隐藏的值动画,添加头部到布局中,并且通过设置paddingTop隐藏头部

    public RefreshLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
        addHeader(context);
    }

    private void init() {

        mScaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();

        mHeaderAnimator = ValueAnimator.ofInt(0).setDuration(DEFAULT_DURATION);
        mHeaderAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                if (getContext() == null) {
                    // 若是退出Activity了,动画结束不必执行头部动作
                    return;
                }
                // 通过设置paddingTop实现显示或者隐藏头部
                int offset = (Integer) valueAnimator.getAnimatedValue();
                mHeader.setPadding(0, offset, 0, 0);
            }
        });
        mHeaderAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                if (getContext() == null) {
                    // 若是退出Activity了,动画结束不必执行头部动作
                    return;
                }
                if (mCurrentState == RELEASE_TO_REFRESH) {
                    // 释放刷新状态执行的动画结束,意味接下来就是刷新了,改状态并且调用刷新的监听
                    mHeader.setText("正在刷新...");
                    mCurrentState = REFRESHING;
                    if (mOnRefreshListener != null) {
                        mOnRefreshListener.onRefresh();
                    }
                } else if (mCurrentState == HIDING) {
                    // 下拉状态执行的动画结束,隐藏头部,改状态
                    mHeader.setText("我是头部");
                    mCurrentState = HIDE;
                }
            }
        });
    }

    // 头部的创建
    private void addHeader(Context context) {

        // 强制垂直方法
        setOrientation(LinearLayout.VERTICAL);

        mHeader = new TextView(context);
        mHeader.setBackgroundColor(Color.GRAY);
        mHeader.setTextColor(Color.WHITE);
        mHeader.setText("我是头部");
        mHeader.setTextSize(TypedValue.COMPLEX_UNIT_SP, 25);
        mHeader.setGravity(Gravity.CENTER);
        addView(mHeader, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);

        mHeader.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                // 算出头部高度
                mHeaderHeight = mHeader.getMeasuredHeight();
                // 移除监听
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                    mHeader.getViewTreeObserver().removeOnGlobalLayoutListener(this);
                } else {
                    mHeader.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                }
                // 设置paddingTop为-mHeaderHeight,刚好把头部隐藏掉了
                mHeader.setPadding(0, -mHeaderHeight, 0, 0);
            }
        });

    }

在填充完布局后取出内容控件

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        // 设置长点击或者短点击都能消耗事件,要不这样做,若孩子都不消耗,最终点击事件会被它的上级消耗掉,后面一系列的事件都只给它的上级处理了
        setLongClickable(true);

        // 获取内容控件
        mContentView = getChildAt(1);
        if (mContentView == null) {
            // 为空抛异常,强制要求在XML设置内容控件
            throw new IllegalArgumentException("You must add a content view!");
        }
        if (!(mContentView instanceof ScrollingView 
             || mContentView instanceof WebView 
              || mContentView instanceof ScrollView 
               || mContentView instanceof AbsListView)) {
            // 不是具有滚动的控件,这里设置标志位
            mContentViewScrollable = false;
        }

    }

重头戏来了,分发对于下拉刷新的特殊处理:
1.mContentViewOffset用于判别内容页的滑动距离,在无偏移值时才去处理下拉刷新的操作;
2.在mContentViewOffset!=0即内容页滑动的第一个瞬间,强制把MOVE事件改为DOWN,是因为之前MOVE都被拦截掉了,若不给个DOWN让内容页重新定下滑动起点,会有一瞬间滑动一大段距离的坑爹效果。

    @Override
    public boolean dispatchTouchEvent(final MotionEvent event) {

        if (!mIsRefreshable) {
            // 禁止下拉刷新,直接把事件分发
            return super.dispatchTouchEvent(event);
        }

        if ((mCurrentState == REFRESHING 
             || mCurrentState == RELEASE_TO_REFRESH 
              || mCurrentState == HIDING) 
               && mHeaderAnimator.isRunning()) {
            // 正在刷新,正在释放,正在隐藏头部都不处理事件,并且不分发下去
            return true;
        }

        // 支持多指触控
        int actionMasked = MotionEventCompat.getActionMasked(event);

        switch (actionMasked) {

            case MotionEvent.ACTION_DOWN: {
                // 记录响应的手指
                mActivePointerId = event.getPointerId(0);
                // 记录初始Y坐标
                mInitDownY = mLastMotionY = event.getY(0);
            }
            break;

            case MotionEvent.ACTION_POINTER_DOWN: {
                // 另外一根手指按下,切换到这个手指响应
                int pointerDownIndex = MotionEventCompat.getActionIndex(event);
                if (pointerDownIndex < 0) {
                    Log.e("RefreshLayout", "296行-dispatchTouchEvent(): " + "Got ACTION_POINTER_DOWN event but have an invalid action index.");
                    return dispatchTouchEvent(event);
                }
                mActivePointerId = event.getPointerId(pointerDownIndex);
                mLastMotionY = event.getY(pointerDownIndex);
            }
            break;

            case MotionEvent.ACTION_POINTER_UP: {
                // 另外一根手指抬起,切换回其他手指响应
                final int pointerUpIndex = MotionEventCompat.getActionIndex(event);
                final int pointerId = event.getPointerId(pointerUpIndex);
                if (pointerId == mActivePointerId) {
                    // 抬起手指就是之前控制滑动手指,切换其他手指响应
                    final int newPointerIndex = pointerUpIndex == 0 ? 1 : 0;
                    mActivePointerId = event.getPointerId(newPointerIndex);
                }
                mLastMotionY = event.getY(event.findPointerIndex(mActivePointerId));
            }
            break;

            case MotionEvent.ACTION_MOVE: {
                // 移动事件
                if (mActivePointerId == INVALID_POINTER) {
                    Log.e("RefreshLayout", "235行-dispatchTouchEvent(): " + "Got ACTION_MOVE event but don't have an active pointer id.");
                    return dispatchTouchEvent(event);
                }

                float y = event.getY(event.findPointerIndex(mActivePointerId));
                // 移动的偏移量
                float yDiff = y - mLastMotionY;
                mLastMotionY = y;

                if (mContentViewOffset == 0 && (yDiff > 0 || (yDiff < 0 && isHeaderShowing()))) {
                    // 内容控件还没滚动时,下拉或者在头部还在显示的时候上滑时,交由自己处理滑动事件

                    // 滑动的总距离
                    float totalDistanceY = mLastMotionY - mInitDownY;
                    if (totalDistanceY > 0 && totalDistanceY <= mScaledTouchSlop && yDiff > 0) {
                        // 下拉时,优化滑动逻辑,不要稍微一点位移就响应
                        return super.dispatchTouchEvent(event);
                    }

                    // 正在处理事件
                    mIsHeaderHandling = true;

                    if (mCurrentState == REFRESHING) {
                        // 正在刷新,不让contentView响应滑动
                        event.setAction(MotionEvent.ACTION_CANCEL);
                    }

                    // 处理下拉头部
                    scrollHeader(yDiff);

                    break;

                } else if (mIsHeaderHandling) {
                    // 在头部隐藏的那一瞬间的事件特殊处理
                    if (mContentViewScrollable) {
                        // 1.可滑动的View,由于之前处理头部,之前的MOVE事件没有传递到内容页,这里需要要ACTION_DOWN来重新告知滑动的起点,不然会瞬间滑动一段距离
                        // 2.对于不滑动的View设置了点击事件,若这里给它一个ACTION_DOWN事件,在手指抬起时ACTION_UP事件会触发点击,因此这里做了处理
                        event.setAction(MotionEvent.ACTION_DOWN);
                    }
                    mIsHeaderHandling = false;
                }
            }
            break;

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP: {
                // 处理手指抬起或取消事件
                mActivePointerId = INVALID_POINTER;
                if (isHeaderShowing()) {
                    // 头部显示情况下
                    if (actionMasked == MotionEvent.ACTION_CANCEL) {
                        // 取消的话强制不能刷新,状态改为下拉刷新,接下来autoScrollHeader就会隐藏头部
                        mCurrentState = PULL_TO_REFRESH;
                    }
                    autoScrollHeader();
                }
            }
            break;

            default:
                break;
        }

        if (mCurrentState != REFRESHING 
             && isHeaderShowing() 
               && actionMasked != MotionEvent.ACTION_UP 
                 && actionMasked != MotionEvent.ACTION_POINTER_UP) {
            // 不是在刷新的时候,并且头部在显示, 某些情况下不让contentView响应事件
            event.setAction(MotionEvent.ACTION_CANCEL);
        }

        return super.dispatchTouchEvent(event);
    }

头部的处理逻辑:拿到下拉偏移量,然后动态去设置头部的paddingTop值,即可实现显示隐藏;手指抬起时根据状态决定是显示刷新还是直接隐藏头部

   /**
     * 拉动头部
     *
     * @param diff 拉动距离
     */
    private void scrollHeader(float diff) {
        // 除以3相当于阻尼值
        diff /= 3;
        // 计算出移动后的头部位置
        int top = (int) (diff + mHeader.getPaddingTop());
        // 控制头部位置最小不超过-mHeaderHeight,最大不超过mHeaderHeight * 4
        mHeader.setPadding(0, Math.min(Math.max(top, -mHeaderHeight), mHeaderHeight * 3), 0, 0);
        if (mCurrentState == REFRESHING) {
            // 之前还在刷新状态,继续维持刷新状态
            mHeader.setText("正在刷新...");
            return;
        }
        if (mHeader.getPaddingTop() > mHeaderHeight / 2) {
            // 大于mHeaderHeight / 2时可以刷新了
            mHeader.setText("可以释放刷新...");
            mCurrentState = RELEASE_TO_REFRESH;
        } else {
            // 下拉状态
            mHeader.setText("正在下拉...");
            mCurrentState = PULL_TO_REFRESH;
        }
    }

    /**
     * 执行头部显示或隐藏滑动
     */
    private void autoScrollHeader() {
        // 处理抬起事件
        if (mCurrentState == RELEASE_TO_REFRESH) {
            // 释放刷新状态,手指抬起,通过动画实现头部回到(0,0)位置
            mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), 0);
            mHeaderAnimator.setDuration(DEFAULT_DURATION);
            mHeaderAnimator.start();
            mHeader.setText("正在释放...");
        } else if (mCurrentState == PULL_TO_REFRESH || mCurrentState == REFRESHING) {
            // 下拉状态或者正在刷新状态,通过动画隐藏头部
            mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), -mHeaderHeight);
            if (mHeader.getPaddingTop() <= 0) {
                mHeaderAnimator.setDuration((long) (DEFAULT_DURATION * 1.0 / 
                 mHeaderHeight * (mHeader.getPaddingTop() + mHeaderHeight)));
            } else {
                mHeaderAnimator.setDuration(DEFAULT_DURATION);
            }
            mHeaderAnimator.start();
            if (mCurrentState == PULL_TO_REFRESH) {
                // 下拉状态的话,把状态改为正在隐藏头部状态
                mCurrentState = HIDING;
                mHeader.setText("收回头部...");
            }
        }
    }

你可能会问了,这个mContentViewOffset怎么知道呢?接下来就是处理的方法,我会针对不同的滑动控件,去设置它们的滑动距离的监听,方法各种各样,通过handleTargetOffset去判别View的类型采取不同的策略;然后你可能会觉得要是我那个控件我也要实现监听咋办?这个简单,继承我已经实现的监听器,再补充你想要的功能即可,这个时候就不能再调handleTargetOffset这个方法了呗。

    // 设置内容页滑动距离
    public void setContentViewOffset(int offset) {
        mContentViewOffset = offset;
    }

    /**
     * 根据不同类型的View采取不同类型策略去计算滑动距离
     *
     * @param view 内容View
     */
    public void handleTargetOffset(View view) {
        if (view instanceof RecyclerView) {

            ((RecyclerView) view).addOnScrollListener(new RecyclerViewOnScrollListener());

        } else if (view instanceof NestedScrollView) {

            ((NestedScrollView) view).setOnScrollChangeListener(new NestedScrollViewOnScrollChangeListener());

        } else if (view instanceof WebView) {

            view.setOnTouchListener(new WebViewOnTouchListener());

        } else if (view instanceof ScrollView) {

            view.setOnTouchListener(new ScrollViewOnTouchListener());

        } else if (view instanceof ListView) {

            ((ListView) view).setOnScrollListener(new ListViewOnScrollListener());

        }
    }

    /**
     * 适用于RecyclerView的滑动距离监听
     */
    public class RecyclerViewOnScrollListener extends RecyclerView.OnScrollListener {

        int offset = 0;

        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            offset += dy;
            setContentViewOffset(offset);
        }

    }

    /**
     * 适用于NestedScrollView的滑动距离监听
     */
    public class NestedScrollViewOnScrollChangeListener implements NestedScrollView.OnScrollChangeListener {

        @Override
        public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
            setContentViewOffset(scrollY);
        }
    }

    /**
     * 适用于WebView的滑动距离监听
     */
    public class WebViewOnTouchListener implements View.OnTouchListener {

        @Override
        public boolean onTouch(View view, MotionEvent motionEvent) {
            setContentViewOffset(view.getScrollY());
            return false;
        }

    }

    /**
     * 适用于ScrollView的滑动距离监听
     */
    public class ScrollViewOnTouchListener extends WebViewOnTouchListener {

    }

    /**
     * 适用于ListView的滑动距离监听
     */
    public class ListViewOnScrollListener implements AbsListView.OnScrollListener {

        @Override
        public void onScrollStateChanged(AbsListView absListView, int i) {

        }

        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            if (firstVisibleItem == 0) {
                View c = view.getChildAt(0);
                if (c == null) {
                    return;
                }
                int firstVisiblePosition = view.getFirstVisiblePosition();
                int top = c.getTop();
                int scrolledY = -top + firstVisiblePosition * c.getHeight();
                setContentViewOffset(scrolledY);
            } else {
                setContentViewOffset(1);
            }
        }

    }

最后参考谷歌大大的SwipeRefreshLayout提供setRefreshing来开启或关闭刷新动画,至于openHeader为啥要post(Runnable)呢?相信用过SwipeRefreshLayout在onCreate的时候直接调用setRefreshing(true)没有小圆圈出来的都知道这个坑!

    public void setRefreshing(boolean refreshing) {
        if (refreshing && mCurrentState != REFRESHING) {
            // 强开刷新头部
            openHeader();
        } else if (!refreshing) {
            closeHeader();
        }
    }

    private void openHeader() {
        post(new Runnable() {
            @Override
            public void run() {
                mCurrentState = RELEASE_TO_REFRESH;
                mHeaderAnimator.setDuration((long) (DEFAULT_DURATION * 2.5));
                mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), 0);
                mHeaderAnimator.start();
            }
        });
    }

    private void closeHeader() {
        mHeader.setText("刷新完毕,收回头部...");
        mCurrentState = HIDING;
        mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), -mHeaderHeight);
        // 0~-mHeaderHeight用时DEFAULT_DURATION
        mHeaderAnimator.setDuration(DEFAULT_DURATION);
        mHeaderAnimator.start();
    }

3.3.效果展示

这里写图片描述

这里写图片描述

这里写图片描述

除了以上三个还有在Demo中实现了ListView、ViewPager、ScrollView、NestedScrollView,具体看代码即可

Demo地址:Github:RefreshLayoutDemo,觉得还不错的话给个Star哦。