仿京東、淘寶首頁,通過兩層巢狀的RecyclerView實現tab的吸頂效果

JasonGaoH發表於2019-08-23

為什麼會有這篇文章

之前寫過一篇文章使用CoordinatorLayout過程中遇到的兩個問題以及淺析CoordinatorLayout工作機制,這篇文章上主要講了通過CoordinatorLayout實現tab吸頂的效果時遇到的問題,效果跟京東、淘寶首頁類似,只不過實現方法不同而已,但是使用CoordinatorLayout來實現是會有不少細節問題是很難處理好的,下面會詳細介紹。

首先我們可以簡單看下京東首頁的效果gif,來看看我們到底是要實現什麼樣的效果:

image

京東首頁的tab篩選區將feed分為兩個部分,上面是各種不同item,tab的下半部分可以左右橫滑,並且下拉可以加重更多,只要網路有資料的情況下理論上是可以無限下拉的。

其實用CoordinatorLayout來實現tab吸頂,如果能將一些細節問題處理好的話,其實大致可以實現類似京東首頁的這個效果,具體細節問題可以參考文章開頭說的之前的文章,文章裡講了下使用CoordinatorLayout來實現類似效果遇到的動畫抖動問題以及頁面回彈問題以及對應的解決方法。

那麼為什麼會不採用CoordinatorLayout來實現,轉而採用巢狀RecyclerView的方式呢?

首先我們來看下CoordinatorLayout實現的大致佈局:

image

一個問題是從AppBarLayout滑動效果是不能傳遞到下面的ViewPager裡去的,我嘗試了各種方式都沒能解決掉這個問題,可以簡單看下Demo效果圖:

image

從gif圖大致可以看到AppBarLayout滑上去之後慣性消失了,tab下面的區域是不能接著滾動的。

這個慣性消失的問題,我在網上找到了一個一篇解決慣性消失的文章如下支付寶首頁互動三部曲3實現支付寶首頁互動,實現方式大致是自己把CoordinatorLayout這套機制再實現了一遍,因為是自己實現的,裡面的一些機制是比較方便改動的,它處理慣性這個問題的邏輯大致是將AppBarLayout中未消費的y軸偏移量拿出來再交由RecyclerView去滑動,程式碼如下:

mHeaderView.setOnHeaderFlingUnConsumedListener(new APHeaderView.OnHeaderFlingUnConsumedListener() {
    @Override
    public int onFlingUnConsumed(APHeaderView header, int targetOffset, int unconsumed) {
        APHeaderView.Behavior behavior = mHeaderView.getBehavior();
        int dy = -unconsumed;
        if (behavior != null) {
            mRecyclerView.scrollBy(0, dy);
        }
        return dy;
    }
});
複製程式碼

這裡由於篇幅原因,就不展開詳細介紹了,感興趣地同學可以點開上面的連結去研究研究,文章中也貼出了GitHub地址。

我們重新回到我們一開始的問題,為什麼想替換掉CoordinatorLayout,另外一個問題是CoordinatorLayout這種實現相對比較簡單,但是會導致頁面的巢狀層級很深,我們從上面貼出來的佈局來看,view巢狀的層級特別深,而且如果我們要實現類似京東或者淘寶首頁這樣的效果,在TabLayout上面的區域,也就是下圖箭頭標註的地方必須要採用RecyclerView的來實現,因為tab上半部分的內容和個數都是不確定的,使用RecyclerView才比較方便,但是這樣頁面的層級就更深了,載入速度也變得更慢了。

image

如何實現

要拋棄CoordinatorLayout,那麼如何實現呢?

我們用Android Studio中的Layout Inspector工具看了下京東首頁的佈局,發現的確是採用兩層RecyclerView巢狀來實現的,展示的佈局大致如下所示:

image

那麼接下來是怎麼去實現這個效果了。其實一開始我以為要採用巢狀滾動這套機制來實現,後來發現不採用巢狀滾動機制也是可以實現的。

現在我們可以大致構造這樣一個佈局:

image

我們把ViewPager以及TabLayout這一塊作為外部RecyclerView的一個item,ViewPager可能會有個多個內部RecyclerView,只要我們能讓外部RecyclerView和內部RecyclerView的滑動事件正確分發基本就可以解決這個問題了。

如果只是構造出這個佈局出來,我們發現內部的RecyclerView都不會顯示出來,因為滑動完全由外部RecyclerView接管了。

那麼重點來了,這種情況如何處理?

其實RecyclerView的LayoutManager中有這兩個方法用於判斷RecyclerView在水平方向上和豎直方向上是否可以滾動的。

public boolean canScrollHorizontally() {
    return false;
}

public boolean canScrollVertically() {
    return false;
}
複製程式碼

然後LayoutManager有各種不同的實現LinearLayoutManager,StaggeredGridLayoutManager等

這個LayoutManager中的canScrollVertically和canScrollHorizontally在RecyclerView的onInterceptTouchEvent中是會拿來作判斷,判斷當前RecyclerView是否需要處理滑動事件的。

還有一點需要注意:我們處理內外兩層RecyclerView的滑動衝突問題,主要是想解決下面兩種場景:一、手指上滑時,當外部RecyclerView滑動底部的時候,內部的RecyclerView能繼續去響應使用者的滑動,因為內部的RecyclerView理論上是可以無限滾動的;二、手指下滑時,當內部的RecyclerView滑動到頂部的時候,外部的RecyclerView能夠繼續響應使用者的下滑事件。

其實上面已經說出瞭如何處理巢狀RecyclerView的最重要的點,其他的部分相當於都是一些細節的處理了。

在外部RecyclerView(下面成ParentRecyclerView)中的重寫LayoutManager的canScrollVertically方法如下:

fun initLayoutManager() {
    val linearLayoutManager = object :LinearLayoutManager(context) {

        override fun canScrollVertically():Boolean {
            //找到當前的childRecyclerView
            val childRecyclerView = findNestedScrollingChildRecyclerView()
            //只有當前childRecyclerView滑動到頂部才認為ParentRecyclerView是可以豎直方向是可以滾動的
            return childRecyclerView == null || childRecyclerView.isScrollTop()
        }

    }
    linearLayoutManager.orientation = LinearLayoutManager.VERTICAL
    layoutManager = linearLayoutManager
}
複製程式碼

在內部RecyclerView(以下稱ChildRecyclerView)中定義了isScrollTop(),用於判斷ChildRecyclerView是否滾動到頂部。

fun isScrollTop(): Boolean {
    //RecyclerView.canScrollVertically(-1)的值表示是否能向下滾動,false表示已經滾動到頂部
    return !canScrollVertically(-1)
}
複製程式碼

另外,在ParentRecyclerView的onTouchEvent方法中:

override fun onTouchEvent(e: MotionEvent): Boolean {
        if(lastY == 0f) {
            lastY = e.y
        }
        if(isScrollEnd()) {
            //如果父RecyclerView已經滑動到底部,需要讓子RecyclerView滑動剩餘的距離
            val childRecyclerView = findNestedScrollingChildRecyclerView()
            childRecyclerView?.run {
                val deltaY = (lastY - e.y).toInt()
                if(deltaY != 0) {
                    scrollBy(0,deltaY)
                }
            }
        }
        lastY = e.y
        return try {
            super.onTouchEvent(e)
        } catch (e: Exception) {
            e.printStackTrace()
            false
        }
    }
複製程式碼

關於滑動事件主要程式碼就是上面這些,具體可以可以看看專案程式碼NestedRecyclerView

還有關於RecyclerView的fling部分,在RecyclerView的onScrollStateChanged回撥中監聽y軸的總的偏移量totalDy,然後在RecyclerView不滾動的時候交由內部或者外部RecyclerView去fling,這裡就不贅述了,具體可以看專案的程式碼。

最後貼上專案的執行gif圖展示:

image

image

最後

寫作不易,歡迎大家點贊,如果有問題,歡迎提出一起討論,您的點贊是我寫作的最大動力,感謝!

相關文章