Jetpack Composeでスタバアプリのようなスティッキーヘッダーを実装したい

# きっかけ

スタバのアプリのホーム画面をスクロールした時に、ぬるぬるっと動いて固定されるスティッキヘッダーを見て感動したので、これをJetpack Composeで表現できないかなと思い立ちました。

実際に書いてみて、それっぽいのができたので将来の自分への忘備録として残しておこうと思います。

# TL;DR

最終的なコード全体を確認したい場合は、次のリポジトリを参照してください。

https://github.com/takagimeow/sticky-header-sample

# LazyColumnとstickyHeader

スクロールしたときに、一番上に到達したらそのまま画面外にはみ出るのではなく、一番上で固定されるようなスティッキヘッダーを実装したい場合は、`LazyListScope`の`stickyHeader()`を使います。

https://developer.android.com/reference/kotlin/androidx/compose/foundation/lazy/LazyListScope#stickyHeader(kotlin.Any,kotlin.Any,kotlin.Function1)

そして、その固定したい中身を`stickyHeader()`の`content`引数を経由してラムダで渡します。

```kotlin
LazyColumn {
    stickyHeader {
        // ここにスティッキヘッダーの本体を書いていく
    }
}
```

# スタバのアプリの一番上の部分

スタバのアプリでは、画面の一番上に「おはようございます」などのメッセージが表示されるので、その部分を`TopAppBar`を使って実装していきます。

`LazyColumn`の要素として一番上に配置させたいので、まずは`item`を最初に呼び出します。そしてその中に`Spacer`と`TopAppBar`を配置します。

```kotlin
LazyColumn {
    item {
        Spacer(modifier = Modifier.height(32.dp))
        TopAppBar(
            title = {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(8.dp)
                ) {
                    Text(
                        "おはようございます",
                        style = MaterialTheme.typography.headlineLarge,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis
                    )
                }
            }
        )
        Spacer(modifier = Modifier.height(8.dp))
    }
}
```

# スティッキヘッダーの本体

次に、スティッキヘッダーの本体の部分を実装していこうと思います。
スタバ本家のアプリでは、**eTicket**と**Inbox**が並んで左端に、設定アイコンがポツンと一番右端に表示されています。これを再現するために、今回は`Row`を組み合わせて再現しようと思います。

```kotlin
stickyHeader {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.background)
            .padding(
                horizontal = 18.dp,
                vertical = 18.dp
            ),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Row() {
            Row(
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(
                    imageVector = Icons.Outlined.Info,
                    contentDescription = null
                )
                Spacer(modifier = Modifier.width(6.dp))
                Text(
                    text = "eTicket",
                    style = MaterialTheme.typography.titleMedium
                )
            }
            Spacer(modifier = Modifier.width(16.dp))
            Row(
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(
                    imageVector = Icons.Filled.Email,
                    contentDescription = null
                )
                Spacer(modifier = Modifier.width(6.dp))
                Text(
                    text = "Inbox",
                    style = MaterialTheme.typography.titleMedium
                )
            }
        }

        Icon(
            imageVector = Icons.Filled.Settings,
            contentDescription = null
        )
    }
}
```

今回は、アイコンなどを押したからといって、特定のアクションを実行したいわけではないので`IconButton`は使いませんでした。

# スクロール対象のコンテンツ

本家のアプリでは、ヘッダーの下にはバナー広告やリワードが表示されていますが、今回は単純にエミュレーターでスクロールできるまで`LazyColumn`の高さをスケールさせたいので、簡単なリストを準備してそれをコンテンツ代わりに表示したいと思います。

```kotlin
val dataSource by remember {
    mutableStateOf(
        listOf(
            "1",
            "2",
            "3",
            "4",
            "5",
            "6",
            "7",
            "8",
            "9",
            "10"
        )
    )
}
...
LazyColumn(
    state = listState
) {
    ...
    items(dataSource) { item ->
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(
                    horizontal = 8.dp
                )
                .height(120.dp)
                .background(MaterialTheme.colorScheme.primaryContainer),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(
                text = item,
            )
        }
        
        Spacer(modifier = Modifier.height(8.dp))
    }
}
```

そして、完成した実際のコンポーザブルは次のようになります。

```kotlin
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun StickyTopAppBarScreen() {
    val listState = rememberLazyListState()

    val dataSource by remember {
        mutableStateOf(
            listOf(
                "1",
                "2",
                "3",
                "4",
                "5",
                "6",
                "7",
                "8",
                "9",
                "10"
            )
        )
    }

    LazyColumn(
        state = listState
    ) {
        item {
            Spacer(modifier = Modifier.height(32.dp))
            TopAppBar(
                title = {
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(8.dp)
                    ) {
                        Text(
                            "おはようございます",
                            style = MaterialTheme.typography.headlineLarge,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis
                        )
                    }
                }
            )
            Spacer(modifier = Modifier.height(8.dp))
        }
        stickyHeader {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(MaterialTheme.colorScheme.background)
                    .padding(
                        horizontal = 18.dp,
                        vertical = 18.dp
                    ),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Row() {
                    Row(
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Icon(
                            imageVector = Icons.Outlined.Info,
                            contentDescription = null
                        )
                        Spacer(modifier = Modifier.width(6.dp))
                        Text(
                            text = "eTicket",
                            style = MaterialTheme.typography.titleMedium
                        )
                    }
                    Spacer(modifier = Modifier.width(16.dp))
                    Row(
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Icon(
                            imageVector = Icons.Filled.Email,
                            contentDescription = null
                        )
                        Spacer(modifier = Modifier.width(6.dp))
                        Text(
                            text = "Inbox",
                            style = MaterialTheme.typography.titleMedium
                        )
                    }
                }

                Icon(
                    imageVector = Icons.Filled.Settings,
                    contentDescription = null
                )
            }
        }
        items(dataSource) { item ->
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(
                        horizontal = 8.dp
                    )
                    .height(120.dp)
                    .background(MaterialTheme.colorScheme.primaryContainer),
                horizontalArrangement = Arrangement.Center,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = item,
                )
            }
            
            Spacer(modifier = Modifier.height(8.dp))
        }
    }
}
```

`TopAppBar`と`stickyHeader`は`Experimental`なので、必ず`@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)`をコンポーザブルに指定してください。

# 完成したもの

実際に動かすと次のような感じになります。

![名称未設定のデザイン (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/577119/44c74fc1-f5bf-d4d7-2cd8-2382c6bc93e9.gif)

# まとめ

僕は昔、React Nativeでスティッキヘッダーを再現してみたことがあるのですが、そのときはスクロール位置を取得して、スティッキヘッダーコンポーネントをそれに連動してアニメーションさせることで実装するみたいなことをした記憶があります。~~結構大変だった。。。~~

ところが今回、Jetpack Composeを使って同じようなスティッキヘッダーを表現しようとしてみた結果、ものの30分くらいで同じような表現を実装することができました。
とりあえずやってみて思ったのが、Jetpack Composeすごい！！！ってことです。

ぜひみなさんも、この書きやすさと表現力の豊かさを体験してみてもらいたいです。

# 参考

https://www.answertopia.com/jetpack-compose/jetpack-compose-sticky-headers-and-scroll-detection/