ViewPager2:ViewPager都能自動嵌套滾動了,我不行?我麻了!該怎麼做?
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
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
}
那麼使用就是這樣:
如果想要嵌套滾動就在 VPItemFragment 中設置一個子ViewPager容器。
```kotlin
view.findViewById
val fragmentList = listOf(VPItemChildFragment(0), VPItemChildFragment(1), VPItemChildFragment(2))
bindFragment(
childFragmentManager,
fragmentList
behavior = 1
)
} ```
由於我們並沒有使用自定義ViewPager,這樣實現默認的ViewPager會有嵌套效果嗎?,看看效果
啊?這?能嵌套了?默認的ViewPager就支持嵌套了?
是的,對於ViewPager嵌套問題,之前的老版本確實是需要自定義處理攔截事件,但是新版本的ViewPager已經幫我們處理了嵌套效果。
源碼中對 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
val fragmentList = listOf(VPItemChildFragment(0), VPItemChildFragment(1), VPItemChildFragment(2))
bindFragment(
childFragmentManager,
lifecycle,
fragmentList
)
orientation = ViewPager2.ORIENTATION_HORIZONTAL
```
ViewPager都行,沒道理我不行,來運行試試
這...怎麼又不行了? 現實連着扇了我兩巴掌。那ViewPager2內部肯定沒有寫嵌套的兼容代碼。
由於ViewPager2內部是RV實現的,這裏只重新了RV的攔截事件
並沒有嵌套滾動的邏輯,那我知道了,我們像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>
```
其他的代碼沒變化,運行效果:
這樣就能達到和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是一樣的效果:
那麼 ViewPager2 嵌套 ViewPager 呢?
這種情況下如果我們不加中間層,和ViewPager2的嵌套是一樣的,父佈局直接無法分發事件下來。
所以我們還是需要加上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 確是不會接受到事件自己動起來。
其實效果就是請求攔截但是沒有攔截到,還是子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
}
```
修改之後的效果如下:
雖然效果都能實現,但是我不相信真有兄弟在實戰中會這麼嵌套吧 😂 😂,搞這些騷操作還是為了更深入的理解和學習。
總結
新版本的ViewPager可以很方便的自帶嵌套效果,我們使用起來確實很方便,ViewPager2也可以通過加一個Host中間層來實現同樣的效果。包括Host在其他嵌套的場景下的使用,這個思路很重要。
但是ViewPager只能固定方向,而ViewPager2通過RV實現更加的靈活,不僅可以自定義方向,還能自適應高度,這一點特性在一些特定的場景下面就很方便。
一般普通的場景我們可以使用ViewPager快速實現即可,一些特殊的效果我們可以通過ViewPager2來實現!如果想要嵌套效果我們也可以通過Host中間層來解決事件傳遞問題。
好了本文的全部代碼與Demo都已經開源。有興趣可以看這裏,可供大家參考學習。
慣例,我如有講解不到位或錯漏的地方,希望同學們可以指出交流。
如果感覺本文對你有一點點的啟發,還望你能點贊
支持一下,你的支持是我最大的動力。
Ok,這一期就此完結。
- Android操作文件也太難了趴,File vs DocumentFile 以及 DocumentsProvider vs FileProvider 的異同
- findViewById不香嗎?為什麼要把簡單的問題複雜化?為什麼要用DataBinding?
- Android自定義View繪製進階-水波浪温度刻度表
- Android自定義ViewGroup佈局進階,完整的九宮格實現
- 記錄仿抖音的視頻播放並緩存預加載視頻的效果實現
- Kotlin對象的懶加載方式?by lazy 與 lateinit 的異同
- 定位都得集成第三方?Android原生定位服務LocationManager不行嗎?
- 還用第三方庫管理狀態欄嗎?Android關於狀態欄管理的幾種方案實現!
- 下載需要集成第三方?Android原生下載服務DownloadManager不行嗎?
- Android陰影實現的幾種方案-自定義圓角ViewGroup加入陰影效果
- 操作Android窗口的幾種方式?WindowInsets與其兼容庫的使用與踩坑
- Android軟鍵盤與佈局的協調-不同的效果與實現方案的探討
- ViewPager2:ViewPager都能自動嵌套滾動了,我不行?我麻了!該怎麼做?
- Android軟鍵盤的監聽與高度控制的幾種方案及常用效果
- 圓角升級啦,來手把手一起實現自定義ViewGroup的各種圓角與背景
- Android導航欄的處理-HostStatusLayout加入底部的導航欄適配
- 一次搞懂怎麼設置圓角圖片,ImageView的各種圓角設置
- 一看就會 Android框架DataBinding的使用與封裝
- 別濫用FileProvider了,Android中FileProvider的各種場景應用
- Android登錄攔截的場景-基於攔截器模式實現