スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

Jetpack ComposeでタブをSticky Headerにする方法

こんにちは。Androidエンジニアの@morux2です。先日スタディサプリ中学講座まなレポ機能が追加されました🎉 まなレポは学習状況・学習履歴を確認できる機能です。リアルタイムで学習状況が表示されるので、学習後すぐに成果の振り返りをしていただくことができます。

この記事ではまなレポ機能の実装の裏側をご紹介します。画面はJetpack Composeで構築しています。

まなレポ画面

まなレポ画面の概要

画面要件は大きく2つあります。

  • 日と週のタブを切り替えて、日次・週次の学習履歴を確認できる

  • タブがSticky Headerになる(スクロールするとステータスバーの下にタブが張り付く)

ここからは2つの画面要件をどう実現したかお話ししていきます。

Sticky Header

Jetpack Composeでタブを切り替えられるようにする

タブにはTabRowを用いています。選択されたタブの状態を保持し、when式で描画内容を制御します。こちらの実装は公式のサンプルプロジェクトを参考にしています。*1

@Composable
fun StudyReportScreen() {
    var tabSelected by rememberSaveable { mutableStateOf(Screen.DAILY) }
    Column {
        TabRow(
            selectedTabIndex = tabSelected.ordinal
        ) {
            Screen.values().map { it.name }.forEachIndexed { index, title ->
                Tab(
                    text = { Text(text = title) },
                    selected = tabSelected.ordinal == index,
                    onClick = { tabSelected = Screen.values()[index] }
                )
            }
        }
        when (tabSelected) {
            Screen.DAILY -> DailyScreen()
            Screen.WEEKLY -> WeeklyScreen()
        }
    }
}

enum class Screen {
    DAILY, WEEKLY
}

@Composable
fun DailyScreen() {}

@Composable
fun WeeklyScreen() {}

タブをスワイプによって切り替えたい場合はaccompanistHorizontalPagerを用いて実現ができます。*2しかしHorizontal Pagerの採用は見送りました。DailyScreenとWeeklyScreenのComposable関数にログを仕込んだ結果、初回描画や画面回転時にDailyだけでなくWeeklyもComposition処理が走ってしまうこと*3が確認されたためです。

タブをSticky Headerにする

LazyListScopeでstickyHeader関数を用いると、要素をステータスバーの下に吸着させることができます。*4今回は画面全体をLazyColumnで括り、TabRowをstickyHeaderのブロックに入れました。DailyScreenとWeeklyScreenで描画する学習履歴は、学習状況によって要素数が変動します。LazyColumnにサイズが固定値でないLazyColumnをネストさせることができないので、DailyScreenとWeeklyScreenは、LazyListScopeな関数に書き換えています。

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StudyReportScreen() {
    var tabSelected by rememberSaveable { mutableStateOf(Screen.DAILY) }
    LazyColumn {
        item {
            Text(
                modifier = Modifier.padding(vertical = 100.dp).fillMaxWidth(),
                textAlign = TextAlign.Center,
                text = "collapsed!"
            )
        }
        stickyHeader {
            TabRow(
                selectedTabIndex = tabSelected.ordinal
            ) {
                Screen.values().map { it.name }.forEachIndexed { index, title ->
                    Tab(
                        text = { Text(text = title) },
                        selected = tabSelected.ordinal == index,
                        onClick = { tabSelected = Screen.values()[index] }
                    )
                }
            }
        }
        when (tabSelected) {
            Screen.DAILY -> dailyScreen(this@LazyColumn)
            Screen.WEEKLY -> weeklyScreen(this@LazyColumn)
        }
    }
}

enum class Screen {
    DAILY, WEEKLY
}

fun dailyScreen(
    lazyListScope: LazyListScope
) {
    with(lazyListScope) {
        item {
            ...
        }
    }

fun weeklyScreen(
    lazyListScope: LazyListScope
) {
    with(lazyListScope) {
        item {
            ...
        }
    }

ここまでの実装でタブをSticky Headerにすることができました。しかし、1つのLazyColumn(lazyListState)をDailyScreenとWeeklyScreenで共有しているので、タブを超えてスクロールが連動してしまうという問題が起きました。

スクロールが連動してしまう様子

この問題の回避策として、タブを切り替えた時にスクロール位置をタブまでリセットするという処理を加えました。LaunchedEffectのkeyにタブの選択状態を渡すことで、タブが切り替わった時のみスクロール位置をリセットすることができます。

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StudyReportScreen() {
    val lazyListState = rememberLazyListState()
    LazyColumn(
        state = lazyListState
    ) {
        stickyHeader(key = "stickyHeader") {
            ...
        }
    }
    LaunchedEffect(tabSelected) {
        val stickyHeaderIndex =
            lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "stickyHeader" }?.index
                ?: return@LaunchedEffect
        if (lazyListState.firstVisibleItemIndex > stickyHeaderIndex) {
            lazyListState.scrollToItem(stickyHeaderIndex)
        }
    }
}

タブ切り替えのたびにスクロール位置をリセット

最後に

Jetpack Composeを用いることで手軽にSticky Headerを実装することができました。スワイプ処理(Pager)の実現はやや複雑で難しさもありますが、CoordinatorLayoutを用いるよりも手軽に試すことができて良かったです。サンプルコードはGitHub - morux2/StickyTabHeaderSampleで公開しています。

スタディサプリでは、一緒に最高のプロダクトを作っていってくれる仲間を募集しています! 少しでもご興味がある方はこちらのページからご連絡ください! brand.studysapuri.jp

*1:https://github.com/android/compose-samples/tree/master/Crane

*2:Horizontal Pagerは2023年2月現在experimentalです

*3:HorizontalPagerでは内部的にLazyRowが使われているためです

*4:stickyHeader関数は2023年2月現在experimentalです