Jetpack Composeでスクロールとヘッダーを連動させる
はじめに
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 を更新しよう
- ヘッダーの高さを dp と px で用意する。(ヘッダーの高さが動的の場合は
Modifier.onSizeChanged
などを使用して高さを取得したら OK だとおもいます。) - ヘッダーの position を State として保持する。最初の位置を 0 とする。
-
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
}
Discussion