DMM.comの、一番深くておもしろいトコロ。

Jetpack Composeで自動でスクロールする無限スクロール可能な横方向のPagerを実装する

Jetpack Composeで自動でスクロールする無限スクロール可能な横方向のPagerを実装する

  • このエントリーをはてなブックマークに追加

はじめに

この記事は、DMMグループ Advent Calendar 2022の18日目の記事です。

こんにちは。動画配信事業部でAndroidエンジニアをしている、新卒1年目の富山(@yt8492)です。

先日、DMM TVというサービスがリリースされました!

tv.dmm.com

自分もAndroidエンジニアとしてDMM TVの開発に関わっています。

さて、Android版DMM TVアプリは、Jetpack Composeを用いて開発しています。

今回は、DMM TVでも使っている、自動でスクロールする横方向のPagerの実装の紹介をしたいと思います。

Androidエンジニア以外の方には、スマホアプリでよくあるスライドするアレといえばわかるかもしれません。

今回作るもの

今回作るPagerの仕様としては、以下の通りです。

  • 時間経過で自動で横スライドする
  • 手動でもスライドできる
  • 端に到達して更にスライドすると逆の端に飛ぶ(無限にスクロールできる)

動作としては次のようになります。

AccompanistのHorizontalPagerについて

実装に入る前に、AccompanistのPagerについて紹介します。

Accompanist は、Googleが提供しているJetpack Compose向けのUtil集で、準標準ライブラリ的な位置づけになります。その中の1つに、 Pager layouts があります。

※執筆時点でのバージョンは 0.28.0 です。

AccompanistのPager layoutsが提供するAPIの1つに、 HorizontalPager があります。

これがまさに、水平方向のスクロールができるPagerなのですが、1つ問題があります。それは、無限スクロールができないという点です。

このように、最後の要素に到達して更にスクロールしようとしても、最初の要素に戻りませんね。

HorizontalPager の定義を見てみましょう。

@Composable
fun HorizontalPager(
    count: Int,
    modifier: Modifier = Modifier,
    state: PagerState = rememberPagerState(),
    reverseLayout: Boolean = false,
    itemSpacing: Dp = 0.dp,
    contentPadding: PaddingValues = PaddingValues(0.dp),
    verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
    flingBehavior: FlingBehavior = PagerDefaults.flingBehavior(
        state = state,
        endContentPadding = contentPadding.calculateEndPadding(LayoutDirection.Ltr),
    ),
    key: ((page: Int) -> Any)? = null,
    userScrollEnabled: Boolean = true,
    content: @Composable PagerScope.(page: Int) -> Unit,
)

引数がいろいろありますが、今回重要なのは

  • count: Int
  • state: PagerState
  • content: @Composable PagerScope.(page: Int) -> Unit

の3つです。

count は、Pagerのページ数を決定します。

state は、Pagerの状態を表します。 currentPage で現在のページのindexを取得できたり、 scrollToPage, animateScrollToPage でPagerをスクロールさせたりすることができます。

content は、Pagerに表示させるコンテンツのComposable関数です。 page が現在のページindexになるので、これで表示させるコンテンツを出し分けることができます。

さて、今回問題になっているのは無限スクロールができないことです。解決策としては、 HorizontalPagercount をIntの最大値にし、擬似的に無限スクロール可能とすることが考えられますが、 PagerStatecurrentPagecontentpage もそれに合わせた値になってしまうので、使う側としては直感的でないかつ不便です。

そこで今回は、無限スクロールを可能にしつつ、自動でスクロールもできるPagerを、AccompanistのHorizontalPagerをラップする形で実装します。

HorizontalInfiniteAutoScrollPagerの実装

水平方向に無限にかつ自動でスクロール可能なPagerということで、 HorizontalInfiniteAutoScrollPager と命名しました。今回はそれを実装していきます。

APIを考える

今回実装するものを、まずは呼び出し側から見たAPIから考えていきましょう。

参考として、まずはAccompanistのHorizontalPagerを見てみましょう。

val state = rememberPagerState(
  initialPage = 0,
)
HorizontalPager(
  count = 5,
  state = state,
) { page ->
  // pageによる出し分け
}

rememberPagerState でPagerStateを生成・保持し、それを HorizontalPager に渡しています。 個人的にはページ数と初期ページを別々のところで指定しているのが気になるところですね。

さて、上記を参考に HorizontalInfiniteAutoScrollPager を呼び出し側から見た時にどのようになるかを考えてみます。

まずstateの生成ですが、今回はページ数、初期ページの他に、自動スクロールのために必要な情報であるスクロール間隔も渡せるようにします。

val state = rememberInfiniteAutoScrollPagerState(
  pageCount = 5
  initialPage = 0,
  durationMillis = 5000,
)

Pagerのほうは、先程のstateを単純に受け取り、pageを引数に取るラムダにPager内に表示するUIを記述するという形になります。

HorizontalInfiniteAutoScrollPager(
  state = state,
) { page ->
  // pageによる出し分け
}

これらをもとに、実装していきます。

実装を考える

今回は、Accompanistの HorizontalPager をラップして実装します。前述の通り、 HorizontalPager にはIntの最大値をページ数として渡すので、外から見たときのページ数と内部の状態のページ数に乖離が発生することになります。

そこで今回は、 HorizontalInfiniteAutoScrollPager 用のstateを、Accompanistの PagerState をラップする形で実装し、PagerStateがもつページの状態から外から見たときのページの状態を計算するようにします。

InfiniteAutoScrollPagerState

HorizontalInfiniteAutoScrollPager 用のstateを実装します。このstateが管理する情報は以下の通りです。

  • 使う側から見たときのページ数
  • 使う側から見たときの初期ページ
  • 使う側から見たときの現在のページ
  • 自動スクロールの間隔(ミリ秒)
  • 内部のPagerで扱うPageState

今回は、これをdata classとして定義します。

@OptIn(ExperimentalPagerApi::class)
data class InfiniteAutoScrollPagerState(
  val pageCount: Int,
  val initialPage: Int,
  val currentPage: Int,
  val durationMillis: Int,
  val pagerState: PagerState,
)

これを生成・保持するComposable関数を実装します。

@OptIn(ExperimentalPagerApi::class)
@Composable
fun rememberInfiniteAutoScrollPagerState(
  pageCount: Int,
  initialPage: Int,
  durationMillis: Int,
): InfiniteAutoScrollPagerState

ページが切り替わった時に発火させるコールバックも渡せるようにしています。

さて、それでは関数の中身の実装です。

ページの計算

今回は、内部的なページ数をIntの最大値にして擬似的な無限スクロールを実現しますが、前にも後ろにもスクロールできるようにしたいので、0ページ目は内部的にはIntの最大値/2となります。

val startIndex = Int.MAX_VALUE / 2 + initialPage
val pagerState = rememberPagerState(
  initialPage = startIndex,
)

内部的に扱っているcurrentPageから、使う側から見たときのcurrentPageを計算します。

val currentPage = (pagerState.currentPage - Int.MAX_VALUE / 2).mod(pageCount)

これで、無限スクロールに伴う内部的なページと使う側から見たページの乖離の問題は解決できました。

自動スクロールの実装

次に、自動スクロールの実装です。まずは実装を貼ります。

val scope = rememberCoroutineScope()
val scrollTo = { state: PagerState , page: Int ->
  scope.launch {
    if (!state.isScrollInProgress) {
      pagerState.animateScrollToPage(page)
    }
  }
}
LaunchedEffect(pagerState.currentPage) {
  onChangedPage?.invoke(currentPage)
  delay(durationMillis.toLong())
  scrollTo(pagerState, pagerState.currentPage + 1)
}

LaunchedEffectcurrentPage の変更を検知し、ページが切り替わった時のコールバックを発火させ、 delay ののち pagerState.animateScrollToPage で次のページにスクロールさせるというシンプルなロジックですが、スクロール部分を LaunchedEffect のCoroutineScopeではなくわざわざ rememberCoroutineScope で取得したscopeで行っているのに疑問を持った人もいるのではないでしょうか?

これは、Kotlin CoroutinesとLaunchedEffectの仕様が関係しています。 LaunchedEffect のkeyに指定している pagerState.currentPage が、 pagerState.animateScrollToPage(page) を呼んだ際にスクロールのアニメーションが終わる前に値が変化するために、 LaunchedEffect がその変化を検知し、以前のkeyのcoroutineのjobをcancelして新しいkeyのjobを開始します。 pagerState.animateScrollToPage はsusupend関数であるため、この際にスクロール最中にcancelされることになり、結果としてスクロールが中途半端になってしまうという現象が発生します。

これの対策として、 pagerState.animateScrollToPageLaunchedEffect とは別のscopeで呼ぶことで LaunchedEffect のscopeのcancelに影響させずにスクロールさせています。

最後に、このComposable関数の返り値として InfiniteAutoScrollPagerState を返せば完成です。

return InfiniteAutoScrollPagerState(
  pageCount,
  initialPage,
  currentPage,
  durationMillis,
  pagerState,
)

完成したrememberInfiniteAutoScrollPagerState関数

@OptIn(ExperimentalPagerApi::class)
@Composable
fun rememberInfiniteAutoScrollPagerState(
  pageCount: Int,
  initialPage: Int,
  durationMillis: Int,
  onChangedPage: ((Int) -> Unit)? = null,
): InfiniteAutoScrollPagerState {
  val startIndex = Int.MAX_VALUE / 2 + initialPage
  val pagerState = rememberPagerState(
    initialPage = startIndex,
  )
  val currentPage = (pagerState.currentPage - Int.MAX_VALUE / 2).mod(pageCount)
  val scope = rememberCoroutineScope()
  val scrollTo = { state: PagerState , page: Int ->
    scope.launch {
      if (!state.isScrollInProgress) {
        pagerState.animateScrollToPage(page)
      }
    }
  }
  LaunchedEffect(pagerState.currentPage) {
    onChangedPage?.invoke(currentPage)
    delay(durationMillis.toLong())
    scrollTo(pagerState, pagerState.currentPage + 1)
  }
  return InfiniteAutoScrollPagerState(
    pageCount,
    initialPage,
    currentPage,
    durationMillis,
    pagerState,
  )
}

HorizontalInfiniteAutoScrollPager

こちらは単純です。 HorizontalPagercount にIntの最大値を、 state に先程実装した InfiniteAutoScrollPagerState が保持している PagerState を渡し、 現在の page から使う側から見たときのcurrentPageを計算して content に渡しています。

@ExperimentalPagerApi
@Composable
fun HorizontalInfiniteAutoScrollPager(
  state: InfiniteAutoScrollPagerState,
  contentWidth: Dp? = null,
  itemSpacing: Dp = 0.dp,
  content: @Composable PagerScope.(Int) -> Unit,
) {
  BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
    val contentPadding = if (contentWidth != null) {
      PaddingValues(horizontal = (maxWidth - contentWidth) / 2)
    } else {
      PaddingValues(0.dp)
    }

    HorizontalPager(
      count = Int.MAX_VALUE,
      state = state.pagerState,
      contentPadding = contentPadding,
      itemSpacing = itemSpacing,
      modifier = Modifier.fillMaxWidth(),
    ) { index ->
      val i = (index - Int.MAX_VALUE / 2).mod(state.pageCount)
      content(i)
    }
  }
}

おわりに

今回は、DMM TVでも使っている、自動でスクロールする横方向のPagerの実装の紹介をしてみました。いかがでしたか?このようなPagerはいろんなアプリで見かけるので、実装する際の参考になればと思います。

宣伝

DMM TVをよろしくお願いします!

tv.dmm.com