ViewPagerを使ってBottom Sheetにスクロール可能なページを複数配置する方法

# はじめに

タイトルを見ても、何を言っているのかよくわからないかもしれないので、まずは下のGIFをご覧ください。非ModalなBottom Sheetの上にViewPagerがあり、それぞれのページがスクロール可能になっています。
![bottomsheet_viewpager.gif](https://qiita-image-store.s3.amazonaws.com/0/80557/6eac22be-229e-4998-a7ec-663d7564e043.gif)

こういったUIを作る際に誰もがぶつかる (はずの) 問題が一つあるのですが、この記事では、その問題をなんとか回避した方法を紹介します。

# ViewPager on BottomSheet問題

普通に考えれば、 [`BottomSheetBehavior`](https://developer.android.com/reference/com/google/android/material/bottomsheet/BottomSheetBehavior) を設定したViewの中に [`ViewPager`](https://developer.android.com/reference/androidx/viewpager/widget/ViewPager) を配置すれば (あまり普通じゃないUIだけど) 普通にできるのでは、と思うと思います。私も普通にそう思ってました。ただ、そういう実装をして動作確認をしたところ、1番目 (一番左) のページは問題ないのですが、2番目以降のページが縦方向にスクロールできたりできなかったりします。

# なぜ2番目以降のページがスクロールできないのか

["bottomsheet viewpager scroll" などでググる](https://www.google.co.in/search?q=bottomsheet+viewpager+scroll) と、そのものズバリのStackOverflowのページがヒットします。

- [Android ViewPager with RecyclerView works incorrectly inside BottomSheet - Stack Overflow](https://stackoverflow.com/q/37715822)
- [android - Scroll not working for multiple RecyclerView in BottomSheet - Stack Overflow](https://stackoverflow.com/q/39326321)

`BottomSheetBehavior` は、ユーザーのタッチ操作によりBottom Sheet全体を縦方向にスライドする処理を実装していますが、それと同時に、スクロール可能な子ViewをBottom Sheet内に配置することもサポートしています。問題は、ここで、 `BottomSheetBehavior` がスクロール可能な子Viewを1つしかサポートしていないということです。 `BottomSheetBehavior` は、タッチイベントの調停のためにスクロール可能な子Viewを把握しておく必要があるようで、スクロール可能な子Viewを見つける処理を [`BottomSheetBehavior.findScrollingChild(View)`](https://github.com/material-components/material-components-android/blob/8073c98d604279fe26ac95fcc60c70882cace7df/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java#L733-L747) で実装しているのですが、Viewツリーを[深さ優先探索](https://ja.wikipedia.org/wiki/%E6%B7%B1%E3%81%95%E5%84%AA%E5%85%88%E6%8E%A2%E7%B4%A2)で探索して、最初に [`ViewCompat.isNestedScrollingEnabled(View)`](https://android.googlesource.com/platform/frameworks/support/+/f7528a970ad7f2f099b4dc1397084cc388c9193c/core/src/main/java/androidx/core/view/ViewCompat.java#2695) が `true` を返したViewが見つかるような実装になっています。 

```java:BottomSheetBehavior.java
  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を持つ子View[^1]しかスクロール可能な子Viewとして認識されないので、あるページはスクロールできるけれど他のページはスクロールできない、という動作になります。

[^1]: ViewPagerの初期状態では、これは一般的に、一番左のページのViewになります。ただ、 `PagerAdapter` の実装によりますが、ViewPagerは両隣のページのViewだけを保持して、他は必要になるまで作成しない or 破棄するという特性があるので、タブの切り替えを繰り返していくと一番小さいIndexを持つViewが、他のページのものに切り替わっていきます

# ググって見つかった解決方法 (非採用)

上の2つのStackOverflowでは、[ViewPagerBottomSheet](https://github.com/laenger/ViewPagerBottomSheet)というライブラリを使う方法が挙げられています。

ViewPagerBottomSheetライブラリでは、[`BottomSheetBehavior`](https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java) をほぼ完コピした [`ViewPagerBottomSheetBehavior`](https://github.com/laenger/ViewPagerBottomSheet/blob/master/vpbs/src/main/java/biz/laenger/android/vpbs/ViewPagerBottomSheetBehavior.java) というカスタム `Behavior` を用意して、[`findScrollingChild(View)` の実装を修正する](https://github.com/laenger/ViewPagerBottomSheet/blob/master/vpbs/src/main/java/biz/laenger/android/vpbs/ViewPagerBottomSheetBehavior.java#L608-L633)ことで、ViewPagerの一番最初の子Viewではなく、ViewPagerが表示している子Viewをスクロール可能な子Viewとして見つけるようにしています。つまり、 `Behavior` がスクロール可能な子Viewを一つしかサポートしないのはそのままなのですが、ViewPagerが表示しているページにもとづいて、スクロール可能な子Viewを動的に変えるようになっています。

```java:ViewPagerBottomSheetBehavior.java
    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` の派生カスタムクラスを用意する、という方法を思いつき、開発中のアプリで使っています。

```kotlin:BottomSheetViewPager.kt
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()` で実装しています
- `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](https://github.com/kafumi/android-bottomsheet-viewpaper) に格納してあります。

- 標準の `ViewPager` でスクロールがうまくいかないバージョン: [c62be3d](https://github.com/kafumi/android-bottomsheet-viewpager/commit/c62be3d809c103d8e7d687097207c540fbbd9ba9)
- 上記の `BottomSheetViewPager` を導入して問題を回避したバージョン: [e2636af](https://github.com/kafumi/android-bottomsheet-viewpager/tree/e2636afa09b159c27552d5889729df1c9cf4e2c7)

# まとめ

- Bottom SheetにViewPagerを配置する際に発生するスクロールの問題と、解決方法の案を紹介しました
- あまり、こういうUIを作ることはないと思いますが、Bottom SheetにViewPagerを配置することになった際に、参考になったら幸いです