19
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

and factory.incAdvent Calendar 2022

Day 9

【Jetpack Compose】Pagerを使った画面でもCollapsing Toolbarを実装したい

Posted at

この記事はand factory.inc Advent Calendar 2022 9日目の記事です。
昨日は @KKusumi さんの 【Android】Android StudioのPullRequest機能が便利すぎるので絶対使った方がいい【IDE】でした。

Jetpack Composeで折りたたみツールバーを実装したい

Android Viewで折りたたみツールバーを実装するときは、CoordinatorLayoutやCollapsingToolbarLayoutを使うことで実装することができました。

Jetpack Composeではまだそのようなものは用意されてはいません。
現時点で僕が今把握しているCollapsing処理を実装するにあたって参考になるものは以下のようなものがあります。

1. Material3のTopAppBarでのcollapsingサポート

Material3のTopAppBarでは、scrollBehavior を使うことでスクロール時にMaterial3の折りたたみ処理をすることができます。
注意点としては、Material3パッケージで提供されている点(Material2から移行しておく必要がある)と、あくまでもMaterial3の挙動という点です。
Android ViewのMaterial2時代の折りたたみ処理ではないため、期待する挙動かどうかはよく確認してください。
(自分は頑張ってM3対応したあとに、思ってたものではなくてガックシしてしまいました。。笑)

また、この記事のテーマでもある、AccompanistのPager layoutを使っている場合、うまく動きませんでした。。。

2. NestedScrollConnection

Modifier.nestedScroll と NestedScrollConnectionを使って自分で実装する方法です。

スクロールに連動してなにか処理をしたい。スクロール要素でない箇所に対しても伝搬して処理がしたいなど、折りたたみツールバーのようなものを実装するときに使うものになっています。
(他にもスクロールに連動してFABを見え隠れする実装するときなど)

上のnestedScrollのページには公式の折りたたみツールバーの実装方法が書いてあるので、それを参考に自分で実装することで簡単な折りたたみツールバーの実装をすることができます。

こちらも欠点としては、ドキュメント記載の実装方法はだいぶ特定の実装に依存している気がします。そのため、参考にして実装してみて上手くいかない場合は自分で解決する必要があります。
APIはあるけど、自分で実装するって感じです。

ちょっと大変かもしれませんが、今のところはこのNestedScrollを使って実装するのがおすすめです。(おすすめというか公式でそう書いてあるし。)

3. compose-collapsing-toolbarを使う

一番スター数の多いライブラリです。これで上手く動くのであれば使うのが一番手っ取り早いかもです。

Pager構成の画面でCollapsingしたい

で、本題のPager構成の画面なのですが、色々やってみて結局NestedScrollConnectionを使って自分で実装するしかないかなという感じでした。
ただ、どうしてもAndorid Viewでサポートされているような、スクロールするとその移動量に合わせてツールバーを隠す処理は難しく、これではどうか。。?という提案でアニメーションでそれっぽく実装してみました。

それがこんな感じです。

202073211-d4dea9aa-1519-4f69-a863-4894e9776918_AdobeExpress.gif

ぱっと見は気づかないかもしれませんが、 AnimatedVisibility を使ってshow/hide切り替えの動きで実装しています。
結構触ってみるといい感じなので、厳密に同じ挙動を求めていない場合はありかもしれません。

ソースコードはこちらで公開しています。

簡単な解説

NestedScrollConnectionには以下のように使いますが、これを使うことで子要素のスクロールイベントが発生した時に移動量を取得することができます。
その移動量をみて、下向きならhide、上向ならshowとすることで実装しています。

val density = LocalDensity.current

val playDistance = with(density) { 12.dp.toPx() }
var isShowTopBarArea by remember { mutableStateOf(true) }

val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            if (available.y.absoluteValue > playDistance) {
                isShowTopBarArea = available.y > 0
            }
            return Offset.Zero
        }
    }
}

playDistanceという12pxの変数を使っていますが、これは移動量が12px以上の時だけ処理するようにするいわゆる "遊び" です。
これがないとちょっとでも触れた瞬間にアニメーションが発火してしまって違和感がすごいので使ってます。

上の例は、下方向にスクロールしたらすぐにhideして、上方向にスクロールすると即座にshowする、Enter alwaysの挙動の場合の書き方です。

下方向はすぐに、上方向は一番上までスクロールしたら、というexit untilの挙動を実装する場合はもうちょっと複雑に実装する必要があります。

val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0)
val lazyListStates = listOf(
    rememberLazyListState(), rememberLazyListState(), rememberLazyListState()
)

val density = LocalDensity.current

val playDistance = with(density) { 12.dp.toPx() }
var isShowTopBarArea by remember { mutableStateOf(true) }

val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            lazyListStates.getOrNull(pagerState.currentPage)?.let { lazyListState ->
                if (available.y > 0 && lazyListState.firstVisibleItemIndex == 0) {
                    // 一番上の要素が表示されたので表示
                    isShowTopBarArea = true
                } else {
                    if (available.y.absoluteValue > playDistance && available.y < 0) {
                        isShowTopBarArea = false
                    }
                }
            }

            return Offset.Zero
        }
    }
}

スクロール量はNestedScrollConnectionで取得することができますが、スクロール位置が一番上にきたかどうかを取得するにはLazyListStateのようなLazyListのStateから取得する必要があります。
また、今回のようなPager構成の場合、Tabの分だけLazyListStateを使う必要があるためリストを用意してあげて、pagerStateから現在選択されているTabのLazyListStateを取得する必要があります。
まぁ、力技での実装です😂

collapsing部分(例えば画像部分やツールバー部分)をスワイプしても発火させたい場合は、その要素のModifierに scrollable を付けてあげればOKです。

Image(
    painter = painterResource(id = R.drawable.banner),
    contentScale = ContentScale.FillWidth,
    contentDescription = null,
    modifier = Modifier
        .fillMaxWidth()
        // enable event when scroll image.
        .scrollable(
            orientation = Orientation.Vertical,
            state = rememberScrollableState { it }
        )
)

scrollableを付けて上げることでイベントが伝搬してくれるのでNestedScrollConnectionに値が流れてくるようになります。

Animationじゃなく実装したい

いや、Animationじゃなくて指の動きに合わせた動きにしたい!という場合でも、NestedScrollConnectionを使います。
例えば移動量をそのまま Modifier.height として指定してあげればそれっぽい感じになりました。

ただし上手くいかなかったところもあるので書いておきます。

  • Enter alwaysはいい感じにできたが、Exit untilの動きは上手く実装できなかった(一番上に行った後にスクロールしないためheight分の高さの移動量を取得できなかったため)
  • heightを変更するので、潰れていくような動きになる。
  • 理想的な動きは Modifier.offset を使う。ただしoffsetは実際のComposeの位置を動かさないため、そこを解決する必要がある(できなかった)。
  • Animationとは違い、対象の最大height値が必要。もしも高さが不定の場合は難しくなる。

おわりに

色々頑張って最後に辿り着いたのがこのAnimationでの実装でした。
でもこのやり方だとより複雑な条件や動きのときに困ることになりそうです。

このQiitaを元に、もっと洗練された実装がたくさん公開されたらいいなぁと思います :smile:

19
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?