ViewPager2 嵌套后的滑动冲突解决方案 | 官方认证

bf7c10418c362ee1adf7cf926aeb42b4.png

我赌一包辣条 | 作者

承香墨影(ID:cxmyDev)| 校对

https://juejin.cn/post/6911456860533063688 | 原文

Hi,大家好,这里是承香墨影!

ViewPager2 正式版已经发布,已经逐渐开始替代旧版本的 ViewPager。许多开发者也已经在项目中使用了 ViewPager2。

相比 ViewPager,ViewPager2 的功能不可谓不强大,昨天分享的文章《学不动也要学!深入了解 ViewPager2 》中,对 ViewPager2 的使用做过详细的讲解。

但是,由于当时没有太多实战,所以并没有发现 ViewPager2 的嵌套使用,存在严重的滑动冲突。直到今年用 ViewPager2 重构 BannerViewPager 的时候才发现这个问题。

因此,在 BVP 3.0 版本中额外对 ViewPager2 做了滑动冲突处理,效果还算差强人意。另外,曾在论坛上看到过不少 ViewPager2 滑动冲突的求助帖子,甚至还有同学因为搜索 ViewPager2 滑动冲突而找到了 BannerViewPager的 Github 主页。既然如此,不如写篇文章将 BVP 处理滑动冲突的经验分享给大家,没准还能涨知(fěn)识(sī),嘿嘿嘿。

  • BannerViewPager
    https://github.com/zhpanvip/BannerViewPager

dc453a99a9edd709ffea67578755d9a2.jpeg

一、为什么 ViewPager 没有滑动冲突?

不知道你是否有这个疑问,在 ViewPager 时代,ViewPager 嵌套 ViewPager 并没有出现过滑动冲突。可是为什么在 ViewPager 的升级版 ViewPager2 中却出现了滑动冲突呢?

想要搞清楚这个问题,就需要我们深入到 ViewPager 和 ViewPager2 的内部,分析一下它们的源码了。

我们知道,滑动冲突是需要在 onInterceptTouchEvent() 方法中进行处理的,根据自身条件,来决定是否要拦截事件。在 ViewPager 的源码中看到以下代码 (为方便阅读,代码做了删减)。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {

  final int action = ev.getAction() & MotionEvent.ACTION_MASK;
  if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
    // 在事件取消或者抬起手指后重置状态
      resetTouch();
      return false;
  }


  switch (action) {
    case MotionEvent.ACTION_MOVE: {
      // 这里判断在水平方向上的滑动距离大于竖直方向的2倍,则认为是有效的切换页面的滑动
      if (xDiff > mTouchSlop && xDiff * 0.5f > yDiff) { 
        mIsBeingDragged = true;
        // 禁止Parent View拦截事件,即事件要能够传递到ViewPager
        requestParentDisallowInterceptTouchEvent(true);
        setScrollState(SCROLL_STATE_DRAGGING);
      } else if (yDiff > mTouchSlop) {
        mIsUnableToDrag = true;
      }
      break;
    }

    case MotionEvent.ACTION_DOWN: {     
      if (mScrollState == SCROLL_STATE_SETTLING
              && Math.abs(mScroller.getFinalX() - mScroller.getCurrX()) > mCloseEnough) {
        // 在Down事件中禁止Parent View拦截事件,是为了事件序列能够传递到ViewPager
        requestParentDisallowInterceptTouchEvent(true);
        setScrollState(SCROLL_STATE_DRAGGING);
      } else {
        completeScroll(false);
        mIsBeingDragged = false;
      }
      break;
    }

    case MotionEvent.ACTION_POINTER_UP:
      onSecondaryPointerUp(ev);
      break;
  }
  return mIsBeingDragged;
}

可以看到,在 ACTION_DOWN 与 ACTION_MOVE 中根据一些判断条件,调用了 requestParentDisallowInterceptTouchEvent(true) 方法来禁止 Parent View 拦截事件。也就是说,ViewPager 已经帮我们处理了滑动冲突,所以我们只管用即可,无需担心滑动冲突问题。

现在,我们转到 ViewPager2 中,翻阅源码发现,只有在 RecyclerView 的实现类中有 onInterceptTouchEvent() 的相关方法,而且这句代码仅仅是处理禁用了用户输入的逻辑!

private class RecyclerViewImpl extends RecyclerView {
  .... // 省略部分代码
  @Override
  public boolean onInterceptTouchEvent(MotionEvent ev) {
    return isUserInputEnabled() && super.onInterceptTouchEvent(ev);
  }
}

也就是说,ViewPager2 其实并没有帮我们处理滑动冲突!

这是为什么呢?难道是 ViewPager2 的开发者们把这件事忘了?这里我敢保证肯定不是这样子的。

其实,只要我们看一看 ViewPager2 的结构大概就能知道了。

ViewPager2 被声明了 final,意味着我们不能像继承 ViewPager 一样,来修改 ViewPager2。如果官方在 ViewPager2 内部自行处理了滑动冲突,那么如果有特殊的需求,需要根据我们自己的情况,来处理 ViewPager2 的滑动,那么官方写的处理滑动冲突的代码,是不是会影响到我们自己的需求?

所以我觉得也正因为这样,干脆不做任何处理,全权交给了开发者自行解决。

二、滑动冲突的处理方案

既然官方不给我们处理,那就需要我们自己动手了。在开始之前,我们先来了解一下处理滑动冲突的两种方案。既然出现滑动冲突,那么一定是由于两个布局相互嵌套引起的。既然是两个布局,那么我们就可以分为两个方向来处理。即所谓的外部拦截法内部拦截法

2.1 外部拦截法

所谓的「外部拦截法」中的外部,是指出现滑动冲突的这两个布局的外层。

我们知道,一个事件序列是由 Parent View 先获取到的,如果 Parent View 不拦截事件那么才会交由子 View 去处理。既然是外层先获知事件,那外层 View 根据自身情况,来决定是否要拦截事件不就行了吗?

因此外部拦截法的实现是非常简单的,大概思路如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
  boolean intercepted = false;
  int x = (int) event.getX();
  int y = (int) event.getY();
  switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
      intercepted = false;
      break;
    }
    case MotionEvent.ACTION_MOVE: {
      if (needIntercept) { // 这里根据需求判断是否需要拦截
          intercepted = true;
      } else {
          intercepted = false;
      }
      break;
    }
    case MotionEvent.ACTION_UP: {
      intercepted = false;
      break;
    }
    default:
      break;
  }
  mLastXIntercept = x;
  mLastYIntercept = y;
  return intercepted;
}

2.2 内部拦截法

所谓的「内部拦截法」指的是对内部的 View 做文章,让内部 View 决定是不是拦截事件。

但是现在就有问题了,你怎么知道外部的 View 是不是要拦截事件啊??如果外部 View 把事件拦截了,内部的 View 岂不是连西北风都喝不到了?

别着急,Google 官方当然有考虑到这种情况。在 ViewGroup 中有一个叫 requestDisallowInterceptTouchEvent() 的方法,这个方法接受一个 boolean 值,意思是是否要禁止 ViewGroup 拦截当前事件。

墨影说:我们知道事件,是由一组事件序列组成,子 View 可以调用 requestDisallowInterceptTouchEvent() 的前提,是它接收到事件,即它只能让父 View 不拦截除 ACTION_DOWN 之外的事件。

如果是 true 的话,那么该 ViewGroup 则无法对事件进行拦截。有了这个方法那我们就可以让内部 View 大显神通了。来看下内部拦截法的代码:

public boolean dispatchTouchEvent(MotionEvent event) {
  int x = (int) event.getX();
  int y = (int) event.getY();

  switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
      // 禁止parent拦截down事件
      parent.requestDisallowInterceptTouchEvent(true);
      break;
    }
    case MotionEvent.ACTION_MOVE: {
      int deltaX = x - mLastX;
      int deltaY = y - mLastY;
      if (disallowParentInterceptTouchEvent) { 
        // 根据需求条件来决定是否让Parent View拦截事件。
        parent.requestDisallowInterceptTouchEvent(false);
      }
      break;
    }
    case MotionEvent.ACTION_UP: {
      break;
    }
    default:
      break;
  }

  mLastX = x;
  mLastY = y;
  return super.dispatchTouchEvent(event);
}

这么处理之后,两个嵌套 View 就可以和谐工作了。

下面是来自外部 View 和内部 View 的对话。

外部 View:"我想拦截事件!"

内部 View:"不,你不想。这事件我要定了,耶稣都留不住他。"

da28ffec52a6898b32fd1b93bed39e4f.jpeg

三、处理 ViewPager2 的滑动冲突

上一章讲了滑动冲突处理的两种方案,那么本章我们就来解决 ViewPager2 的滑动冲突。首先,应该确定一下存在在哪些需要拦截和不需要拦截的边界条件。在写这篇文章之前,我 Google 搜索了一下 ViewPager2 的滑动冲突处理方案,关于这方面的文章还不算少,不过大部分的文章对于 ViewPager2 的滑动冲突处理考虑的都不够完善。

下面我们详细来分析一下:

  • 如果设置了 userInputEnable=false, 那么 ViewPager2 不应该拦截任何事件;

  • 如果只有一个 Item,那么 ViewPager2 也不应该拦截事件;

  • 如果是多个 Item,且当前是第一个页面,那么只能拦截向左的滑动事件,向右的滑动事件就不应该由 ViewPager2 拦截了;

  • 如果是多个 Item,且当前是最后一个页面,那么只能拦截向右的滑动事件,向左的滑动事件不应该由当前的 ViewPager2 拦截;

  • 如果是多个 Item,且是中间页面,那么无论向左还是向右的事件都应该由 ViewPager2 拦截;

  • 最后,由于 ViewPager2 是支持竖直滑动的,那么竖直滑动也应该考虑以上条件。

分析完了边界条件之后,我们看下应该使用哪种方案来处理滑动冲突?很明显,这里应该使用内部拦截法处理。

但是,由于 ViewPager2 被设置成了 final,我们无法通过继承的方式来处理,因此就需要我们在 ViewPager2 外部加一层自定义的 Layout。这层 Layout 其实相当于夹在了内层 View 和外层 View 的中间,其实就是这层 Layout 就变成了内层。

好了,废话不多说了,直接贴代码了。

class ViewPager2Container @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : RelativeLayout(context, attrs, defStyleAttr) {

  private var mViewPager2: ViewPager2? = null
  private var disallowParentInterceptDownEvent = true
  private var startX = 0
  private var startY = 0

  override fun onFinishInflate() {
    super.onFinishInflate()
    for (i in 0 until childCount) {
      val childView = getChildAt(i)
      if (childView is ViewPager2) {
        mViewPager2 = childView
        break
      }
    }
    if (mViewPager2 == null) {
      throw IllegalStateException("The root child of ViewPager2Container must contains a ViewPager2")
    }
  }

  override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    val doNotNeedIntercept = (!mViewPager2!!.isUserInputEnabled
            || (mViewPager2?.adapter != null
            && mViewPager2?.adapter!!.itemCount <= 1))
    if (doNotNeedIntercept) {
      return super.onInterceptTouchEvent(ev)
    }
    when (ev.action) {
      MotionEvent.ACTION_DOWN -> {
        startX = ev.x.toInt()
        startY = ev.y.toInt()
        parent.requestDisallowInterceptTouchEvent(!disallowParentInterceptDownEvent)
      }
      MotionEvent.ACTION_MOVE -> {
        val endX = ev.x.toInt()
        val endY = ev.y.toInt()
        val disX = abs(endX - startX)
        val disY = abs(endY - startY)
        if (mViewPager2!!.orientation == ViewPager2.ORIENTATION_VERTICAL) {
          onVerticalActionMove(endY, disX, disY)
        } else if (mViewPager2!!.orientation == ViewPager2.ORIENTATION_HORIZONTAL) {
          onHorizontalActionMove(endX, disX, disY)
        }
      }
      MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> parent.requestDisallowInterceptTouchEvent(false)
    }
    return super.onInterceptTouchEvent(ev)
  }

  private fun onHorizontalActionMove(endX: Int, disX: Int, disY: Int) {
    if (mViewPager2?.adapter == null) {
      return
    }
    if (disX > disY) {
      val currentItem = mViewPager2?.currentItem
      val itemCount = mViewPager2?.adapter!!.itemCount
      if (currentItem == 0 && endX - startX > 0) {
        parent.requestDisallowInterceptTouchEvent(false)
      } else {
        parent.requestDisallowInterceptTouchEvent(currentItem != itemCount - 1
          || endX - startX >= 0)
      }
    } else if (disY > disX) {
      parent.requestDisallowInterceptTouchEvent(false)
    }
  }

  private fun onVerticalActionMove(endY: Int, disX: Int, disY: Int) {
    if (mViewPager2?.adapter == null) {
      return
    }
    val currentItem = mViewPager2?.currentItem
    val itemCount = mViewPager2?.adapter!!.itemCount
    if (disY > disX) {
      if (currentItem == 0 && endY - startY > 0) {
        parent.requestDisallowInterceptTouchEvent(false)
      } else {
        parent.requestDisallowInterceptTouchEvent(currentItem != itemCount - 1
          || endY - startY >= 0)
      }
    } else if (disX > disY) {
        parent.requestDisallowInterceptTouchEvent(false)
    }
  }

  /**
   * 设置是否允许在当前View的{@link MotionEvent#ACTION_DOWN}事件中禁止父View对事件的拦截,该方法
   * 用于解决CoordinatorLayout+CollapsingToolbarLayout在嵌套ViewPager2Container时引起的滑动冲突问题。
   *
   * 设置是否允许在ViewPager2Container的{@link MotionEvent#ACTION_DOWN}事件中禁止父View对事件的拦截,该方法
   * 用于解决CoordinatorLayout+CollapsingToolbarLayout在嵌套ViewPager2Container时引起的滑动冲突问题。
   *
   * @param disallowParentInterceptDownEvent 是否允许ViewPager2Container在{@link MotionEvent#ACTION_DOWN}事件中禁止父View拦截事件,默认值为false
   *   true 不允许ViewPager2Container在{@link MotionEvent#ACTION_DOWN}时间中禁止父View的时间拦截,
   *   设置disallowIntercept为true可以解决CoordinatorLayout+CollapsingToolbarLayout的滑动冲突
   *   false 允许ViewPager2Container在{@link MotionEvent#ACTION_DOWN}时间中禁止父View的时间拦截,
   */
  fun disallowParentInterceptDownEvent(disallowParentInterceptDownEvent: Boolean) {
    this.disallowParentInterceptDownEvent = disallowParentInterceptDownEvent
  }
}

上边代码限于篇幅我就不做过多解释了,注意一下在 onFinishInflate 中我们通过循环,遍历了 ViewPager2Container 的所有子 View,如果没有找到 ViewPager2 就抛出异常。另外,disallowParentInterceptDownEvent 方法注释写的比较详细就不多说了。

使用方法也很简单,直接用 ViewPager2Container 包裹 ViewPager2 即可。

<com.zhpan.sample.viewpager2.ViewPager2Container
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintLeft_toLeftOf="parent"
  app:layout_constraintRight_toRightOf="parent"
  app:layout_constraintTop_toTopOf="parent">

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

  <com.zhpan.indicator.IndicatorView
    android:id="@+id/indicatorView"
    android:layout_centerHorizontal="true"
    android:layout_alignParentBottom="true"
    android:layout_margin="@dimen/dp_20"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"/>

</com.zhpan.sample.viewpager2.ViewPager2Container>

这是关于 ViewPager2 滑动出冲突的处理方案,ViewPager2Container 的源码我也放到了 Github,需要用到的可以自取。

  • ViewPager2Container
    https://github.com/zhpanvip/AndroidSample/blob/master/app/src/main/java/com/zhpan/sample/viewpager2/ViewPager2Container.kt

墨影说

最后再补充一点。ViewPager2 嵌套确实存在事件冲突的情况,本文内的方案,也可以算是 Google 官方认证过的方案。

具体在 Jetpack 团队在 ViewPager2 的 Sample 写过一个例子,就是解决多层 ViewPager2 滑动时事件冲突的问题,有兴趣可以去看看。

  • NestedScrollableHost
    https://github.com/androidx/androidx/blob/androidx-main/viewpager2/integration-tests/testapp/src/main/java/androidx/viewpager2/integration/testapp/NestedScrollableHost.kt

-- End --

本文对你有帮助吗?留言、转发、点好看是最大的支持,谢谢!

推荐阅读:

还在用 ViewPager?是时候替换成 ViewPager2 了!

Flutter 也能做复杂效果,影院选座走一波!

动态生成代码:AOP 之 AspectJ 在 Android 的应用!

9487930db8790b2e2549757b5c2c1932.jpeg
  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
### 回答1: 在使用ViewPager2嵌套RecyclerView时,建议使用FragmentStateAdapter来设置ViewPager2的适配器,并在每个Fragment中使用RecyclerView。这样可以确保在滑动ViewPager2时,RecyclerView能够正确地重用并显示不同的数据。 同时,由于RecyclerView默认会拦截滑动事件,导致ViewPager2滑动失效,因此需要在RecyclerView中禁用滑动事件的拦截。可以通过设置RecyclerView的NestedScrollingEnabled属性为false来实现,即recyclerView.setNestedScrollingEnabled(false)。 另外,还需要在RecyclerView的Adapter中实现getItemCount()和getItemViewType()方法,并根据需要显示不同的布局类型。在使用多个RecyclerView的情况下,建议使用不同的ViewType来避免布局重复和数据错乱等问题。 总之,正确地使用ViewPager2和RecyclerView嵌套需要考虑多方面的因素,包括适配器、布局、事件处理等等。需要仔细思考和实践,才能达到最佳效果。 ### 回答2: ViewPager2Android 系统中的一个控件,可以用来创建包含多个页面的用户界面。而 RecyclerView 则是一个用于显示大量数据列表的控件。嵌套 ViewPager2 和 RecyclerView 可以带来更加丰富的用户界面和更好的交互体验。 在将 RecyclerView 嵌套ViewPager2 中时,需要注意以下几点: 1. 使用 FragmentStateAdapter 或 RecyclerView.Adapter ViewPager2 中的每一页都可以是一个 Fragment 或 View,我们可以使用 FragmentStateAdapter 或 RecyclerView.Adapter 作为 ViewPager2 的数据源。如果我们使用 RecyclerView.Adapter,可以创建多个 RecyclerView,每个 RecyclerView 显示不同的数据列表,而每个列表可以是独立的数据流。而使用 FragmentStateAdapter,我们可以创建不同的 Fragment,每个 Fragment 显示自己独立的数据流。 2.设置recyclerView为可滑动 当我们将 RecyclerView 嵌套ViewPager2 中时,需要为 RecyclerView 设置合适的滑动方式。默认情况下,RecyclerView 会拦截 ViewPager2滑动事件,导致 ViewPager2滑动失效。我们可以使用 setNestedScrollingEnabled 方法为 RecyclerView 开启嵌套滑动,或使用 ViewPager2.OnPageChangeCallback 监听 ViewPager2滑动事件,并通过调用 RecyclerView 的 scrollBy 和 scrollToPosition 方法使得 RecyclerView 能够正确滑动。 3.注意 RecyclerView 的布局 在将 RecyclerView 嵌套ViewPager2 中时,需要给 RecyclerView 设置适当的布局,以免出现滑动冲突、数据显示过大等问题。我们可以对 RecyclerView 进行水平或垂直的滚动,但需要注意 RecyclerView 的布局高度。 综上,ViewPager2 和 RecyclerView 的组合可以带来更加丰富和高效的用户界面和交互体验。它可以用于显示各种类型的列表数据,并通过 ViewPager2 的分页显示功能提供更好的用户体验。但在使用时如上文所述,需要注意一些细节问题。 ### 回答3: ViewPager2和RecyclerView都是Android中常用的控件之一。ViewPager2是一个可滑动的容器,常用于页面之间的切换和滑动;RecyclerView是一个高度可定制的列表工具,可用于呈现大量数据,并提供了很多的回收和性能优化功能。 在某些场景下,需要ViewPager2嵌套RecyclerView实现滑动和展示数据的需求,这种需求可能出现在新闻客户端中,每个tab对应一种类型的新闻,每种类型的新闻数据量很大。这时候就可以考虑使用ViewPager2嵌套RecyclerView来优化用户体验和性能。 具体的实现方法如下: 1、创建一个Activity或Fragment来承载ViewPager2; 2、在ViewPager2中添加多个Fragment,每个Fragment都对应一个tab,包含一个RecyclerView; 3、在Fragment中创建一个合适的适配器类Adapter; 4、在Adapter中重写onCreateViewHolder、onBindViewHolder和getItemCount等方法,并将RecyclerView需要的数据进行绑定; 5、在使用RecyclerView时考虑合适的数据源和异步加载等优化。将RecyclerView中的数据源从Main Thread中移除,使用异步线程进行数据的加载和显示; 6、在ViewPager2中添加TabLayout用于切换不同的Fragment。 需要注意的是,ViewPager2嵌套RecyclerView能够实现数据的高效切换和渐变。为了更好地优化性能,应该尽量减少RecyclerView嵌套层数,并考虑分页加载等策略来优化加载速度和性能。 总之,ViewPager2嵌套RecyclerView是一种常用的Android开发技术,可以使用它来优化用户体验和性能,提高应用的质量。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值