一次满足两个需求——嵌套滑动&下拉刷新

前言

日常开发中,下拉刷新列表是一个常见的不能再常见的需求了,github上也有很多成熟了下拉刷新库可供学习,但如果要刷新的列表被ScrollView或者RecyclerView包围,可能很多传统的下拉刷新的实现就没办法使用了,那我们如何在嵌套滑动中和谐共处地实现下拉刷新呢?

先看最终效果图:


基础知识——嵌套滚动

按照使用习惯,用户会在手机屏幕上使用上下或者左右滑动手势来滚动页面或者列表,但如果在同一个界面有多个控件可以滑动时,如何协调多个控件响应用户操作是一个颇为复杂的问题。一般来说,我们可以在控件中实现dispatchTouchEvent(), onTouchEvent(), onInterceptTouchEvent()三连击来控制滑动操作,但这个实现方式有个漏洞,如果Touch事件传递过程中,某个View获得处理Touch事件机会,那么其他View就再也没有机会去处理这个Touch事件了,直到下一次手指再按下,也就是说,这种方法无法让多个View协同处理一个滑动事件。
这时候,Google霸霸就跳出来给我们指出了一条明路——NestedScrolling机制。NestedScrolling可以很好地解决嵌套控件中滑动事件的拦截、分发和使用的问题,使用NestedScrolling后的效果如下:



可以看到,顶部的AppBar和下面的ListView都是可以滑动的,使用了NestedScrolling后,只有ListView下滑到顶部时,AppBar才会响应下滑事件。
NestedScrolling的想法并不复杂,它会把嵌套的控件分为父View和子View,控件接收到的每个滑动事件都分开几个阶段通知父View,父View决定事件的处理,并负责把处理完的事件分发给子View,层层传递下去,直到滑动事件消耗完:

场景一:

  • 子View:爸爸,我准备在x轴方向滑动50px,有什么吩咐没
  • 父View:好的,没什么吩咐的,你滑吧。
  • 子View:遵命!滑动ing...... 爸爸,我滑完了,总共滑了50px。
  • 父View:好的,记得每次都要提前汇报!

场景二:

  • 子View:爸爸,我准备在x轴方向滑动50px,有什么吩咐没
  • 父View:你x轴的50px我要全部没收,你别动了
  • 子View:纳尼 w(゚Д゚)w 好吧谁让你是爸爸...

具体到代码实现,Google提供了NestedScrollingParent和NestedScrollingChild两个接口,如果自定义的View要当父亲来嵌套子View,那么请实现NestedScrollingParent,如果要当儿子被父View包含,请实现NestedScrollingChild,大多数情况下,我们自定义的View最好同时继承NestedScrollingParent和NestedScrollingChild来提高使用时的灵活性。

我们先来看看NestedScrollingParent和NestedScrollingChild:

public interface NestedScrollingParent {

    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    void onStopNestedScroll(@NonNull View target);

    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);

    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    @ScrollAxis
    int getNestedScrollAxes();
}
public interface NestedScrollingChild {

    void setNestedScrollingEnabled(boolean enabled);

    boolean isNestedScrollingEnabled();

    boolean startNestedScroll(@ScrollAxis int axes);

    void stopNestedScroll();

    boolean hasNestedScrollingParent();

    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);

    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

可以看出Parent和Child很多方法名都是接近的,Google霸霸怕我们处理不好这些事件的分发,还贴心的给我们提供了两个对应的辅助类:NestedScrollingParentHelper和NestedScrollingChildHelper,如果要写一个简单的NestedScrolling对象,只需要调用NestedScrollingHelper对应的方法就可以了,例如:

class NestedScrollingView extends ViewGroup implements NestedScrollingChild{

    private NestedScrollingChildHelper mChildHelper = new NestedScrollingChildHelper(this);
    
    //...
    
    @Override
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
                              int dxUnconsumed, int dyUnconsumed, @Nullable  int[] offsetInWindow){
        return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
                dxUnconsumed, dyUnconsumed,offsetInWindow)
    }
}

滑动事件传递简要流程图如下(不包含Fling事件):


关于NestedScrollingParent和NestedScrollingChild方法参数和返回值的详细说明可以参考这里,接下来会结合下拉刷新的需求来实现具体代码。
Lollipop及以上版本的所有View都已经支持了NestedScroll机制,Lollipop之前版本可以通过Support包进行向前兼容。

此外,26.0.0中NestedScroll得到了加强,对嵌套滚动的API做了些改进,出现了新的接口NestedScrollingParent2和NestedScrollingChild2。新的接口在部分方法之上添加了一个新的参数 type ,type参数告诉你是什么类型的输入在驱动scroll事件,目前可以是这两种选项之一:ViewCompat.TYPE_TOUCH 和ViewCompat.TYPE_NON_TOUCH。详细可以参考这里,下文将使用新的API。

实现需求——下拉刷新

要在NestScrolling的基础上实现下拉刷新功能,我们首先定义一个继承ViewGroup的CustomRefreshLayout控件,并重写其中的onMeasure和onLayout方法,实现对滑动控件和刷新控件的宽高测量和具体布局工作:
注:下面的代码使用了Kotlin语言编写,语法不复杂不会影响阅读体验,了解更多关于Kotlin的内容可以看我这篇文章

public class CustomRefreshLayout : ViewGroup {

    private var mTarget: View? = null // 被滑动的控件
    private var mSpinner: FrameLayout by Delegates.notNull() // 刷新控件

    init {
        mSpinner = FrameLayout(context)
        addView(mSpinner)
    }

    //...

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        if (mTarget == null) {
            ensureTarget()
        }
        if (mTarget == null) {
            return
        }
        mTarget?.measure(MeasureSpec.makeMeasureSpec(
                measuredWidth - paddingLeft - paddingRight,
                MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
                measuredHeight - paddingTop - paddingBottom, MeasureSpec.EXACTLY))
        mSpinner.measure(
                MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.AT_MOST),
                MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.AT_MOST))
        mOriginalSpinnerOffsetTop = -(mSpinner.measuredHeight)
        mSpinnerIndex = -1
        // 获取刷新控件的序号
        for (index in 0 until childCount) {
            if (getChildAt(index) === mSpinner) {
                mSpinnerIndex = index
                break
            }
        }
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        val width = measuredWidth
        val height = measuredHeight
        if (childCount == 0) {
            return
        }
        if (mTarget == null) {
            ensureTarget()
        }
        val child = mTarget ?: return
        val childLeft = paddingLeft
        var childTop = paddingTop
        val childWidth = width - paddingLeft - paddingRight
        val childHeight = height - paddingTop - paddingBottom
        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight)
        val spinnerWidth = mSpinner.measuredWidth
        val spinnerHeight = mSpinner.measuredHeight
        mSpinner.layout(width / 2 - spinnerWidth / 2, mCurrentSpinnerOffsetTop,
                width / 2 + spinnerWidth / 2, mCurrentSpinnerOffsetTop + spinnerHeight)
    }

    private fun ensureTarget() {
        // 确保要处理滑动的控件存在
        if (mTarget == null) {
            for (i in 0 until childCount) {
                val child = getChildAt(i)
                if (child != mSpinner) {
                    mTarget = child
                    break
                }
            }
        }
    }
}

这其中有一个坑,刷新控件mSpinner的位置可能不是ViewGroup的最后一个,所以在绘制时可能被滑动控件覆盖,要解决这问题,可以重写getChildDrawingOrder方法来指定绘制顺序:

override fun getChildDrawingOrder(childCount: Int, i: Int): Int {
    return when {
        mSpinnerIndex < 0 -> i
        i == childCount - 1 -> // 最后一位绘制
            mSpinnerIndex
        i >= mSpinnerIndex -> // 提早一位绘制
            i + 1
        else -> // 保持原顺序绘制
            i
    }
}

有了NestedScrolling后,我们可以不用关注触摸事件,只需要处理滑动事件中,所以让CustomRefreshLayout实现NestedScrollingParent2和NestedScrollingChild2两个接口。

public class CustomRefreshLayout : ViewGroup, NestedScrollingParent2, NestedScrollingChild2 {

    private val mParentHelper = NestedScrollingParentHelper(this)
    private val mChildHelper = NestedScrollingChildHelper(this)
    private var mStatus: Status = Status.Ready

    //...

    // NestedScrollingParent

    override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int, type: Int): Boolean {
        return (isEnabled && mStatus != Status.Refresh
                && nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 //只接受垂直方向的滚动
                && type == ViewCompat.TYPE_TOUCH)//只接受touch的滚动,不接受fling
    }

    override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
        mParentHelper.onNestedScrollAccepted(child, target, axes, type)
        // 分发事件给Nested Parent
        startNestedScroll(axes and ViewCompat.SCROLL_AXIS_VERTICAL, type)
        // 重置计数
        mTotalUnconsumed = 0f
        mStatus = Status.Pull
    }

    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray?, type: Int) {
        consumed ?: return

        // 如果在下拉过程中,直接响应并消耗上滑距离,调整Spinner位置
        if (dy > 0 && mTotalUnconsumed > 0) {
            if (dy > mTotalUnconsumed) {
                consumed[1] = dy - mTotalUnconsumed.toInt()
                mTotalUnconsumed = 0f
            } else {
                mTotalUnconsumed -= dy.toFloat()
                consumed[1] = dy
            }
            moveSpinner(mTotalUnconsumed)
        }

        // 让Nested Parent来处理剩下的滑动距离
        val parentConsumed = mParentScrollConsumed
        if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null, type)) {
            consumed[0] += parentConsumed[0]
            consumed[1] += parentConsumed[1]
        }
    }

    override fun onStopNestedScroll(target: View, type: Int) {
        mParentHelper.onStopNestedScroll(target)
        // 如果有处理过滑动事件,执行滑动停止后的操作
        if (mTotalUnconsumed > 0) {
            finishSpinner(mTotalUnconsumed)
            mTotalUnconsumed = 0f
        }
        // 分发事件给Nested Parent
        stopNestedScroll(type)
    }

    override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
                                dxUnconsumed: Int, dyUnconsumed: Int, type: Int) {
        // 首先分发事件给Nested Parent
        dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                mParentOffsetInWindow, type)

        // 考虑到有时候可能被两个nested scrolling view包围,这里计算滑动距离时要加上Nested Parent滑动的距离
        // 如果可以刷新,移动刷新控件的位置
        val dy = dyUnconsumed + mParentOffsetInWindow[1]
        if (dy < 0 && !canChildScrollUp()) {
            mTotalUnconsumed += Math.abs(dy).toFloat()
            moveSpinner(mTotalUnconsumed)
        }
    }

    // NestedScrollingChild,全部交由Childer Helper来处理

    override fun startNestedScroll(axes: Int, type: Int): Boolean {
        return mChildHelper.startNestedScroll(axes, type)
    }

    override fun stopNestedScroll(type: Int) {
        mChildHelper.stopNestedScroll(type)
    }

    override fun hasNestedScrollingParent(type: Int): Boolean {
        return mChildHelper.hasNestedScrollingParent(type)
    }

    override fun dispatchNestedScroll(dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int,
                                      dyUnconsumed: Int, offsetInWindow: IntArray?, type: Int): Boolean {
        return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
                dxUnconsumed, dyUnconsumed, offsetInWindow, type)
    }

    override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?, type: Int): Boolean {
        return mChildHelper.dispatchNestedPreScroll(
                dx, dy, consumed, offsetInWindow, type)
    }

    /**
     * 移动刷新控件的垂直位置
     */
    private fun moveSpinner(overscrollTop: Float) {
        if (mSpinner.visibility != View.VISIBLE) {
            mSpinner.visibility = View.VISIBLE
        }
        val move = if (overscrollTop <= mRefreshSlop) {
            overscrollTop
        } else {
            mRefreshSlop + (overscrollTop - mRefreshSlop) / 2f
        }.toInt()
        val targetOffsetTop = mOriginalSpinnerOffsetTop + move
        setSpinnerOffsetTopAndBottom(targetOffsetTop - mCurrentSpinnerOffsetTop)
    }

    /**
     * 停止下拉后的操作
     */
    private fun finishSpinner(overscrollTop: Float) {
        if (overscrollTop > mRefreshSlop) {
            setRefreshing(true, true /* notify */)
        } else {
            // cancel refresh
            mStatus = Status.Ready
            animateSpinnerToReady()
        }
    }

    /**
     * 设置刷新状态
     * @param refreshing 是否在刷新
     * @param notify 是否通知listener
     */
    private fun setRefreshing(refreshing: Boolean, notify: Boolean) {
        val isRefreshing = mStatus == Status.Refresh
        if (isRefreshing != refreshing) {
            ensureTarget()
            if (refreshing) {
                mStatus = Status.Refresh
                animateSpinnerToRefresh()
                if (notify) {
                    mOnRefreshListener?.onRefresh()
                }
            } else {
                mStatus = Status.Ready
                animateSpinnerToReady()
            }
        }
    }

    /**
     * 设置Spinner的位置
     */
    private fun setSpinnerOffsetTopAndBottom(offset: Int) {
        mSpinner.bringToFront()
        ViewCompat.offsetTopAndBottom(mSpinner, offset)
        mCurrentSpinnerOffsetTop = mSpinner.top
        if (!mIsSpinnerOver) {
            ViewCompat.offsetTopAndBottom(mTarget, offset)
        }
    }
}

再为下拉刷新添加合适的动画效果,比如说简单的平移:

    /**
     * 让Spinner带动画移动到准备位置
     */
    private fun animateSpinnerToReady() {
        mAnimateFrom = mCurrentSpinnerOffsetTop
        mAnimateToReady.reset()
        mAnimateToReady.duration = ANIMATE_DURATION.toLong()
        mAnimateToReady.interpolator = ANIMATE_INTERPOLATOR
        mSpinner.clearAnimation()
        mSpinner.startAnimation(mAnimateToReady)
    }

    //移动到准备位置的动画
    private val mAnimateToReady = object : Animation() {
        public override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
            val targetTop = mAnimateFrom + ((mOriginalSpinnerOffsetTop - mAnimateFrom) * interpolatedTime).toInt()
            val offset = targetTop - mCurrentSpinnerOffsetTop
            setSpinnerOffsetTopAndBottom(offset)
            updateProgress()
        }
    }

    /**
     * 让Spinner带动画移动到刷新位置
     */
    private fun animateSpinnerToRefresh() {
        mAnimateFrom = mCurrentSpinnerOffsetTop
        mAnimateToRefresh.reset()
        mAnimateToRefresh.duration = ANIMATE_DURATION.toLong()
        mAnimateToRefresh.interpolator = ANIMATE_INTERPOLATOR
        mSpinner.clearAnimation()
        mSpinner.startAnimation(mAnimateToRefresh)
    }

    //移动到刷新位置的动画
    private val mAnimateToRefresh = object : Animation() {
        public override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
            val endTarget = mOriginalSpinnerOffsetTop + mRefreshSlop
            val targetTop = mAnimateFrom + ((endTarget - mAnimateFrom) * interpolatedTime).toInt()
            val offset = targetTop - mCurrentSpinnerOffsetTop
            setSpinnerOffsetTopAndBottom(offset)
            updateProgress()
        }
    }

核心功能完成后,在加上一些对外暴露的接口就大功告成了!完整代码在这里,最终实现的效果是这样的:

后记

文章实现的下拉刷新控件CustomRefreshLayout的原理其实和官方提供的SwipeRefreshLayout很类似,区别只在于SwipeRefreshLayout还处理了Touch事件,而CustomRefreshLayout没有,所以CustomRefreshLayout只可以与NestedScrollView或者RecyclerView等实现了NestedScrolling的控件协同使用,有兴趣学习或者使用的同学可以根据需要再扩展一下。

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

推荐阅读更多精彩内容