Help us understand the problem. What is going on with this article?

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

はじめに

タイトルを見ても、何を言っているのかよくわからないかもしれないので、まずは下のGIFをご覧ください。非ModalなBottom Sheetの上にViewPagerがあり、それぞれのページがスクロール可能になっています。
bottomsheet_viewpager.gif

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

ViewPager on BottomSheet問題

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

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

"bottomsheet viewpager scroll" などでググる と、そのものズバリのStackOverflowのページがヒットします。

BottomSheetBehavior は、ユーザーのタッチ操作によりBottom Sheet全体を縦方向にスライドする処理を実装していますが、それと同時に、スクロール可能な子ViewをBottom Sheet内に配置することもサポートしています。問題は、ここで、 BottomSheetBehavior がスクロール可能な子Viewを1つしかサポートしていないということです。 BottomSheetBehavior は、タッチイベントの調停のためにスクロール可能な子Viewを把握しておく必要があるようで、スクロール可能な子Viewを見つける処理を BottomSheetBehavior.findScrollingChild(View) で実装しているのですが、Viewツリーを深さ優先探索で探索して、最初に ViewCompat.isNestedScrollingEnabled(View)true を返したViewが見つかるような実装になっています。

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

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

上の2つのStackOverflowでは、ViewPagerBottomSheetというライブラリを使う方法が挙げられています。

ViewPagerBottomSheetライブラリでは、BottomSheetBehavior をほぼ完コピした ViewPagerBottomSheetBehavior というカスタム Behavior を用意して、findScrollingChild(View) の実装を修正することで、ViewPagerの一番最初の子Viewではなく、ViewPagerが表示している子Viewをスクロール可能な子Viewとして見つけるようにしています。つまり、 Behavior がスクロール可能な子Viewを一つしかサポートしないのはそのままなのですが、ViewPagerが表示しているページにもとづいて、スクロール可能な子Viewを動的に変えるようになっています。

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

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.LayoutParamsposition フィールドにリフレクションで無理やりアクセスしている、という点で結構怖いですが、今の所、問題なく動作しています。 ViewPager.LayoutParams はAndroidフレームワークのクラスではなくMaterial Componentライブラリのクラスなので、ライブラリのバージョンを変えない限りリフレクションがエラーになることはないはずで、まだ安心かな、と...

なお、ViewPager.LayoutParams.positionBottomSheetBehavior.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 に格納してあります。

  • 標準の ViewPager でスクロールがうまくいかないバージョン: c62be3d
  • 上記の BottomSheetViewPager を導入して問題を回避したバージョン: e2636af

まとめ

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

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

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away