Jetpack ComposeとCoordinatorLayoutを組み合わせる

ヒロとジェームス

こんにちは、id:numanuma08です。完全に梅雨ですね。ツーリングや登山をしたいですが、天候に恵まれません。

今日はタイトルのとおり、Jetpack ComposeとCoordinatorLayoutを組み合わせてスクロールしたときにツールバーを画面上部に固定したり隠したりします。

そもそもCoordinaltorLayoutを使うべきかどうか

Jetpack Composeもどんどん機能が増えて、AccompanistやMaterial 3の実装が充実してきています。

google.github.io

m3.material.io

その一方で、既存のアプリにJetpack Composeを組み込んだりMaterial 3に準拠していないデザインを実装するケースもあります。今回はそんな要件です。

スクロールされて消えるツールバーと残るタブ

画面上部にあるツールバーはメインコンテンツのスクロールに連動して画面外に行くが、タブは画面内に残るパターンです。つまり、次の動画の動作。

LargeToolbarとの組み合わせでなんとかできるのかな?と調べましたが、どうやらCoordinatorLayoutを使って実装したほうが速そうでした。このブログを書いている時点(2022/06/22)でまだ試験的実装ですが、CoordinatorLayout内にスクロール可能な要素を配置するためのrememberNestedScrollInteropConnection()を使うと実現可能です。

androidx.compose.ui.platform  |  Android Developers

Gestures  |  Jetpack Compose  |  Android Developers

レイアウト

CoordinatorLayoutを親にして中にメインコンテンツのためのComposeView、AppBarLayout、さらにその中にAppBarやタブを表示するためのComposeViewを含めます。

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <!-- スクロール時に画面外に消える要素を描画する領域 -->
        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/header_content"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_scrollFlags="scroll|enterAlwaysCollapsed" />

        <!-- スクロールしても画面内に残る領域 -->
        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/toolbar_content"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </com.google.android.material.appbar.AppBarLayout>

    <!-- 画面全体に配置するスクロール可能なコンテンツを描画する領域 -->
    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/main_content"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

画面外に消える要素にはapp:layout_scrollFlags="scroll|enterAlwaysCollapsed"を指定します。あとは、それぞれのComposeViewに対して要素を宣言します。

@Composable
fun MainScreen() {
    val pagerState = rememberPagerState()
    val coroutineScope = rememberCoroutineScope()

    AndroidView(factory = { context ->
        val layout = View.inflate(context, R.layout.screen_main, null)
        with(layout.findViewById<ComposeView>(R.id.header_content)) {
            setContent {
                // スクロールすると画面外に消えるAppBar
                SmallTopAppBar(
                    title = { Text(text = "this is title") }
                )
            }
        }
        with(layout.findViewById<ComposeView>(R.id.toolbar_content)) {
            setContent {
                // スクロールしても画面内に残るタブ
                TabRow(selectedTabIndex = pagerState.currentPage) {
                    repeat(3) {
                        Tab(
                            selected = pagerState.currentPage == it,
                            onClick = {
                                coroutineScope.launch {
                                    pagerState.scrollToPage(it)
                                }
                            }) {
                            Text(text = "tab $it")
                        }
                    }
                }
            }
        }
        with(layout.findViewById<ComposeView>(R.id.main_content)) {
            setContent {
                HorizontalPager(count = 3, state = pagerState) { page ->
                    // スクロール可能な要素。 rememberNestedScrollInteropConnection を使うと
                    // CoordinatorLayout と連携した動作が実現できる
                    val nestedScrollInterop = rememberNestedScrollInteropConnection()
                    LazyColumn(
                        modifier = Modifier.nestedScroll(nestedScrollInterop),
                        state = rememberLazyListState()
                    ) {
                        items(20) { item ->
                            Box(
                                modifier = Modifier
                                    .padding(16.dp)
                                    .height(56.dp)
                                    .fillMaxWidth()
                                    .background(Color.Gray),
                                contentAlignment = Alignment.Center
                            ) {
                                Text(item.toString())
                            }
                        }
                    }
                }
            }
        }
        layout
    })
}
  • スクロールしたときに画面外に消える部分
  • スクロールしても画面内に残る部分
  • スクロールするコンテンツ

それぞれをComposeViewで宣言して、レイアウトを組むことで目的を達成できました。

まとめと感想

Jetpack ComposeとCoordinatorLayoutを使ってスクロール時に画面外に消える部分と残る部分があるツールバーを実現しました。

個人的にはJetpack Composeで完結する仕組みになっていたらシンプルで良かったのにと思いました。実際探すと自前でlayoutを組んでJetpack Compose単体で完結しているサンプルもありました。しかし、今回のやり方のほうがよりシンプルな方法で目的を達成できたと思っています。

参考

独自のlayoutを使って同じことを実現しているサンプル

github.com