詳解! ComposeでNestedScroll その3 Dispatcher編

この記事をシェア

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

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

サンプル

今回のサンプルは、LazyColumnの中にBoxを配置しています。Boxには、Boxのサイズよりも大きなコンテンツを配置しています。Boxをスクロールすると、先にBox内のコンテンツがスクロールし、その後LazyColumnがスクロールします。もちろんフリングにも対応しています。

このサンプルのような動作は、標準APIのModifier.verticalScrollを使えば、NestedScrollDispatcherを意識することなく実現できますが、今回はサンプルとして、pointerInputで自分でスクロールを実装し、NestedScrollDispatcherの使い方を説明します。

今回のソースコードもGitHubに公開しています。(https://github.com/usuiat/NestedScrollSamples

画面の構成

画面を構成するコンポーザブルのコードは、以下のようになっています。

@Composable
fun DispatcherSampleScreen(
    scrollState: DispatcherSampleScrollState,
    scrollAreaHeight: Dp,
    contentHeight: Dp,
) {
    LazyColumn {
        item { GrayBox() }
        item {
            val scope = rememberCoroutineScope()
            Box(
                contentAlignment = Alignment.TopCenter,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(scrollAreaHeight)
                    .clipToBounds()
                    .nestedScroll(
                        connection = scrollState.nestedScrollConnection,
                        dispatcher = scrollState.nestedScrollDispatcher,
                    )
                    .onGloballyPositioned { coordinates ->
                        scrollState.updateComponentPosition(coordinates.positionInWindow().y)
                    }
                    .pointerInput(Unit) {
                        // 後述
                    }
            ) {
                LargeContent(
                    modifier = Modifier
                        .wrapContentHeight(align = Alignment.Top, unbounded = true)
                        .height(contentHeight)
                        .graphicsLayer { translationY = scrollState.offset }
                )
            }
        }
        item { GrayBox() }
    }
}

図にするとこんな感じです。

LazyColumnGrayBoxBoxを配置し、Boxの中にLargeContentを配置しています。GrayBoxLargeContentの中身は、今回の内容に関係ないので省略します。

Boxの高さはscrollAreaHeightLargeContentの高さはcontentHeightで指定しています。(contentHeightの方が大きいという前提です。)LargeContentwrapContentHeight(unbounded = true)を指定することによって、Boxよりも大きなコンテンツの描画を許可し、なおかつBoxclipToBoundsを指定することによって、そのコンテンツがBoxからはみ出さないようにしています。

pointerInputでは、Boxのドラッグジェスチャーを検出し、ドラッグ開始、ドラッグ中、ドラッグ終了の各タイミングでDispatcherSampleScrollStateのメソッドを呼び出しています。DispatcherSampleScrollStateは、スクロール状態管理クラスです。dragflingのメソッド呼び出しを通して、保持するoffsetの値が更新されます。そして、graphicsLayerLargeContentの表示位置を変更することによって、スクロールを実現しています。

.pointerInput(Unit) {
    detectDragGestures(
        onDragStart = {
            scrollState.reset()
        },
        onDrag = { change, dragAmount ->
            scope.launch {
                scrollState.drag(
                    delta = dragAmount.y,
                    position = change.position.y,
                    timeMillis = change.uptimeMillis
                )
            }
        },
        onDragEnd = {
            scope.launch {
                scrollState.fling()
            }
        },
    )
}
LargeContent(
    modifier = Modifier
        // 中略
        .graphicsLayer { translationY = scrollState.offset }
)

DispatcherSampleScrollStateNestedScrollDispatcherを保持しています。これを、Modifier.nestedScrollを使ってBoxにセットしています。これにより、DispatcherSampleScrollState内で発行するスクロールイベントを、親コンポーネントのLazyColumnに伝えます。NestedScrollConnectionも同時にセットしていますが、今回はこれはあまり意味を持ちません。

.nestedScroll(
    connection = scrollState.nestedScrollConnection,
    dispatcher = scrollState.nestedScrollDispatcher,
)

onGloballyPositionedでは、Windowに対するBoxの位置をDispatcherSampleScrollStateに設定しています。これは、Boxの位置が動いている最中にスクロールの速度を算出するために必要になります。

.onGloballyPositioned { coordinates ->
    scrollState.updateComponentPosition(coordinates.positionInWindow().y)
}

なお、LazyColumnは内部でModifier.scrollableを使っており、scrollableにはNestedScrollConnectionが実装されています。そのため、LazyColumnはそのままでネストスクロールをサポートしています。

NestedScrollDispatcherの実装例1

では、DispatcherSampleScrollStateの実装を見ていきます。今回も2種類の実装を紹介するので、以下のようにインターフェースを定義しました。

interface DispatcherSampleScrollState {
    val nestedScrollConnection: NestedScrollConnection
    val nestedScrollDispatcher: NestedScrollDispatcher
    val offset: Float

    fun reset()
    suspend fun drag(delta: Float, position: Float, timeMillis: Long)
    suspend fun fling()
    fun updateComponentPosition(position: Float)
}

まずは1パターン目の実装例です。DispatcherSampleScrollStateImpl1rememberで作成して、上で説明したDispatcherSampleScreenに渡しています。

@Composable
fun DispatcherSample1() {
    val scrollAreaHeight = 600.dp
    val contentHeight = 1000.dp
    val offsetMax = with(LocalDensity.current) { (contentHeight - scrollAreaHeight).toPx() }
    val scrollState = remember { DispatcherSampleScrollStateImpl1(offsetMax = offsetMax) }
    DispatcherSampleScreen(
        scrollState = scrollState,
        scrollAreaHeight = scrollAreaHeight,
        contentHeight = contentHeight,
    )
}

DispatcherSampleScrollStateImpl1の実装は下記です。この実装は、前回NestedScrollConnectionの実装例の1パターン目に対応するやり方で、親コンポーネントのNestedScrollConnectiononPreScrollonPostScrollだけ実装すればよくなる方法です。

@Stable
class DispatcherSampleScrollStateImpl1(
    private val offsetMax: Float,
) : DispatcherSampleScrollState {
    private val _offset = mutableFloatStateOf(0f)
    override val offset: Float
        get() = _offset.floatValue

    override val nestedScrollConnection = object : NestedScrollConnection {}
    override val nestedScrollDispatcher = NestedScrollDispatcher()

    private var componentPosition = 0f
    override fun updateComponentPosition(position: Float) {
        componentPosition = position
    }

    private val velocityTracker = VelocityTracker1D(false)
    private var cancelFling = false

    override fun reset() {
        velocityTracker.resetTracking()
    }

    override suspend fun drag(delta: Float, position: Float, timeMillis: Long) {
        // 後述
    }

    override suspend fun fling() {
        // 後述
    }

    private fun scroll(delta: Float, source: NestedScrollSource) {
        // 後述
    }
}

offsetのバッキングフィールドはMutableFloatStateです。

nestedScrollConnectionは、Modifier.nestedScrollに何らかのインスタンスを渡さなければならないのでここで作成していますが、中身は空です。

updateComponentPositionは、Windowに対するコンポーネントの位置が変わったときに呼ばれ、その位置をcomponentPositionに保持しています。

resetはドラッグ開始時に呼ばれ、スクロール速度算出処理をリセットしています。

    override suspend fun drag(delta: Float, position: Float, timeMillis: Long) {
        cancelFling = true
        scroll(delta, NestedScrollSource.Drag)
        val positionInWindow = position + componentPosition
        velocityTracker.addDataPoint(timeMillis, positionInWindow)
    }

dragはドラッグイベントが発生するたびに呼ばれ、実際のスクロール処理scrollを呼び出します。scrollのソースには、NestedScrollSource.Dragを指定しています。この情報は、親コンポーネントのNestedScrollConnectiononPreScrollonPostScrollに渡ります。dragの引数のpositionはコンポーネント(ここではBox)内での指が触れている座標になります。コンポーネント自体がスクロールしている場合、この値だけではスクロールの速度を計算できないので、componentPositionと足し合わせてWindowに対する座標を取得し、スクロール速度を算出しています。

   override suspend fun fling() {
        val velocityY = velocityTracker.calculateVelocity()
        cancelFling = false
        var lastValue = 0f
        AnimationState(
            initialValue = 0f,
            initialVelocity = velocityY,
        ).animateDecay(exponentialDecay()) {
            if (cancelFling) {
                cancelAnimation()
            }
            scroll(value - lastValue, NestedScrollSource.Fling)
            lastValue = value
        }
    }

flingはドラッグ終了時に呼ばれます。ここでは、AnimationState.animateDecayを使い、スクロールが徐々に遅くなってやがて停止するアニメーションを実現しています。アニメーションのブロック内で実際のスクロール処理を呼び出します。このときのソースはNestedScrollSource.Flingを指定しています。また、cancenFlingフラグが立っていたらアニメーションをキャンセルしています。

実際のスクロール処理は下記のようになります。まずdispatchPreScrollを呼び出して、先にスクロールするチャンスを親コンポーネントに提供します。この戻り値を差し引いた分を使って、自分自身のスクロール処理を行います。実際には、offsetの値を更新します。PreScrollと自分自身のスクロールを実行して余った分を使ってdispatchPostScrollを呼び出し、もう一度親コンポーネントがスクロールするチャンスを提供します。

    private fun scroll(delta: Float, source: NestedScrollSource) {
        if (delta == 0f) return

        val available = Offset(0f, delta)
        val preConsumed = nestedScrollDispatcher.dispatchPreScroll(
            available = available,
            source = source,
        )

        val weAvailable = available - preConsumed
        val newY = (offset + weAvailable.y).coerceIn(-offsetMax, 0f)
        val weConsumed = Offset(0f, newY - offset)
        _offset.floatValue = newY

        nestedScrollDispatcher.dispatchPostScroll(
            consumed = preConsumed + weConsumed,
            available = available - preConsumed - weConsumed,
            source = source,
        )
    }

dispatchPreFlingdispatchPostFlingは呼び出していませんが、Decayアニメーションを実装した上でdispatchPreScrolldispatchPostScrollを呼び出しているので、これで親コンポーネントも連動したフリング動作を実現することができます。

NestedScrollDispatcherの実装例2

次に2つ目の実装パターンです。この実装は、前回NestedScrollConnectionの実装例の2パターン目に対応するやり方で、親コンポーネント側ではonPreScrollonPostScrollに加えて、onPreFlingonPostFlingの実装が必要になります。親コンポーネント側の実装は増えますが、その分子コンポーネント側の実装量は少なくて済みます。

@Stable
class DispatcherSampleScrollStateImpl2(
    offsetMax: Float,
) : DispatcherSampleScrollState {
    private val _offset = Animatable(0f).apply { updateBounds(-offsetMax, 0f) }
    override val offset: Float
        get() = _offset.value

    override val nestedScrollConnection = object : NestedScrollConnection {}
    override val nestedScrollDispatcher = NestedScrollDispatcher()

    private var componentPosition = 0f
    override fun updateComponentPosition(position: Float) {
        componentPosition = position
    }

    private val velocityTracker = VelocityTracker1D(false)

    override fun reset() {
        velocityTracker.resetTracking()
    }

    override suspend fun drag(delta: Float, position: Float, timeMillis: Long) {
        // 後述
    }

    override suspend fun fling() {
        // 後述
    }
}

1つ目の実装パターンと違うのは、offsetのバッキングフィールドがAnimatableになっていることです。後述するfling内でoffsetを手軽にアニメーションさせるためにこのようにしています。

    override suspend fun drag(delta: Float, position: Float, timeMillis: Long) {
        val available = Offset(0f, delta)
        val preConsumed = nestedScrollDispatcher.dispatchPreScroll(
            available = available,
            source = NestedScrollSource.Drag,
        )

        val weAvailable = available - preConsumed
        val oldY = offset
        _offset.snapTo(offset + weAvailable.y)
        val weConsumed = Offset(0f, offset - oldY)

        nestedScrollDispatcher.dispatchPostScroll(
            consumed = preConsumed + weConsumed,
            available = available - preConsumed - weConsumed,
            source = NestedScrollSource.Drag,
        )

        velocityTracker.addDataPoint(timeMillis, position + componentPosition)
    }

dragの実装は、1つ目の実装のscrollとほぼ同じ内容になっています。自分自身のスクロール処理の前後で、dispatchPreScrolldispatchPostScrollを呼び出しています。違うのは、offsetの値の変更にsnapToを使う必要があることくらいです。

    override suspend fun fling() {
        val velocityY = velocityTracker.calculateVelocity()
        val velocity = Velocity(0f, velocityY)
        val preConsumed = nestedScrollDispatcher.dispatchPreFling(
            available = velocity,
        )

        val animationResult = _offset.animateDecay(
            initialVelocity = velocityY - preConsumed.y,
            animationSpec = exponentialDecay(),
        )

        if (animationResult.endReason == AnimationEndReason.BoundReached) {
            val postAvailable = Velocity(0f, animationResult.endState.velocity)
            nestedScrollDispatcher.coroutineScope.launch {
                nestedScrollDispatcher.dispatchPostFling(
                    consumed = velocity - postAvailable,
                    available = postAvailable,
                )
            }
        }
    }

flingは、一つ目の実装とは違っています。自分自身のスクロールは、Animatable.animateDecayを使っています。その前後でdispatchPreFlingdispatchPostFlingを呼び出します。dispatchPostFlingは、自分自身が端までスクロールしてもまだDecayが続いている場合に呼び出します。この場合のスクロール速度は、AnimationResultから取得しています。

課題

今回紹介したサンプルコードですが、まだ完ぺきではないです。ビルドして動かしていただくと分かるのですが、ときどき意図しないスクロールの挙動になることがあります。具体的には、子コンポーネントが動いてほしい場面で動いてくれない場合があります。問題点のご指摘、解決方法のご提案などありましたら、ご連絡いただけると嬉しいです。


以上、長くなりましたが、NestedScrollDispatcherの使い方を説明しました。

次回は、標準APIでNestedScrollを実現する方法について確認します。

この記事をシェア