ViewPager2:ViewPager都能自動巢狀滾動了,我不行?我麻了!該怎麼做?

語言: CN / TW / HK

theme: smartblue highlight: agate


持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第6天,點選檢視活動詳情

記錄ViewPager與ViewPager2巢狀的問題解決

前言

事情是這樣的,今天玩WY雲音樂,發現他們的ViewPager非常的順滑,多層巢狀下面的ViewPager都能順滑的滑動。當時就有一個思考,如果使用ViewPager2巢狀實現會不會有不同呢?

之前也看評論區有到說ViewPager2的巢狀滾動問題,然後我這裡實驗一下ViewPager多層巢狀下的滾動問題。

記錄與測試一下 ViewPager 與 ViewPager2 的巢狀不同點。不同的ViewPager巢狀的不同點,ViewPager巢狀ViewPager2,與ViewPager2巢狀ViewPager有什麼不同。

那我們直接開始吧。

ViewPager的巢狀滾動

從小爸媽就對我講,黃梅戲可... 哎呀,什麼鬼,串戲了😅 😂

不是,是學Android開始,老師就跟我們講,ViewPager 巢狀 ViewPager ,我們要處理事件衝突的,很麻煩,我們需要自定義ViewPager自己處理,然後我們網上找的自定義ViewPager大致是這樣:

```java public class MyViewPager extends ViewPager {

int lastX = -1;
int lastY = -1;

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

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

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    int x = (int) ev.getRawX();
    int y = (int) ev.getRawY();
    int dealtX = 0;
    int dealtY = 0;

    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            dealtX = 0;
            dealtY = 0;
            // 保證子View能夠接收到Action_move事件
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            dealtX += Math.abs(x - lastX);
            dealtY += Math.abs(y - lastY);

            // 這裡是否攔截的判斷依據是左右滑動,讀者可根據自己的邏輯進行是否攔截
            if (dealtX >= dealtY) { // 左右滑動請求父 View 不要攔截
                getParent().requestDisallowInterceptTouchEvent(true);
            } else {
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            lastX = x;
            lastY = y;
            break;
        case MotionEvent.ACTION_CANCEL:
            break;
        case MotionEvent.ACTION_UP:
            break;

    }
    return super.dispatchTouchEvent(ev);
}

} ```

ViewPager的巢狀滾動處理,實際上就是看子View是不是能滾動,如果子View需要滾動,就讓父容器不要攔截,否則就讓父容器攔截,

為了對比效果,我們先不用自定義ViewPager,我們先使用預設的ViewPager來實現對比一下:

xml <androidx.viewpager.widget.ViewPager android:id="@+id/viewpager" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"/>

先設定一個ViewPager

```kotlin findViewById(R.id.viewpager).apply {

    bindFragment(
        supportFragmentManager,
        listOf(VPItemFragment(Color.RED), VPItemFragment(Color.GREEN), VPItemFragment(Color.BLUE)),
        behavior = 1
        )
}

```

擴充套件方法如下: kotlin fun ViewPager.bindFragment( fm: FragmentManager, fragments: List<Fragment>, pageTitles: List<String>? = null, behavior: Int = 0 ): ViewPager { offscreenPageLimit = fragments.size - 1 adapter = object : FragmentStatePagerAdapter(fm, behavior) { override fun getItem(p: Int) = fragments[p] override fun getCount() = fragments.size override fun getPageTitle(p: Int) = if (pageTitles == null) null else pageTitles[p] } return this }

那麼使用就是這樣:

vp_01.gif

如果想要巢狀滾動就在 VPItemFragment 中設定一個子ViewPager容器。

```kotlin view.findViewById(R.id.viewPager).apply {

        val fragmentList = listOf(VPItemChildFragment(0), VPItemChildFragment(1), VPItemChildFragment(2))
        bindFragment(
            childFragmentManager,
            fragmentList
           behavior = 1
        )

} ```

由於我們並沒有使用自定義ViewPager,這樣實現預設的ViewPager會有巢狀效果嗎?,看看效果

vp_02.gif

啊?這?能嵌套了?預設的ViewPager就支援嵌套了?

是的,對於ViewPager巢狀問題,之前的老版本確實是需要自定義處理攔截事件,但是新版本的ViewPager已經幫我們處理了巢狀效果。

image.png

image.png

原始碼中對 onTouchEvent 和 onInterceptTouchEvent 已經處理了事件的攔截,預設就支援巢狀滾動了。所以之前的自定義 MyViewPager 目前來說沒什麼用。

ViewPager2的巢狀滾動

既然ViewPager預設就支援巢狀滾動了,那麼我想ViewPager2肯定也行,畢竟它是ViewPager的升級版本嘛。

簡單的試試?

我們把ViewPager換成ViewPager2,然後繫結到Fragment物件。

```kotlin override fun init() {

    findViewById<ViewPager2>(R.id.viewpager2).apply {

        bindFragment(
            supportFragmentManager,
            lifecycle,
            listOf(VPItemFragment(Color.RED), VPItemFragment(Color.GREEN), VPItemFragment(Color.BLUE)),
        )

        orientation = ViewPager2.ORIENTATION_HORIZONTAL
    }

}

```

子Fragment內部再使用ViewPager2巢狀,區別於ViewPager,我把ViewPager2的背景設定為灰色。

```kotlin view.findViewById(R.id.viewPager2).apply {

        val fragmentList = listOf(VPItemChildFragment(0), VPItemChildFragment(1), VPItemChildFragment(2))
        bindFragment(
            childFragmentManager,
            lifecycle,
            fragmentList
        )

        orientation = ViewPager2.ORIENTATION_HORIZONTAL

```

ViewPager都行,沒道理我不行,來執行試試

vp_04.gif

這...怎麼又不行了? 現實連著扇了我兩巴掌。那ViewPager2內部肯定沒有寫巢狀的相容程式碼。

由於ViewPager2內部是RV實現的,這裡只重新了RV的攔截事件

image.png

並沒有巢狀滾動的邏輯,那我知道了,我們像ViewPager一樣,繼承自 ViewPager2 然後重寫巢狀滾動的邏輯不就行了嗎!還真不行, ViewPager2 是finnal的無法繼承,那怎麼辦?

其實我們的目的就是巢狀的父容器的事件需要傳遞到子容器中,既然我們不能直接繼承修改,那麼我們可以加一箇中間層,使用一個ViewGroup包裹我們巢狀的容器,在中間層中判斷是否需要傳遞和攔截事件。

谷歌已經給出了推薦的程式碼

```kotlin /* * Layout to wrap a scrollable component inside a ViewPager2. Provided as a solution to the problem * where pages of ViewPager2 have nested scrollable elements that scroll in the same direction as * ViewPager2. The scrollable element needs to be the immediate and only child of this host layout. * * This solution has limitations when using multiple levels of nested scrollable elements * (e.g. a horizontal RecyclerView in a vertical RecyclerView in a horizontal ViewPager2). /

class NestedScrollableHost : FrameLayout {

constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

private var touchSlop = 0
private var initialX = 0f
private var initialY = 0f

private val parentViewPager: ViewPager2?
    get() {
        var v: View? = parent as? View
        while (v != null && v !is ViewPager2) {
            v = v.parent as? View
        }
        return v as? ViewPager2
    }

private val child: View? get() = if (childCount > 0) getChildAt(0) else null

init {
    touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}

private fun canChildScroll(orientation: Int, delta: Float): Boolean {
    val direction = -delta.sign.toInt()
    return when (orientation) {
        0 -> child?.canScrollHorizontally(direction) ?: false
        1 -> child?.canScrollVertically(direction) ?: false
        else -> throw IllegalArgumentException()
    }
}

override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
    handleInterceptTouchEvent(e)
    return super.onInterceptTouchEvent(e)
}

private fun handleInterceptTouchEvent(e: MotionEvent) {
    val orientation = parentViewPager?.orientation ?: return
    // Early return if child can't scroll in same direction as parent
    if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
        return
    }

    if (e.action == MotionEvent.ACTION_DOWN) {
        initialX = e.x
        initialY = e.y
        parent.requestDisallowInterceptTouchEvent(true)
    } else if (e.action == MotionEvent.ACTION_MOVE) {
        val dx = e.x - initialX
        val dy = e.y - initialY
        val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL
        // assuming ViewPager2 touch-slop is 2x touch-slop of child
        val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
        val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f

        if (scaledDx > touchSlop || scaledDy > touchSlop) {

            if (isVpHorizontal == (scaledDy > scaledDx)) {
                // Gesture is perpendicular, allow all parents to intercept
                parent.requestDisallowInterceptTouchEvent(false)
            } else {
                // Gesture is parallel, query child if movement in that direction is possible
                if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
                    // Child can scroll, disallow all parents to intercept
                    parent.requestDisallowInterceptTouchEvent(true)
                } else {
                    // Child cannot scroll, allow all parents to intercept
                    parent.requestDisallowInterceptTouchEvent(false)
                }
            }
        }
    }
}

}

```

我們使用NestedScrollableHost把內部ViewPager2包裹起來就可以了,在onInterceptTouchEvent函式中,如果接受到了DOWN事件,就需要呼叫requestDisallowInterceptTouchEvent通知外層的ViewPager2不要攔截事件,讓我們的Host來處理滑動事件。

當Move事件觸發的時候,判斷一下內部的ViewPager2是否能滑動?不能滑動就通知父佈局要攔截事件。

使用,我們巢狀內部的ViewPager之後,在執行程式碼試試 ```xml

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/viewPager2"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</com.guadou.kt_demo.demo.demo16_record.viewpager.NestedScrollableHost>

```

其他的程式碼沒變化,執行效果:

vp_03.gif

這樣就能達到和ViewPager一樣的效果了。

這個思路也是很妙,在兩者中間一箇中間層,事件還不是我們自己說了算,想什麼時候傳遞就什麼時候傳遞,想哪個方向傳遞就哪個方向傳遞,可以請求父類不要攔截,也可以自己不往下分發,非常的靈活。

那麼不僅僅是這一個ViewPager2的巢狀場景,其實ViewPager2巢狀RV,甚至ScrollView巢狀RV等場景都可以靈活的應用。

ViewPager與ViewPager2的相互巢狀

好的到此我們就結束了,什麼? 你要 ViewPager 巢狀 ViewPager2 ?真會玩。

其實和ViewPager2巢狀ViewPager2一樣的道理,加Host中間層。即可

xml <androidx.viewpager.widget.ViewPager android:id="@+id/viewpager" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"/>

```xml

<com.guadou.kt_demo.demo.demo16_record.viewpager.NestedScrollableHost
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/viewPager2"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</com.guadou.kt_demo.demo.demo16_record.viewpager.NestedScrollableHost>

```

ViewPager巢狀ViewPager2是一樣的效果:

vp_05.gif

那麼 ViewPager2 巢狀 ViewPager 呢?

這種情況下如果我們不加中間層,和ViewPager2的巢狀是一樣的,父佈局直接無法分發事件下來。

vp_06.gif

所以我們還是需要加上Host中間層,幫助我們分發事件

```xml

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</com.guadou.kt_demo.demo.demo16_record.viewpager.NestedScrollableHost>

```

加上中間層就好了嗎,這個嘛其實情況就不同了,之前的情況下是 ViewPager 是父佈局,那麼我們請求父佈局不要攔截,他就自己處理了,但是當我們的父佈局是 ViewPager2 的情況下,我們請求他不要攔截,其實執行的邏輯是一致的,但是 ViewPager2 確是不會接受到事件自己動起來。

vp_07.gif

其實效果就是請求攔截但是沒有攔截到,還是子View在響應Touch事件,此時我們需要在中間層自己處理事件的攔截。關於View事件的分發,我想大家應該都比我我強,我就不獻醜了。

我們如果想要ViewPager2為父佈局,在請求攔截的時候可以自動滾動,我們直接修改中間層的 dispathTouchEvent 和 onInterceptTouchEvent 方法都是可以的,由於之前的程式碼是處理的 onInterceptTouchEvent 方法,所以我們還是在這個基礎上修改,如果子View不能滾動了,那麼我們中間層不往下分發事件即可,此時事件會傳遞到 ViewPager2 的 OnTouchEvent 中即可自動滾動了。

主要修改方法如下:

```kotlin private fun handleInterceptTouchEvent(e: MotionEvent): Boolean {

    val orientation = parentViewPager?.orientation ?: return false

    if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
        return false
    }

    if (e.action == MotionEvent.ACTION_DOWN) {
        initialX = e.x
        initialY = e.y
        parent.requestDisallowInterceptTouchEvent(true)

    } else if (e.action == MotionEvent.ACTION_MOVE) {

        val dx = e.x - initialX
        val dy = e.y - initialY
        val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL

        val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
        val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f

        if (scaledDx > touchSlop || scaledDy > touchSlop) {
            return if (isVpHorizontal == (scaledDy > scaledDx)) {
                //垂直的手勢攔截
                parent.requestDisallowInterceptTouchEvent(false)
                true
            } else {

                if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
                    //子View能滾動,不攔截事件
                    parent.requestDisallowInterceptTouchEvent(true)
                    false
                } else {
                    //子View不能滾動,直接就攔截事件
                    parent.requestDisallowInterceptTouchEvent(false)
                    true
                }
            }
        }
    }

    return false
}

```

修改之後的效果如下:

vp_08.gif

雖然效果都能實現,但是我不相信真有兄弟在實戰中會這麼巢狀吧 😂 😂,搞這些騷操作還是為了更深入的理解和學習。

總結

新版本的ViewPager可以很方便的自帶巢狀效果,我們使用起來確實很方便,ViewPager2也可以通過加一個Host中間層來實現同樣的效果。包括Host在其他巢狀的場景下的使用,這個思路很重要。

但是ViewPager只能固定方向,而ViewPager2通過RV實現更加的靈活,不僅可以自定義方向,還能自適應高度,這一點特性在一些特定的場景下面就很方便。

一般普通的場景我們可以使用ViewPager快速實現即可,一些特殊的效果我們可以通過ViewPager2來實現!如果想要巢狀效果我們也可以通過Host中間層來解決事件傳遞問題。

好了本文的全部程式碼與Demo都已經開源。有興趣可以看這裡,可供大家參考學習。

慣例,我如有講解不到位或錯漏的地方,希望同學們可以指出交流。

如果感覺本文對你有一點點的啟發,還望你能點贊支援一下,你的支援是我最大的動力。

Ok,這一期就此完結。

「其他文章」