詳解! ComposeでNestedScroll その4 標準API編

この記事をシェア

ComposeのNestedScrollについて詳しく解説するシリーズの第4回です。今回は、Composeの標準APIでNestedScrollを実現する方法を確認します。今回のソースコードもGitHubに公開しています。(https://github.com/usuiat/NestedScrollSamples

第1回はこちら。NestedScrollの基本的な仕組みを説明しました。
第2回はこちらNestedScrollConnectionの実装を説明しました。
第3回はこちらNestedScrollDiapatcherの実装を説明しました。

標準APIのススメ

第2回第3回で親コンポーネントと子コンポーネントの実装方法を紹介してきましたが、それなりに複雑で行数も多いコードになりました。特に第3回で紹介した子コンポーネントのNestedScrollDispatcherの実装は細かいケアが必要な部分が多いです。そのため、ネストスクロールの子コンポーネントは、なるべくLazyColumnなどネストスクロール対応済みの標準コンポーネントか、Modifier.scrollableModifier.verticalScrollなどの標準APIを使うことをお勧めします。

サンプル(再掲)

この記事では、第3回で紹介したNestedScrollDispatcherのサンプルを標準APIのModifierを使って書き換えてみます。

まずはサンプルの内容を振り返っておきます。LazyColumnにBoxを配置して、そのBoxの中に大きなコンテンツLargeContentを配置するというものでした。LargeContentのジェスチャーをpointerInputで検出し、NestedScrollDispatcherのメソッドを呼び出してネストスクロールを実現していました。ソースコードの行数は100行を超えて、あまり見通しが良いとは言えないコードになっていました。

Modifier.verticalScroll

では、上記サンプルをModifier.verticalScrollを使って書き直してみます。

@Composable
fun VerticalScrollSample(
) {
    val scrollAreaHeight = 600.dp
    val contentHeight = 1000.dp
    val scrollState = rememberScrollState()
    LazyColumn {
        item { GrayBox() }
        item {
            Box(
                contentAlignment = Alignment.TopCenter,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(scrollAreaHeight)
                    .verticalScroll(state = scrollState)
            ) {
                LargeContent(
                    modifier = Modifier.height(contentHeight)
                )
            }
        }
        item { GrayBox() }
    }
}

Modifier.verticalScrollScrollStateを引数にとります。ScrollStaterememberScrollStateで取得できます。

第3回のサンプルでは、Modifier.pointerInputを使ってドラッグジェスチャーを検出し、NestedScrollDispatcherでスクロールイベントを発行し、Modifier.graphicsLayerで実際のスクロールを処理していました。また、NestesScrollDispatcherでイベントを発行するために必要なスクロール速度の計算や座標の計算なども必要でした。しかし今回のサンプルでは、Modifier.verticalScrollがそれらの処理を引き受けてくれるので、格段に少ないコードで実装できました。

Modifier.scrollable

Modifier.verticalScrollは、内部でModifier.scrollableを利用しています。Modifier.scrollableは、Modifier.verticalScrollより一段階低いレイヤーのModifierになっています。次はこのModifier.scrollableを使うとどのような実装になるかを見ていきます。

@Composable
fun ScrollableSample() {
    val scrollAreaHeight = 600.dp
    val contentHeight = 1000.dp
    val offsetMax = with(LocalDensity.current) { (contentHeight - scrollAreaHeight).toPx() }
    var offset by remember { mutableFloatStateOf(0f) }

    val scrollableState = rememberScrollableState { delta ->
        val oldOffset = offset
        offset = (oldOffset + delta).coerceIn(-offsetMax, 0f)
        offset - oldOffset
    }

    LazyColumn {
        item { GrayBox() }
        item {
            Box(
                contentAlignment = Alignment.TopCenter,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(scrollAreaHeight)
                    .clipToBounds()
                    .scrollable(
                        state = scrollableState,
                        orientation = Orientation.Vertical,
                    )
            ) {
                LargeContent(
                    modifier = Modifier
                        .wrapContentHeight(align = Alignment.Top, unbounded = true)
                        .height(contentHeight)
                        .graphicsLayer { translationY = offset }
                )
            }
        }
        item { GrayBox() }
    }
}

さきほどよりは少しボリュームのあるソースコードになりましたが、これでもまだ第3回のコードよりはかなり見通しが良いです。

Modifier.scrollableは、OrientationScrollableStateの2つが必須の引数です。Orientationを指定することによって、縦スクロールと横スクロールの両方に対応可能です。

fun Modifier.scrollable(
    state: ScrollableState,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    flingBehavior: FlingBehavior? = null,
    interactionSource: MutableInteractionSource? = null
): Modifier

ScrollableStateは、rememberScrollableStateを使って取得することができます。rememberScrollableStateの引数のラムダに、スクロール検出時の動作を実装します。利用可能なスクロール量がPixelで渡されるので、対応するスクロール処理を記述して、実際に消費したスクロール量を返すようにします。このラムダは、ドラッグ中もフリング動作中も呼び出されるので、実装を1か所にまとめることができます。

@Composable
fun rememberScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableState

Modifier.scrollableModifier.verticalScrollとは異なり、検出したスクロールジェスチャーに対する処理は自分で実装する必要があります。コード量は増えますが、その分柔軟な実装が可能になると言えます。

Modifier.scrollableについて補足

Modifier.scrollableは、Composeのスクロール処理の根幹を担う関数で、さまざまなAPIの内部で利用されています。先ほどのModifier.verticalScrollの内部で使われている他にも、LazyColumnLazyRowなどのコンポーネントもModifier.scrollableを利用しています。

Modifier.scrollableNestedScrollConnectionNestedScrollDispatcherの両方を実装しています。したがって、ネストスクロールの親にも子にもなれます。

Modifier.scrollableを利用しているコンポーネントでスクロールジェスチャーが発生すると、そのコンポーネントはネストスクロールの子コンポーネントとしてふるまうので、NestedScrollDispatcherが下図のようにイベントを発行します。指が触れてドラッグしている間は、dispatchPreScrolldispatchPostScrollを発行します。このときのNestedScrollSourceDragです。指が離れてドラッグが終了すると、dispatchPreFlingを発行し、Decayアニメーションが継続している間はdispatchPreScrolldispatchPostScrollを発行します。このときのNestedScrollSourceFlingです。最後に、アニメーションが終了するとdispatchPostFlingを発行します。

Modifier.scrollableを親コンポーネントで利用している場合、NestedScrollConnectionが子コンポーネントからのイベントを受け取ってスクロールを処理します。Modifier.scrollable内のNestedScrollConnectiononPostScrollonPostFlingをoverrideしているので、子コンポーネントのスクロールやフリングの後に親コンポーネントがスクロールする動作になります。

標準APIで対応できないケース

ここまで見てきたように、Modifier.verticalScrollのような標準APIを利用すれば、簡単にNestedScrollを実現できます。では、標準APIで実現できないのはどのようなケースでしょうか。

複雑なジェスチャーとNestedScrollを組み合わせる場合

縦横自在にドラッグできるようなコンポーネントや、カスタムジェスチャーを検出するコンポーネントをNestedScrollに組み入れたい場合です。このようなジェスチャーは、Modifier.pointerInputを使って実装しますが、Modifier.pointerInputはそのままではNestedScrollをサポートしていないので、NestedScrollDispatcherNestedScrollConnectionを自前で実装する必要があります。

子コンポーネントより先に親コンポーネントをスクロールする場合

Modifier.scrollableやそれを利用したAPIは、PostScrollでNestedScrollのチェーンを形成しています。つまり、子コンポーネントがこれ以上スクロールできないという状態になって初めて、親コンポーネントがスクロールします。そのため、PreScrollを実装する必要がある場合は、自前での実装が必要になります。

ただし、TopAppBarについてはあらかじめ数種類のスクロール方式が用意されており、スクロール検出時にまずTopAppBarをスクロールしてから子コンポーネントのLazyColumnをスクロールする、といったような使い方ができるようになっています。第1回で紹介したサンプルが一例です。


以上、今回は標準APIを使ったNestedScrollについて説明しました。

この記事をシェア