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,这一期就此完结。

「其他文章」