はじめに
タイトルを見ても、何を言っているのかよくわからないかもしれないので、まずは下のGIFをご覧ください。非ModalなBottom Sheetの上にViewPagerがあり、それぞれのページがスクロール可能になっています。
こういったUIを作る際に誰もがぶつかる (はずの) 問題が一つあるのですが、この記事では、その問題をなんとか回避した方法を紹介します。
ViewPager on BottomSheet問題
普通に考えれば、 BottomSheetBehavior
を設定したViewの中に ViewPager
を配置すれば (あまり普通じゃないUIだけど) 普通にできるのでは、と思うと思います。私も普通にそう思ってました。ただ、そういう実装をして動作確認をしたところ、1番目 (一番左) のページは問題ないのですが、2番目以降のページが縦方向にスクロールできたりできなかったりします。
なぜ2番目以降のページがスクロールできないのか
"bottomsheet viewpager scroll" などでググる と、そのものズバリのStackOverflowのページがヒットします。
- Android ViewPager with RecyclerView works incorrectly inside BottomSheet - Stack Overflow
- android - Scroll not working for multiple RecyclerView in BottomSheet - Stack Overflow
BottomSheetBehavior
は、ユーザーのタッチ操作によりBottom Sheet全体を縦方向にスライドする処理を実装していますが、それと同時に、スクロール可能な子ViewをBottom Sheet内に配置することもサポートしています。問題は、ここで、 BottomSheetBehavior
がスクロール可能な子Viewを1つしかサポートしていないということです。 BottomSheetBehavior
は、タッチイベントの調停のためにスクロール可能な子Viewを把握しておく必要があるようで、スクロール可能な子Viewを見つける処理を BottomSheetBehavior.findScrollingChild(View)
で実装しているのですが、Viewツリーを深さ優先探索で探索して、最初に ViewCompat.isNestedScrollingEnabled(View)
が true
を返したViewが見つかるような実装になっています。
View findScrollingChild(View view) {
if (ViewCompat.isNestedScrollingEnabled(view)) {
return view;
}
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0, count = group.getChildCount(); i < count; i++) {
View scrollingChild = findScrollingChild(group.getChildAt(i));
if (scrollingChild != null) {
return scrollingChild;
}
}
}
return null;
}
つまり、ViewPager
がスクロール可能なページを複数持っていても、一番小さいIndexを持つ子View1しかスクロール可能な子Viewとして認識されないので、あるページはスクロールできるけれど他のページはスクロールできない、という動作になります。
ググって見つかった解決方法 (非採用)
上の2つのStackOverflowでは、ViewPagerBottomSheetというライブラリを使う方法が挙げられています。
ViewPagerBottomSheetライブラリでは、BottomSheetBehavior
をほぼ完コピした ViewPagerBottomSheetBehavior
というカスタム Behavior
を用意して、findScrollingChild(View)
の実装を修正することで、ViewPagerの一番最初の子Viewではなく、ViewPagerが表示している子Viewをスクロール可能な子Viewとして見つけるようにしています。つまり、 Behavior
がスクロール可能な子Viewを一つしかサポートしないのはそのままなのですが、ViewPagerが表示しているページにもとづいて、スクロール可能な子Viewを動的に変えるようになっています。
View findScrollingChild(View view) {
// 前略...
if (view instanceof ViewPager) {
ViewPager viewPager = (ViewPager) view;
View currentViewPagerChild = ViewPagerUtils.getCurrentView(viewPager);
if (currentViewPagerChild == null) {
return null;
}
View scrollingChild = findScrollingChild(currentViewPagerChild);
if (scrollingChild != null) {
return scrollingChild;
}
} else if (view instanceof ViewGroup) {
// 後略...
}
なお、 BottomSheetBehavior.findScrollingChild(View)
は package-private なメソッドで、 BottomSheetBehavior
の派生クラスでも findScrollingChild(View)
をオーバーライドできないので、派生クラスではなくコピークラスを用意しているようです。
ViewPagerBottomSheetライブラリの問題点
ViewPagerBottomSheetライブラリはうまく動作するのですが、このライブラリが提供する ViewPagerBottomSheetBehavior
はある時点の BottomSheetBehavior
のソースコードをコピーして作られており、 BottomSheetBehavior
が更新されても、 ViewPagerBottomSheetBehavior
がその更新分を取り込まない限り更新の恩恵を受けられない、という問題があります。実際、 ViewPagerBottomSheetBehavior
の最後の更新は2018年12月2日時点で2018年5月6日となっていて半年以上更新がなく、あまりメンテナンスされていない雰囲気です。
採用した解決方法
ViewPagerBottomSheetライブラリの「ViewPagerの場合は、深さ優先探索ではなく、ViewPagerが表示しているページがスクロール可能な子Viewとして認識されるようにする」という方法はよさそうなので、標準の BottomSheetBehavior
を使いつつ、この方法を実現できないか考えました。結果、 getChildAt()
をオーバーライドした ViewPager
の派生カスタムクラスを用意する、という方法を思いつき、開発中のアプリで使っています。
class BottomSheetViewPager(context: Context, attrs: AttributeSet?) : ViewPager(context, attrs) {
constructor(context: Context) : this(context, null)
private val positionField: Field =
ViewPager.LayoutParams::class.java.getDeclaredField("position").also {
it.isAccessible = true
}
init {
addOnPageChangeListener(object : SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
requestLayout()
}
})
}
override fun getChildAt(index: Int): View {
val stackTrace = Throwable().stackTrace
val findScrollingChild = stackTrace.getOrNull(1)?.let {
it.className == "com.google.android.material.bottomsheet.BottomSheetBehavior" &&
it.methodName == "findScrollingChild"
}
if (findScrollingChild != true) {
return super.getChildAt(index)
}
// Swap index 0 and `currentItem`
val currentView = getCurrentView() ?: return super.getChildAt(index)
return if (index == 0) {
currentView
} else {
var view = super.getChildAt(index)
if (view == currentView) {
view = super.getChildAt(0)
}
return view
}
}
private fun getCurrentView(): View? {
for (i in 0 until childCount) {
val child = super.getChildAt(i)
val lp = child.layoutParams as? ViewPager.LayoutParams
if (lp != null) {
val position = positionField.getInt(lp)
if (!lp.isDecor && currentItem == position) {
return child
}
}
}
return null
}
}
少し長いですが、ポイントは2つです。
-
getChildAt(Int)
をオーバーライドして、返すViewを調整している-
getChildAt(Int)
の呼び出し元がBottomSheetBehavior.findScrollingChild(View)
でないなら、特に何もせず、ただsuper.getChildAt(index)
を呼び出す -
getChildAt(Int)
の呼び出し元がBottomSheetBehavior.findScrollingChild(View)
なら、指定されたIndexの子Viewをそのまま返すのではなく、現在表示しているページのViewをIndex 0のViewとして返す- 現在表示しているViewを取得する処理は
getCurrentView()
で実装しています
- 現在表示しているViewを取得する処理は
-
-
ViewPager
で表示しているページが変わったら (onPageSelected()
が呼ばれたら)、requestLayout()
を呼び出して、BottomSheetBehavior
にスクロール可能な子Viewを再探索させる
呼び出し元の判定にスタックトレースを使っている、ViewPager.LayoutParams
の position
フィールドにリフレクションで無理やりアクセスしている、という点で結構怖いですが、今の所、問題なく動作しています。 ViewPager.LayoutParams
はAndroidフレームワークのクラスではなくMaterial Componentライブラリのクラスなので、ライブラリのバージョンを変えない限りリフレクションがエラーになることはないはずで、まだ安心かな、と...
なお、ViewPager.LayoutParams.position
や BottomSheetBehavior.findScrollingChild(View)
の名前が変わるとこの実装は機能しなくなるので、ProGuardなどの難読化から保護する必要がある点は注意です。
# ProGuard設定の例
# The names of `ViewPager$LayoutParams#position` and `BottomSheetBehavior#findScrollingChild()` are used by `BottomSheetViewPager`
-keep class androidx.viewpager.widget.ViewPager$LayoutParams { int position; }
-keep class com.google.android.material.bottomsheet.BottomSheetBehavior { *** findScrollingChild(...); }
サンプルアプリのソースコード
サンプルアプリのソースコードを GitHub に格納してあります。
まとめ
- Bottom SheetにViewPagerを配置する際に発生するスクロールの問題と、解決方法の案を紹介しました
- あまり、こういうUIを作ることはないと思いますが、Bottom SheetにViewPagerを配置することになった際に、参考になったら幸いです
-
ViewPagerの初期状態では、これは一般的に、一番左のページのViewになります。ただ、
PagerAdapter
の実装によりますが、ViewPagerは両隣のページのViewだけを保持して、他は必要になるまで作成しない or 破棄するという特性があるので、タブの切り替えを繰り返していくと一番小さいIndexを持つViewが、他のページのものに切り替わっていきます ↩