詳解! ComposeでNestedScroll その2 Connection編

この記事をシェア

ComposeのNestedScrollについて詳しく解説するシリーズの第2回です。今回は、親コンポーネント側のNestedScrollConnectionの実装を説明していきます。

第1回はこちら。NestedScrollの基本的な仕組みを説明しました。

サンプル

今回のサンプルは、LazyColumnのスクロールに合わせて画面上部のエリアを伸縮するものです。同じような動きはMaterial3のTopAppBarでも実現できますが、今回はこれを手作りします。ソースコードはGitHubに公開しています。(https://github.com/usuiat/NestedScrollSamples

上スクロール(指を下から上へ移動)するときは、LazyColumnのスクロールの前に画面上部のGrayBoxが縮みます。反対に、下スクロール(指を上から下に移動)するときはLazyColumnのスクロールの後でGrayBoxが広がります。フリングにも対応しています。

画面の構成

画面を構成するコンポーザブルのコードは以下のようになっています。Boxの中にGrayBox(中身はシンプルなBoxです)とLazyColumnを配置しています。

@Composable
fun ConnectionSampleScreen(scrollState: ConnectionSampleScrollState) {
    Box(
        modifier = Modifier
            .fillMaxSize().nestedScroll(scrollState.nestedScrollConnection)
    ) {
        GrayBox(modifier = Modifier.height(scrollState.offset))
        LazyColumn(
            modifier = Modifier.fillMaxSize().offset(y = scrollState.offset)
        ) {
            items(50) {
                Text(
                    text = "Item $it",
                    modifier = Modifier.fillMaxWidth().height(40.dp)
                )
            }
        }
    }
}

ConnectionSampleScrollStateはスクロール状態管理クラスです。この記事では2種類の実装を紹介するので、下記のようにインターフェースを定義しました。

interface ConnectionSampleScrollState {
    val nestedScrollConnection: NestedScrollConnection
    val offset: Dp
}

親コンポーネントであるBoxに対してModifier.nestedScrollNestedScrollConnectionをセットすることにより、子コンポーネントであるLazyColumnからスクロールイベントを受け取ります。そして、ConnectionSampleScrollState内でoffsetを変更することによって、親コンポーネント内での子コンポーネントの位置を変更し、NestedScrollを実現しています。

なお、LazyColumnは内部でModifier.scrollableを使っており、scrollableにはNestedScrollDispatcherが実装されています。したがって、NestedScrollDispatcherを自前で実装する必要はありません。

NestedScrollConnectionの実装例1

では、ConnectionSampleScrollState内のNestedScrollConnectionの実装を見ていきます。まずは1パターン目の実装例です。ConnectionSampleScrollStateImpl1rememberで作成して、上で説明したConnectionSampleScreenに渡しています。

@Composable
fun ConnectionSample1() {
    val density = LocalDensity.current
    val scrollState = remember {
        ConnectionSampleScrollStateImpl1(
            maxOffset = 200.dp,
            initialOffset = 200.dp,
            density = density,
        )
    }
    ConnectionSampleScreen(scrollState = scrollState)
}

@Stable
class ConnectionSampleScrollStateImpl1(
    maxOffset: Dp,
    initialOffset: Dp,
    private val density: Density,
) : ConnectionSampleScrollState {
    private val maxOffsetPx = with(density) { maxOffset.toPx() }
    private val initialOffsetPx = with(density) { initialOffset.toPx() }
    private var _offsetPx by mutableFloatStateOf(initialOffsetPx)
    override val offset: Dp
        get() = with(density) { _offsetPx.toDp() }

    override val nestedScrollConnection = object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            if ((available.y >= 0f) or (_offsetPx <= 0f)) return Offset.Zero
            val consumedY = doScroll(available.y)
            return Offset(0f, consumedY)
        }

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            if ((available.y <= 0f) or (_offsetPx >= maxOffsetPx)) return Offset.Zero
            val consumedY = doScroll(available.y)
            return Offset(0f, consumedY)
        }
    }

    private fun doScroll(delta: Float): Float {
        val oldOffset = _offsetPx
        _offsetPx = (_offsetPx + delta).coerceIn(0f, maxOffsetPx)
        return _offsetPx - oldOffset
    }
}

offsetのバッキングフィールドはMutableFloatStateを使っています。スクロールイベントはピクセルで扱うのに対し、コンポーネントの配置はDpで扱うので、相互の変換も行っています。

NestedScrollConnectionでは、onPreScrollonPostScrollを実装し、スクロール処理の実体であるdoScrollを呼び出しています。

スクロールはOffsetで扱われ、親コンポーネントで利用可能なOffsetavailable引数で渡されます。Offset.yの符号は、下スクロールが正、上スクロールが負になります。今回は、onPreScrollでは、Offset.yが負の場合のみスクロールしています。これにより、上スクロールの場合にLazyColumnのスクロールの前にoffsetを変更できます。反対にonPostScrollでは、Offset.yが正の場合のみスクロールしています。これにより、下スクロールの場合にLazyColumnのスクロールの後にoffsetを変更できます。

onPreScrollonPostScrollの戻り値は、実際にスクロールしたOffsetです。戻り値は特にonPreScrollで重要です。第1回で説明した通り、onPreScrollの戻り値によって、子コンポーネントのスクロール量が決まります。Offset.Zeroを返した場合は全量を子コンポーネントのスクロールに使用可能ですが、availableと同量を返した場合は、すべてのスクロールを親コンポーネントが消費したことになり、子コンポーネントはスクロールしません。

これで実装は完了なのですが、onPreFlingonPostFlingを実装していないのにフリングが動作するのはなぜでしょうか。その理由は、LazyColumn内部のscrollableにあります。scrollableは、それ自身がフリングを実装していて、フリング動作中にスクロールが進むたびにdispatchPreScrolldispatchPostScrollを呼び出してくれています。そのため、親コンポーネントのNestedScrollConnectiononPreFlingonPostFlingを実装しなくてもフリングに対応できるのです。

onPreScrollonPostScrollでは、source引数のNestedScrollSourceを見ることで、ドラッグ中なのかフリング中なのかを区別することができます。また、scrollabledispatchPreFlingdispatchPostFlingも呼び出しているので、全体のシーケンスは下図のようになります。

NestedScrollConnectionの実装例2

次に、もう一つのパターンを見ていきます。

@Composable
fun ConnectionSample2() {
    val density = LocalDensity.current
    val scrollState = remember {
        ConnectionSampleScrollStateImpl2(
            maxOffset = 200.dp,
            initialOffset = 200.dp,
            density = density,
        )
    }
    ConnectionSampleScreen(scrollState = scrollState)
}

@Stable
class ConnectionSampleScrollStateImpl2(
    maxOffset: Dp,
    initialOffset: Dp,
    private val density: Density,
) : ConnectionSampleScrollState {
    private val maxOffsetPx = with(density) { maxOffset.toPx() }
    private val initialOffsetPx = with(density) { initialOffset.toPx() }
    private var _offsetPx = Animatable(initialValue = initialOffsetPx).apply {
        updateBounds(0f, maxOffsetPx)
    }
    override val offset: Dp
        get() = with(density) { _offsetPx.value.toDp() }

    override val nestedScrollConnection = object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            if (source == NestedScrollSource.Fling) return Offset.Zero
            if ((available.y >= 0f) or (_offsetPx.value <= 0f)) return Offset.Zero
            val consumed = doScroll(available.y)
            return Offset(0f, consumed)
        }

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            if (source == NestedScrollSource.Fling) return Offset.Zero
            if ((available.y <= 0f) or (_offsetPx.value >= maxOffsetPx)) return Offset.Zero
            val consumedY = doScroll(available.y)
            return Offset(0f, consumedY)
        }

        override suspend fun onPreFling(available: Velocity): Velocity {
            if ((available.y >= 0f) or (_offsetPx.value <= 0f)) return Velocity.Zero
            val consumedY = doFling(available.y)
            return Velocity(0f, consumedY)
        }

        override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
            if ((available.y <= 0f) or (_offsetPx.value >= maxOffsetPx)) return Velocity.Zero
            val consumedY = doFling(available.y)
            return Velocity(0f, consumedY)
        }
    }

    private fun doScroll(delta: Float): Float {
        val oldOffset = _offsetPx.value
        runBlocking {
            _offsetPx.snapTo(_offsetPx.value + delta)
        }
        return _offsetPx.value - oldOffset
    }

    private suspend fun doFling(velocity: Float): Float {
        val result = _offsetPx.animateDecay(
            initialVelocity = velocity,
            animationSpec = splineBasedDecay(density)
        )
        if (result.endReason == AnimationEndReason.BoundReached) {
            return (velocity - result.endState.velocity)
        }
        return velocity
    }
}

こちらのサンプルはonPreFlingonPostFlingを実装し、velocity引数で受け取った速度を初期値としたDecayアニメーションを実行しています。そのため、offsetのバッキングフィールドはAnimatableにしてあります。

フリング動作の実体はdoFlingに書いてあります。戻り値は実際に消費した速度です。Decayアニメーションが最後まで(自然にスクロールが停止するまで)実行された場合は、引数で与えられた初速度をすべて消費したことになるので、引数をそのまま返しています。offsetが上限・下限に達して停止した場合は、実際に消費した速度を算出するため、animateDecayの戻り値のAnimationResultから、アニメーション終了時の速度を算出しています。

また、onPreScrollonPostScrollでは、sourceFlingの場合は処理をしないように判定を追加しています。

どちらの実装方法を選択すべきか

実装が簡単なのは一つ目です。やりたいことを実現できるのであれば、一つ目の方法を選択すべきだと思います。

もし、フリング速度に対してなんらかの処理や判定(例えば、速度が一定以下の場合はフリングしないなど)が必要な場合は、二つ目の例のようにonPreFlingonPostFlingを実装する必要があるでしょう。

また、今回は子コンポーネントがComposeの標準コンポーネントなので一つ目の方法でフリングが動作しますが、もし子コンポーネントがカスタムコンポーネントで、NestedScrollDispatcherを自前で呼び出す場合は、結局、親か子のどちらかでアニメーションの実装が必要になります。その場合は、どちらに実装する方が合理的かを考えて判断することになると思います。

ハマりポイント

NestedScrollConnectionに実装したonPostScrollが一度でもOffset.Zeroを返すと、それ以降、そのジェスチャーが完了するまで、onPostScrollは呼び出されなくなります。一つ目の方法で実装していた場合、フリングも動作しなくなります。おそらく、onPostScrollがZeroを返した時点で、親コンポーネントのスクロールは完了したとみなされているのだと思います。


第2回は以上です。次回は、NestedScrollDispatcherの実装を説明します

この記事をシェア