這可能是 ViewPager2 滑動(dòng)沖突最全處理方案!
BATcoder技術(shù)群,讓一部分人先進(jìn)大廠
大家好,我是劉望舒,騰訊TVP,著有三本業(yè)內(nèi)知名暢銷書,連續(xù)四年蟬聯(lián)電子工業(yè)出版社年度優(yōu)秀作者,谷歌開發(fā)者社區(qū)特邀講師,百度百科收錄的高級(jí)技術(shù)專家。
前華為技術(shù)專家,現(xiàn)大廠技術(shù)總監(jiān)。
想要加入 BATcoder技術(shù)群,公號(hào)回復(fù)BAT 即可。
作者:賭一包辣條
來(lái)源:https://juejin.cn/post/6911456860533063688
ViewPager2 已經(jīng)逐漸開始替代舊版本的ViewPager。許多開發(fā)者也已經(jīng)在項(xiàng)目中使用了 ViewPager2。相比ViewPager,ViewPager2的功能不可謂不強(qiáng)大,我在之前寫過(guò)的一篇文章https://zhpanvip.gitee.io/2019/12/14/24.Know%20about%20ViewPager2
中對(duì) ViewPager2 的使用做過(guò)詳細(xì)的講解。
但是,由于當(dāng)時(shí)沒(méi)有太多實(shí)戰(zhàn),所以并沒(méi)有發(fā)現(xiàn) ViewPager2 的嵌套使用存在嚴(yán)重的滑動(dòng)沖突。直到今年三月份用 ViewPager2 重構(gòu) BannerViewPager 的時(shí)候才發(fā)現(xiàn)這個(gè)問(wèn)題。因此,在BVP
3.0版本中額外對(duì) ViewPager2 做了滑動(dòng)沖突處理,效果還算差強(qiáng)人意。另外,曾在論壇上看到過(guò)不少ViewPager2滑動(dòng)沖突的求助帖子,甚至還有同學(xué)因?yàn)樗阉鱒iewPager2滑動(dòng)沖突而找到了BannerViewPager的Github主頁(yè)。
既然如此,不如寫篇文章將BVP處理滑動(dòng)沖突的經(jīng)驗(yàn)分享給大家,沒(méi)準(zhǔn)還能漲知
(fěn) 識(shí) (sī) ,嘿嘿嘿。

一、為什么ViewPager沒(méi)有滑動(dòng)沖突?
不知道你是否有這個(gè)疑問(wèn),在ViewPager時(shí)代,ViewPager嵌套ViewPager并沒(méi)有出現(xiàn)過(guò)滑動(dòng)沖突。可是為什么在ViewPager的升級(jí)版ViewPager2中卻出現(xiàn)了滑動(dòng)沖突呢?想要搞清楚這個(gè)問(wèn)題就需要我們深入到ViewPager和ViewPager2的內(nèi)部分析一下它們的源碼了。
我們知道,滑動(dòng)沖突是需要在onInterceptTouchEvent方法中進(jìn)行處理的,根據(jù)自身?xiàng)l件來(lái)決定是否要攔截事件。在ViewPager的源碼中看到以下代碼(方便閱讀,代碼做了刪減):
1@Override
2 public boolean onInterceptTouchEvent(MotionEvent ev) {
3
4 final int action = ev.getAction() & MotionEvent.ACTION_MASK;
5 if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
6 // 在事件取消或者抬起手指后重置狀態(tài)
7 resetTouch();
8 return false;
9 }
10
11 switch (action) {
12 case MotionEvent.ACTION_MOVE: {
13 // 這里判斷在水平方向上的滑動(dòng)距離大于豎直方向的2倍,則認(rèn)為是有效的切換頁(yè)面的滑動(dòng)
14 if (xDiff > mTouchSlop && xDiff * 0.5f > yDiff) {
15 mIsBeingDragged = true;
16 // 禁止Parent View攔截事件,即事件要能夠傳遞到ViewPager
17 requestParentDisallowInterceptTouchEvent(true);
18 setScrollState(SCROLL_STATE_DRAGGING);
19 } else if (yDiff > mTouchSlop) {
20 mIsUnableToDrag = true;
21 }
22 break;
23 }
24
25 case MotionEvent.ACTION_DOWN: {
26 if (mScrollState == SCROLL_STATE_SETTLING
27 && Math.abs(mScroller.getFinalX() - mScroller.getCurrX()) > mCloseEnough) {
28 // 在Down事件中禁止Parent View攔截事件,是為了事件序列能夠傳遞到ViewPager
29 requestParentDisallowInterceptTouchEvent(true);
30 setScrollState(SCROLL_STATE_DRAGGING);
31 } else {
32 completeScroll(false);
33 mIsBeingDragged = false;
34 }
35 break;
36 }
37
38 case MotionEvent.ACTION_POINTER_UP:
39 onSecondaryPointerUp(ev);
40 break;
41 }
42 return mIsBeingDragged;
43 }
可以看到在ACTION_DOWN與ACTION_MOVE中根據(jù)一些判斷條件調(diào)用了requestParentDisallowInterceptTouchEvent(true)方法來(lái)禁止Parent
View攔截事件,也就是說(shuō)ViewPager已經(jīng)幫我們處理了滑動(dòng)沖突,所以我們只管用即可,無(wú)需擔(dān)心滑動(dòng)沖突問(wèn)題。
現(xiàn)在,我們轉(zhuǎn)到ViewPager2中,翻閱源碼發(fā)現(xiàn)只有在RecyclerView
的實(shí)現(xiàn)類中有onInterceptTouchEvent的相關(guān)方法,而且這句代碼僅僅是處理禁用了用戶輸入的邏輯!
1private class RecyclerViewImpl extends RecyclerView {
2
3 .... // 省略部分代碼
4
5 @Override
6 public boolean onInterceptTouchEvent(MotionEvent ev) {
7 return isUserInputEnabled() && super.onInterceptTouchEvent(ev);
8 }
9 }
也就是說(shuō)ViewPager2其實(shí)并沒(méi)有幫我們處理滑動(dòng)沖突!這是為什么呢?難道是ViewPager2的開發(fā)者們把這件事忘了?這里我敢保證肯定不是這樣子的。其實(shí),只要我們看一看ViewPager2的結(jié)構(gòu)大概就能知道了。ViewPager2被聲明了final,意味著我們不能像繼承ViewPager一樣來(lái)修改ViewPager2。如果官方在ViewPager2內(nèi)部自行處理了滑動(dòng)沖突,那么如果有特殊的需求,需要根據(jù)我們自己的情況來(lái)處理ViewPager2的滑動(dòng),那么官方寫的處理滑動(dòng)沖突的代碼是不是會(huì)影響到我們自己的需求?所以我覺(jué)得也正因?yàn)檫@樣,干脆不做任何處理,全權(quán)交給了開發(fā)者自行解決。
二、滑動(dòng)沖突的處理方案
既然官方不給我們處理,那就需要我們自己動(dòng)手了。在開始之前我們先來(lái)了解以下處理滑動(dòng)沖突的兩種方案。既然出現(xiàn)滑動(dòng)沖突,那么一定是由于兩個(gè)布局相互嵌套引起的
。既然是兩個(gè)布局,那么我們就可以分為兩個(gè)方向來(lái)處理。即所謂的 外部攔截法 和 內(nèi)部攔截法 。
1.外部攔截法
所謂的“外部攔截法“中的外部是指出現(xiàn)滑動(dòng)沖突的這兩個(gè)布局的外層。我們知道,一個(gè)事件序列是由Parent View先獲取到的,如果Parent
View不攔截事件那么才會(huì)交由子View去處理。既然是外層先獲知事件,那外層View根據(jù)自身情況來(lái)決定是否要攔截事件不就行了嗎?因此外部攔截法的實(shí)現(xiàn)是非常簡(jiǎn)單的,大概思路如下:
1public boolean onInterceptTouchEvent(MotionEvent event) {
2 boolean intercepted = false;
3 int x = (int) event.getX();
4 int y = (int) event.getY();
5 switch (event.getAction()) {
6 case MotionEvent.ACTION_DOWN: {
7 intercepted = false;
8 break;
9 }
10 case MotionEvent.ACTION_MOVE: {
11 if (needIntercept) { // 這里根據(jù)需求判斷是否需要攔截
12 intercepted = true;
13 } else {
14 intercepted = false;
15 }
16 break;
17 }
18 case MotionEvent.ACTION_UP: {
19 intercepted = false;
20 break;
21 }
22 default:
23 break;
24 }
25 mLastXIntercept = x;
26 mLastYIntercept = y;
27 return intercepted;
28 }
2.內(nèi)部攔截法
所謂的”內(nèi)部攔截法“指的是對(duì)內(nèi)部的View做文章,讓內(nèi)部View決定是不是攔截事件。但是現(xiàn)在就有問(wèn)題了,你怎么知道外部的View是不是要攔截事件啊??如果外部View把事件攔截了,內(nèi)部的View豈不是連西北風(fēng)都喝不到了?別著急,Google官方當(dāng)然有考慮到這種情況。在ViewGroup中有一個(gè)叫requestDisallowInterceptTouchEvent的方法,這個(gè)方法接受一個(gè)boolean值,意思是是否要禁止ViewGroup攔截當(dāng)前事件。如果是true的話那么該ViewGroup則無(wú)法對(duì)事件進(jìn)行攔截。有了這個(gè)方法那我們就可以讓內(nèi)部View大顯神通了。來(lái)看下內(nèi)部攔截法的代碼:
1public boolean dispatchTouchEvent(MotionEvent event) {
2 int x = (int) event.getX();
3 int y = (int) event.getY();
4
5 switch (event.getAction()) {
6 case MotionEvent.ACTION_DOWN: {
7 // 禁止parent攔截down事件
8 parent.requestDisallowInterceptTouchEvent(true);
9 break;
10 }
11 case MotionEvent.ACTION_MOVE: {
12 int deltaX = x - mLastX;
13 int deltaY = y - mLastY;
14 if (disallowParentInterceptTouchEvent) { // 根據(jù)需求條件來(lái)決定是否讓Parent View攔截事件。
15 parent.requestDisallowInterceptTouchEvent(false);
16 }
17 break;
18 }
19 case MotionEvent.ACTION_UP: {
20 break;
21 }
22 default:
23 break;
24 }
25
26 mLastX = x;
27 mLastY = y;
28 return super.dispatchTouchEvent(event);
29 }
這么處理之后,兩個(gè)嵌套View就可以和諧工作了。
下面是來(lái)自外部View和內(nèi)部View的對(duì)話。
外部View:”我想攔截事件!“
內(nèi)部View:”不,你不想。這事件我要定了,耶穌都留不住他。“

三、處理ViewPager2的滑動(dòng)沖突
上一章講了滑動(dòng)沖突處理的兩種方案,那么本章我們就來(lái)解決ViewPager2的滑動(dòng)沖突。首先,應(yīng)該確定一下存在在哪些需要攔截和不需要攔截的邊界條件。在寫這篇文章之前,我Google搜索了一下ViewPager2的滑動(dòng)沖突處理方案,關(guān)于這方面的文章還不算少,不過(guò)大部分的文章對(duì)于ViewPager2的滑動(dòng)沖突處理考慮的都不夠完善。
下面我們?cè)敿?xì)來(lái)分析一下:
如果設(shè)置了userInputEnable=false,那么ViewPager2不應(yīng)該攔截任何事件;
如果只有一個(gè)Item,那么ViewPager2也不應(yīng)該攔截事件;
如果是多個(gè)Item,且當(dāng)前是第一個(gè)頁(yè)面,那么只能攔截向左的滑動(dòng)事件,向右的滑動(dòng)事件就不應(yīng)該由ViewPager2攔截了;
如果是多個(gè)Item,且當(dāng)前是最后一個(gè)頁(yè)面,那么只能攔截向右的滑動(dòng)事件,向左的滑動(dòng)事件不應(yīng)該由當(dāng)前的ViewPager2攔截;
如果是多個(gè)Item,且是中間頁(yè)面,那么無(wú)論向左還是向右的事件都應(yīng)該由ViewPager2攔截;
最后,由于ViewPager2是支持豎直滑動(dòng)的,那么豎直滑動(dòng)也應(yīng)該考慮以上條件。
分析完了邊界條件之后,我們看下應(yīng)該使用哪種方案來(lái)處理滑動(dòng)沖突?很明顯,這里應(yīng)該使用內(nèi)部攔截法處理。但是,由于ViewPager2被設(shè)置成了final,我們無(wú)法通過(guò)繼承的方式來(lái)處理,因此就需要我們?cè)赩iewPager2外部加一層自定義的Layout。這層Layout其實(shí)相當(dāng)于夾在了內(nèi)層View和外層View的中間,其實(shí)就是這層Layout就變成了內(nèi)層。好了,廢話不多說(shuō)了,直接貼代碼了。
1class ViewPager2Container @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : RelativeLayout(context, attrs, defStyleAttr) {
2
3 private var mViewPager2: ViewPager2? = null
4 private var disallowParentInterceptDownEvent = true
5 private var startX = 0
6 private var startY = 0
7
8 override fun onFinishInflate() {
9 super.onFinishInflate()
10 for (i in 0 until childCount) {
11 val childView = getChildAt(i)
12 if (childView is ViewPager2) {
13 mViewPager2 = childView
14 break
15 }
16 }
17 if (mViewPager2 == null) {
18 throw IllegalStateException("The root child of ViewPager2Container must contains a ViewPager2")
19 }
20 }
21
22 override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
23 val doNotNeedIntercept = (!mViewPager2!!.isUserInputEnabled
24 || (mViewPager2?.adapter != null
25 && mViewPager2?.adapter!!.itemCount <= 1))
26 if (doNotNeedIntercept) {
27 return super.onInterceptTouchEvent(ev)
28 }
29 when (ev.action) {
30 MotionEvent.ACTION_DOWN -> {
31 startX = ev.x.toInt()
32 startY = ev.y.toInt()
33 parent.requestDisallowInterceptTouchEvent(!disallowParentInterceptDownEvent)
34 }
35 MotionEvent.ACTION_MOVE -> {
36 val endX = ev.x.toInt()
37 val endY = ev.y.toInt()
38 val disX = abs(endX - startX)
39 val disY = abs(endY - startY)
40 if (mViewPager2!!.orientation == ViewPager2.ORIENTATION_VERTICAL) {
41 onVerticalActionMove(endY, disX, disY)
42 } else if (mViewPager2!!.orientation == ViewPager2.ORIENTATION_HORIZONTAL) {
43 onHorizontalActionMove(endX, disX, disY)
44 }
45 }
46 MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> parent.requestDisallowInterceptTouchEvent(false)
47 }
48 return super.onInterceptTouchEvent(ev)
49 }
50
51 private fun onHorizontalActionMove(endX: Int, disX: Int, disY: Int) {
52 if (mViewPager2?.adapter == null) {
53 return
54 }
55 if (disX > disY) {
56 val currentItem = mViewPager2?.currentItem
57 val itemCount = mViewPager2?.adapter!!.itemCount
58 if (currentItem == 0 && endX - startX > 0) {
59 parent.requestDisallowInterceptTouchEvent(false)
60 } else {
61 parent.requestDisallowInterceptTouchEvent(currentItem != itemCount - 1
62 || endX - startX >= 0)
63 }
64 } else if (disY > disX) {
65 parent.requestDisallowInterceptTouchEvent(false)
66 }
67 }
68
69 private fun onVerticalActionMove(endY: Int, disX: Int, disY: Int) {
70 if (mViewPager2?.adapter == null) {
71 return
72 }
73 val currentItem = mViewPager2?.currentItem
74 val itemCount = mViewPager2?.adapter!!.itemCount
75 if (disY > disX) {
76 if (currentItem == 0 && endY - startY > 0) {
77 parent.requestDisallowInterceptTouchEvent(false)
78 } else {
79 parent.requestDisallowInterceptTouchEvent(currentItem != itemCount - 1
80 || endY - startY >= 0)
81 }
82 } else if (disX > disY) {
83 parent.requestDisallowInterceptTouchEvent(false)
84 }
85 }
86
87 /**
88 * 設(shè)置是否允許在當(dāng)前View的{@link MotionEvent#ACTION_DOWN}事件中禁止父View對(duì)事件的攔截,該方法
89 * 用于解決CoordinatorLayout+CollapsingToolbarLayout在嵌套ViewPager2Container時(shí)引起的滑動(dòng)沖突問(wèn)題。
90 *
91 * 設(shè)置是否允許在ViewPager2Container的{@link MotionEvent#ACTION_DOWN}事件中禁止父View對(duì)事件的攔截,該方法
92 * 用于解決CoordinatorLayout+CollapsingToolbarLayout在嵌套ViewPager2Container時(shí)引起的滑動(dòng)沖突問(wèn)題。
93 *
94 * @param disallowParentInterceptDownEvent 是否允許ViewPager2Container在{@link MotionEvent#ACTION_DOWN}事件中禁止父View攔截事件,默認(rèn)值為false
95 * true 不允許ViewPager2Container在{@link MotionEvent#ACTION_DOWN}時(shí)間中禁止父View的時(shí)間攔截,
96 * 設(shè)置disallowIntercept為true可以解決CoordinatorLayout+CollapsingToolbarLayout的滑動(dòng)沖突
97 * false 允許ViewPager2Container在{@link MotionEvent#ACTION_DOWN}時(shí)間中禁止父View的時(shí)間攔截,
98 */
99 fun disallowParentInterceptDownEvent(disallowParentInterceptDownEvent: Boolean) {
100 this.disallowParentInterceptDownEvent = disallowParentInterceptDownEvent
101 }
102}
上邊代碼限于篇幅我就不做過(guò)多解釋了,注意一下在onFinishInflate中我們通過(guò)循環(huán),遍歷了ViewPager2Container的所有子View,如果沒(méi)有找到ViewPager2就拋出異常。另外,disallowParentInterceptDownEvent方法注釋寫的比較詳細(xì)就不多說(shuō)了。
使用方法也很簡(jiǎn)單,直接用ViewPager2Container包裹ViewPager2即可:
1<com.zhpan.sample.viewpager2.ViewPager2Container
2 android:layout_width="match_parent"
3 android:layout_height="match_parent"
4 app:layout_constraintBottom_toBottomOf="parent"
5 app:layout_constraintLeft_toLeftOf="parent"
6 app:layout_constraintRight_toRightOf="parent"
7 app:layout_constraintTop_toTopOf="parent">
8
9 <androidx.viewpager2.widget.ViewPager2
10 android:id="@+id/view_pager2"
11 android:layout_width="match_parent"
12 android:layout_height="match_parent" />
13
14 <com.zhpan.indicator.IndicatorView
15 android:id="@+id/indicatorView"
16 android:layout_centerHorizontal="true"
17 android:layout_alignParentBottom="true"
18 android:layout_margin="@dimen/dp_20"
19 android:layout_width="wrap_content"
20 android:layout_height="wrap_content"/>
21
22 </com.zhpan.sample.viewpager2.ViewPager2Container>
https://github.com/zhpanvip/BannerViewPager
推薦閱讀
? 耗時(shí)2年,Android進(jìn)階三部曲第三部《Android進(jìn)階指北》出版!
? 『BATcoder』做了多年安卓還沒(méi)編譯過(guò)源碼?一個(gè)視頻帶你玩轉(zhuǎn)!
為了防止失聯(lián),歡迎關(guān)注我的小號(hào)
微信改了推送機(jī)制,真愛(ài)請(qǐng)星標(biāo)本公號(hào)??
