一次滿足兩個需求——嵌套滑動&下拉刷新

前言

日常開發(fā)中,下拉刷新列表是一個常見的不能再常見的需求了,github上也有很多成熟了下拉刷新庫可供學(xué)習(xí),但如果要刷新的列表被ScrollView或者RecyclerView包圍,可能很多傳統(tǒng)的下拉刷新的實現(xiàn)就沒辦法使用了,那我們?nèi)绾卧谇短谆瑒又泻椭C共處地實現(xiàn)下拉刷新呢?

先看最終效果圖:


基礎(chǔ)知識——嵌套滾動

按照使用習(xí)慣,用戶會在手機屏幕上使用上下或者左右滑動手勢來滾動頁面或者列表,但如果在同一個界面有多個控件可以滑動時,如何協(xié)調(diào)多個控件響應(yīng)用戶操作是一個頗為復(fù)雜的問題。一般來說,我們可以在控件中實現(xiàn)dispatchTouchEvent(), onTouchEvent(), onInterceptTouchEvent()三連擊來控制滑動操作,但這個實現(xiàn)方式有個漏洞,如果Touch事件傳遞過程中,某個View獲得處理Touch事件機會,那么其他View就再也沒有機會去處理這個Touch事件了,直到下一次手指再按下,也就是說,這種方法無法讓多個View協(xié)同處理一個滑動事件。
這時候,Google霸霸就跳出來給我們指出了一條明路——NestedScrolling機制。NestedScrolling可以很好地解決嵌套控件中滑動事件的攔截、分發(fā)和使用的問題,使用NestedScrolling后的效果如下:



可以看到,頂部的AppBar和下面的ListView都是可以滑動的,使用了NestedScrolling后,只有ListView下滑到頂部時,AppBar才會響應(yīng)下滑事件。
NestedScrolling的想法并不復(fù)雜,它會把嵌套的控件分為父View和子View,控件接收到的每個滑動事件都分開幾個階段通知父View,父View決定事件的處理,并負(fù)責(zé)把處理完的事件分發(fā)給子View,層層傳遞下去,直到滑動事件消耗完:

場景一:

  • 子View:爸爸,我準(zhǔn)備在x軸方向滑動50px,有什么吩咐沒
  • 父View:好的,沒什么吩咐的,你滑吧。
  • 子View:遵命!滑動ing...... 爸爸,我滑完了,總共滑了50px。
  • 父View:好的,記得每次都要提前匯報!

場景二:

  • 子View:爸爸,我準(zhǔn)備在x軸方向滑動50px,有什么吩咐沒
  • 父View:你x軸的50px我要全部沒收,你別動了
  • 子View:納尼 w(?Д?)w 好吧誰讓你是爸爸...

具體到代碼實現(xiàn),Google提供了NestedScrollingParent和NestedScrollingChild兩個接口,如果自定義的View要當(dāng)父親來嵌套子View,那么請實現(xiàn)NestedScrollingParent,如果要當(dāng)兒子被父View包含,請實現(xiàn)NestedScrollingChild,大多數(shù)情況下,我們自定義的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霸霸怕我們處理不好這些事件的分發(fā),還貼心的給我們提供了兩個對應(yīng)的輔助類:NestedScrollingParentHelper和NestedScrollingChildHelper,如果要寫一個簡單的NestedScrolling對象,只需要調(diào)用NestedScrollingHelper對應(yīng)的方法就可以了,例如:

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)
    }
}

滑動事件傳遞簡要流程圖如下(不包含F(xiàn)ling事件):


關(guān)于NestedScrollingParent和NestedScrollingChild方法參數(shù)和返回值的詳細(xì)說明可以參考這里,接下來會結(jié)合下拉刷新的需求來實現(xiàn)具體代碼。
Lollipop及以上版本的所有View都已經(jīng)支持了NestedScroll機制,Lollipop之前版本可以通過Support包進行向前兼容。

此外,26.0.0中NestedScroll得到了加強,對嵌套滾動的API做了些改進,出現(xiàn)了新的接口NestedScrollingParent2和NestedScrollingChild2。新的接口在部分方法之上添加了一個新的參數(shù) type ,type參數(shù)告訴你是什么類型的輸入在驅(qū)動scroll事件,目前可以是這兩種選項之一:ViewCompat.TYPE_TOUCH 和ViewCompat.TYPE_NON_TOUCH。詳細(xì)可以參考這里,下文將使用新的API。

實現(xiàn)需求——下拉刷新

要在NestScrolling的基礎(chǔ)上實現(xiàn)下拉刷新功能,我們首先定義一個繼承ViewGroup的CustomRefreshLayout控件,并重寫其中的onMeasure和onLayout方法,實現(xiàn)對滑動控件和刷新控件的寬高測量和具體布局工作:
注:下面的代碼使用了Kotlin語言編寫,語法不復(fù)雜不會影響閱讀體驗,了解更多關(guān)于Kotlin的內(nèi)容可以看我這篇文章

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后,我們可以不用關(guān)注觸摸事件,只需要處理滑動事件中,所以讓CustomRefreshLayout實現(xiàn)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)
        // 分發(fā)事件給Nested Parent
        startNestedScroll(axes and ViewCompat.SCROLL_AXIS_VERTICAL, type)
        // 重置計數(shù)
        mTotalUnconsumed = 0f
        mStatus = Status.Pull
    }

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

        // 如果在下拉過程中,直接響應(yīng)并消耗上滑距離,調(diào)整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)
        // 如果有處理過滑動事件,執(zhí)行滑動停止后的操作
        if (mTotalUnconsumed > 0) {
            finishSpinner(mTotalUnconsumed)
            mTotalUnconsumed = 0f
        }
        // 分發(fā)事件給Nested Parent
        stopNestedScroll(type)
    }

    override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
                                dxUnconsumed: Int, dyUnconsumed: Int, type: Int) {
        // 首先分發(fā)事件給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()
        }
    }

    /**
     * 設(shè)置刷新狀態(tài)
     * @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()
            }
        }
    }

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

再為下拉刷新添加合適的動畫效果,比如說簡單的平移:

    /**
     * 讓Spinner帶動畫移動到準(zhǔn)備位置
     */
    private fun animateSpinnerToReady() {
        mAnimateFrom = mCurrentSpinnerOffsetTop
        mAnimateToReady.reset()
        mAnimateToReady.duration = ANIMATE_DURATION.toLong()
        mAnimateToReady.interpolator = ANIMATE_INTERPOLATOR
        mSpinner.clearAnimation()
        mSpinner.startAnimation(mAnimateToReady)
    }

    //移動到準(zhǔn)備位置的動畫
    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()
        }
    }

核心功能完成后,在加上一些對外暴露的接口就大功告成了!完整代碼在這里,最終實現(xiàn)的效果是這樣的:

后記

文章實現(xiàn)的下拉刷新控件CustomRefreshLayout的原理其實和官方提供的SwipeRefreshLayout很類似,區(qū)別只在于SwipeRefreshLayout還處理了Touch事件,而CustomRefreshLayout沒有,所以CustomRefreshLayout只可以與NestedScrollView或者RecyclerView等實現(xiàn)了NestedScrolling的控件協(xié)同使用,有興趣學(xué)習(xí)或者使用的同學(xué)可以根據(jù)需要再擴展一下。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容