🗂

Jetpack Composeでスクロールとヘッダーを連動させる

2023/08/04に公開

はじめに

Coordinatorlayoutを Jetpack Compose で実装したい!
いろいろな CoordinatorLayout パターンで紹介されているように、CoordinatorLayout と言っても色々な種類があります。
この記事では、「画面の下に移動するとヘッダーが消える、画面の上に移動するとどの位置でもヘッダーが表示される」というレイアウトを Jetpack Compose で作成してみます。

コードは下記のリポジトリにありますのでよかったら確認してみだくさい。

LazyLayout のスクロール量を計算しよう

前述の通り、CoordinatorLayout と言っても、いくつかの種類があります。
しかし基本的に LazyColumn のスクロール量に応じて、特定のレイアウトのサイズや見た目が変更されます。
なので LazyColumn のスクロール量をリアルタイムで取得できれば良いのです。
 .
 .
LazyColumn のスクロール量を簡単に計算する方法がないのです。(あったら教えてください~)
そんな私は LazyListState からごり押しして現在のスクロール量をリアルタイムで取得する関数を実装してみました。
しかしその後、Modifier.scrollable 使ってみたらもっとシンプルに書けそうだなと思い実装したので、先にこちらを紹介します。
(LazyListStateから計算する方法はおまけに載せておきます。)

val lazyState = rememberLazyListState()
val scrollableState = rememberScrollableState(consumeScrollDelta = {
    lazyState.scrollBy(-it)  // need coroutine scope
    it
})
LazyColumn(
    modifier = Modifier
        .scrollable(
            orientation = Orientation.Vertical,
            state = scrollableState
        ),
    state = lazyState,
    userScrollEnabled = false
) {
    ...
}

Modifier.scrollableから取得したスクロール量に応じて、LazyColumn をスクロールさせます。
したがって、LazyColumn のuserScrollEnabledを false にしておきます。こうすることでScrollableStateから生のスクロール量を取得することができるのです。(scrollByは Coroutine が必要ですが、省略しています。)
若干トリッキーな方法かもしれません。

header の position を更新しよう

  1. ヘッダーの高さを dp と px で用意する。(ヘッダーの高さが動的の場合は Modifier.onSizeChanged などを使用して高さを取得したら OK だとおもいます。)
  2. ヘッダーの position を State として保持する。最初の位置を 0 とする。
  3. ScrollableStateのラムダでスクロールに応じて、headerPosition を更新する。そのときcoerceInで上限と下限を決めることで、「画面の下に移動するとヘッダーが消える、画面の上に移動するとどの位置でもヘッダーが表示される」を達成することができる。
val headerHeightDp = 49.dp
var headerPositionPx by remember { mutableStateOf(0) }
val density = LocalDensity.current
val headerHeightPx = with(density) { headerHeightDp.toPx() }.toInt()
val scrollableState = rememberScrollableState(consumeScrollDelta = {
    scope.launch { lazyState.scrollBy(-it) }
    headerPositionPx = (headerPositionPx + it.toInt()).coerceIn(-headerHeightPx, 0)
    it
})

Box(modifier = Modifier.fillMaxSize()) {
    LazyColumn(
        modifier = Modifier
            .scrollable(
                orientation = Orientation.Vertical,
                state = scrollableState
            ),
        contentPadding = PaddingValues(top = headerHeightDp),
        ...
    ) { ... }

    // Header
    Row(
        Modifier
            .offset { IntOffset(0, headerPositionPx) }
            .height(headerHeightDp)
    ) { ... }
}

成果物

全体のコード
@Composable
fun ComposeCoordinateLayoutSample() {
    val lazyState = rememberLazyListState()
    val scope = rememberCoroutineScope()
    val headerHeight = 56.dp
    var headerPositionPx by remember { mutableStateOf(0) }
    val density = LocalDensity.current
    val headerHeightPx = with(density) { headerHeight.toPx() }.toInt()
    val scrollableState = rememberScrollableState(consumeScrollDelta = {
        scope.launch { lazyState.scrollBy(-it) }
        headerPositionPx = (headerPositionPx + it.toInt()).coerceIn(-headerHeightPx, 0)
        it
    })

    Box(modifier = Modifier.fillMaxSize()) {
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .scrollable(
                    orientation = Orientation.Vertical,
                    state = scrollableState
                ),
            contentPadding = PaddingValues(top = headerHeightDp),
            state = lazyState,
            userScrollEnabled = false
        ) {
            lazyItems()
        }
        Row(
            Modifier
                .offset { IntOffset(0, headerPositionPx) }
                .height(headerHeightDp)
                .fillMaxWidth()
                .background(Color(0xFFEEEEEE))
            , horizontalArrangement = Arrangement.Center
            , verticalAlignment = Alignment.CenterVertically
        ) {
            Text(text = "HEADER")
        }
    }
}

まとめ

Modifier.scrollableを使って、割とすっきりと書くことができました。この方法を応用して、様々な CoordinaterLayout like なレイアウトが作成できるかなと思います。

この記事を書いているときに、公式ドキュメントのMigrate CoordinatorLayout to Composeを見つけました。記事によるとIn Compose, the closest equivalent of a CoordinatorLayout is a Scaffold.とのことで、Scaffold を使用しても似たような実装ができるのかもしれません。
(やった方いたら教えてください...!)

いずれにせよ公式から、CoordinatorLayout Compose みたいなのが出れば楽ちんですね。

おまけ

LazyListStateのスクロール量から HeaderPosition を計算する

場合によっては LazyListState から値をほじくる方法も使えるかもしれません。とりあえず下記の実装で Modifier.scrollable を使った方法と同じような実装はできましたが、こっちのパフォーマンスはあまりよくないかもしれません。。

LazyListState のスクロール量から HeaderPosition を計算する
@Composable
fun LazyListState.headerPosition(headerHeightPx: Int): Int {
    var previousIndex = remember(this) { firstVisibleItemIndex }
    var previousScrollOffset = remember(this) { firstVisibleItemScrollOffset }
    var previousItemHeight = remember(this) { heightAtOrZero(firstVisibleItemIndex) }
    var scrollAmount = remember(this) { 0 }
    return remember(this) {
        derivedStateOf {
            scrollAmount += if (previousIndex == firstVisibleItemIndex) {
                firstVisibleItemScrollOffset - previousScrollOffset
            } else {
                if (previousIndex > firstVisibleItemIndex) {
                    val currentScroll = heightAtOrZero(firstVisibleItemIndex) - firstVisibleItemScrollOffset
                    -previousScrollOffset - currentScroll
                } else {
                    val lastScroll = previousItemHeight - previousScrollOffset
                    lastScroll + firstVisibleItemScrollOffset
                }
            }.also {
                previousIndex = firstVisibleItemIndex
                previousScrollOffset = firstVisibleItemScrollOffset
                previousItemHeight = heightAtOrZero(firstVisibleItemIndex)
            }
            scrollAmount = scrollAmount.coerceIn(0, headerHeightPx)
            scrollAmount
        }
    }.value
}


fun LazyListState.heightAtOrZero(index: Int): Int {
    return layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }?.size ?: 0
}
GitHubで編集を提案

Discussion