TabLayout + ViewPager2でScroll To Topを実現する方法

本記事では、現在選択されているタブを再度タップしたときに、タブ内のリストを先頭までスクロール(以下、Scroll To Topと呼びます)させる方法について記載しています。
なお、本記事の最後にサンプルコードのリンクを記載しているので、実行環境の詳細についてはそちらをご参照いただけますと幸いです。



実装方法

1. Scroll To Topのための処理を外部から呼び出すためのインタフェースを定義する

まず、Scroll To Topのための処理を外部から呼び出すために、ListScrollableインタフェースを定義します。

interface ListScrollable {
  fun scrollToTop()
}


そして、タブ内に表示するFragmentのうち、Scroll To Topを行いたいFragmentに対してListScrollableを実装します。
overrideするscrollToTop()の中で、Scroll To Topのための処理を呼び出します。

// RecyclerViewだけをコンポーネントに持つFragment。
class MainFragment : Fragment(), ListScrollable {

  private lateinit var binding: FragmentMainBinding

  ...

  override fun scrollToTop() {
    binding.recyclerView.smoothScrollToPosition(0)
  }

  ...

}


2. タブ内の各Fragmentを参照するためのプロパティをFragmentStateAdapterの具象クラスに追加する

次に、タブ内の各Fragmentを参照するためのプロパティをFragmentStateAdapterの具象クラスに追加します。
FragmentStateAdapterは、ViewPager2を使ってFragmentをページングするために必要な抽象クラスです。

developer.android.com

class SectionsPagerAdapter(
  private val tabsCount: Int,
  fragmentActivity: FragmentActivity
) : FragmentStateAdapter(fragmentActivity) {

  // タブ内の各Fragmentの参照を保持しています。
  private val _fragments = mutableListOf<Fragment>()
  // 外部に対してはimmutableなリストとして公開します。
  val fragments: List<Fragment> = _fragments

  override fun createFragment(position: Int): Fragment {
    return MainFragment.newInstance().also {
      _fragments.add(it)
    }
  }

  override fun getItemCount(): Int {
    return tabsCount
  }

}


懸念点として、FragmentStateAdapterはタブ内の各Fragmentを外部に対して本来は公開していないので、この実装が適切かどうか正直よく分かっていません。
ただ、今回は他にいい方法が思いつかなかったので、この形式で話を進めます。
よりよい方法がありましたら、ぜひアドバイスいただけますと幸いです🙇‍♂️


3. TabLayoutのイベントリスナー経由でScroll To Topのための処理を呼び出す

最後に、TabLayoutのイベントリスナー経由でScroll To Topのための処理を呼び出します。
現在選択されているタブを再度タップしたときはonTabReselected()が呼ばれるので、その中でscrollToTop()を呼び出します。

class MainActivity : AppCompatActivity() {

  private lateinit var binding: ActivityMainBinding

  override fun onCreate(savedInstanceState: Bundle?) {

    ...

    binding.tabs.addOnTabSelectedListener(object : SimpleOnTabSelectedListener() {
      override fun onTabReselected(tab: TabLayout.Tab?) {
        super.onTabReselected(tab)

        val tabPosition = tab?.position
        if (tabPosition != null) {
          val fragment = sectionsPagerAdapter.fragments.getOrNull(tabPosition)
          // ListScrollableを実装したFragmentのときだけ、scrollToTop()を呼び出します。
          (fragment as? ListScrollable)?.scrollToTop()
        }
      }
    })

    ...

  }

  ...

}


もしくは、FragmentStateAdapter内で各Fragmentは「"f" + holder.getItemId()」のタグ名でActivityに追加されているので、2.の対応を行わずに、supportFragmentManager.findFragmentByTag()を使って取得してもよさそうです。

android.googlesource.com


holder.getItemId()はAdapterのpositionを返却しています。 android.googlesource.com

class MainActivity : AppCompatActivity() {

  private lateinit var binding: ActivityMainBinding

  override fun onCreate(savedInstanceState: Bundle?) {

    ...

    binding.tabs.addOnTabSelectedListener(object : SimpleOnTabSelectedListener() {
      override fun onTabReselected(tab: TabLayout.Tab?) {
        super.onTabReselected(tab)

        val tabPosition = tab?.position
        if (tabPosition != null) {
          // ※先ほどのコードとの差分。タグ名に対応するFragmentがないときはnullが返却されます。
          val fragment = supportFragmentManager.findFragmentByTag("f$tabPosition")
          // ListScrollableを実装したFragmentのときだけ、scrollToTop()を呼び出します。
          (fragment as? ListScrollable)?.scrollToTop()
        }
      }
    })

    ...

  }

  ...

}


これで、TabLayout + ViewPager2でScroll To Topが実現できます。
なお、SimpleOnTabSelectedListenerは、TabLayout.OnTabSelectedListenerを実装したクラスとなっています。

open class SimpleOnTabSelectedListener : TabLayout.OnTabSelectedListener {
  override fun onTabSelected(tab: TabLayout.Tab?) {
    // This space for rent
  }

  override fun onTabUnselected(tab: TabLayout.Tab?) {
    // This space for rent
  }

  override fun onTabReselected(tab: TabLayout.Tab?) {
    // This space for rent
  }
}


サンプルコード

サンプルコードは以下になります。

github.com