9
3

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.

Deep dive NestedScrollConnection

Last updated at Posted at 2023-06-30

Jetpack Compose で開発していると時折スクロールに対して何かしらの処理をする時や、Pull Refresh や Material 3 の TopAppBar の内部でも使用されている NestedScrollConnection を解説します。

NestedScrollConnection とは

スクロール可能な Lazy 系や Modifier.verticalScroll() の Composable fun の親 Composable fun に対して Modifier で設定することで、スクロール量の取得や制御することができる API です。

Column(
    modifier = Modifier.nestedScroll(
        object : NestedScrollConnection {
            ...
        }
    )
) {
    LazyColumn()
    // or
    Column(modifier = Modifier.verticalScroll())
}

NestedScrollConnection の API

NestedScrollConnection に存在するメソッドは以下の 4 つのみです。

Modifier.nestedScroll(
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            return super.onPreScroll(available, source)
        }

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            return super.onPostScroll(consumed, available, source)
        }
        
        override suspend fun onPreFling(available: Velocity): Velocity {
            return super.onPreFling(available)
        }

        override suspend fun onPostFling(
            consumed: Velocity,
            available: Velocity
        ): Velocity {
            return super.onPostFling(consumed, available)
        }
    }
)

onPreScroll

スクロール可能な Composable fun でスクロールが発生した時にスクロール量が処理される前に呼ばれます。
onPreScroll で消費したスクロール量を返すことで消費されなかったスクロール量だけスクロールされます。
全てのスクロール量を返すことでスクロール制御を奪うことになります。

  • available : 発生したスクロール量
  • source : スクロールのイベントの種類(Drag or Fling)

onPostScroll

スクロール可能な Composable fun でスクロールが発生した時にスクロール量が処理された後に呼ばれます。
onPreScroll でスクロール量が全て消費された場合はここでは値が 0 になります。

  • consumed : スクロール可能な Composable fun + 子の NestedScrollConnectiononPostScroll で消費されたスクロール量
  • available : スクロール可能な Composable fun で消費されなかったスクロール量
  • source : スクロールのイベントの種類(Drag or Fling)

onPreFling

スクロール可能な Composable fun で慣性が発生した時(スクロールされている状態で指を離した時)に慣性の速度が処理される前に呼ばれます。
onPreFling で消費した速度を返すことで消費されなかった速度でスクロールされます。

  • available : 発生した慣性の速度

onPostFling

スクロール可能な Composable fun で慣性が発生した時に慣性の速度が処理された後に呼ばれます。
onPostFling で速度が全て消費された場合はここでは値が 0 になります。

  • consumed : スクロール可能な Composable fun + 子の NestedScrollConnectiononPostFling で消費された慣性の速度
  • available : スクロール可能な Composable fun で消費されなかった慣性の速度

実装例

リストを OverScroll 時にさらに引っ張ることができる実装を考えてみます。
OverScroll 時はスクロール可能な Composable fun がスクロールを消費していない時なので onPostScroll の available に入ってくる値を使用します。

override fun onPostScroll(
    consumed: Offset,
    available: Offset,
    source: NestedScrollSource
): Offset {
    // 横方向は available.x を、縦方向にしたい時は available.y を見る
    return if (source == NestedScrollSource.Drag && available.y > 0) {
        // 消費したいスクロール量を計算して消費した分だけ返す
        state.dispatchScroll(available)
    } else {
        Offset.Zero
    }
}

この処理だけだと上向きのスクロールで戻る時にスクロール可能な Composable fun が処理をしてしまうため戻すことができません。

そのため onPreScroll で必要なスクロール量を消費することでスクロール可能な Composable fun が処理させないようにする必要があります。

override fun onPreScroll(
    available: Offset,
    source: NestedScrollSource
): Offset {
    // 横方向は available.x を、縦方向にしたい時は available.y を見る
    return if (source == NestedScrollSource.Drag && available.y < 0) {
        // 消費したいスクロール量を計算して消費した分だけ返す
        state.dispatchScroll(available)
    } else {
        Offset.Zero
    }
}

補足

NestedScrollConnection がネストされている時の処理順番

スクロール可能な Composable fun に近い方から処理されます。
子→親へと伝搬するイメージです。

Column(
    modifier = Modifier.nestedScroll(
        object : NestedScrollConnection {
            // 2
        }
    )
) {
    Column(
        modifier = Modifier.nestedScroll(
            object : NestedScrollConnection {
                // 1
            }
        )
    ) {
        ...
    }
}

子が持つ onPostScroll で消費量を返した場合は親が持つ onPostScroll では consumed に子の消費量も合算された形で返ってきます。

同方向のスクロールがネストされている場合の挙動

本来であれば Jetpack Compose において同方向のスクロールは高さ計算が無限大になってしまうのでネストすることがランタイム的にできませんが、高さが定まっている場合は同方向のスクロールはネストすることが可能です。

LazyColumn {
    item {
        Column(
            modifier = Modifier
                .height(100.dp)
                .verticalScroll(rememberScrollState())
        ) {
            ...
        }
    }
    items(...) { ... }
}

この場合の NestedScrollConnection はどちらの Composable fun のスクロール量も流れてきます。

NestedScrollConnection はスクロール処理のどこで使われているのか

Modifier.scrollable -> Modifier.pointerScrollable -> ScrollingLogic と辿っていき、スクロールの処理に対して ScrollScope#dispatchScroll の拡張関数で親の NestedScrollConnection を呼んで処理しています。

fun ScrollScope.dispatchScroll(availableDelta: Offset, source: NestedScrollSource): Offset {
    val scrollDelta = availableDelta.singleAxisOffset()

    val performScroll: (Offset) -> Offset = { delta ->
        val nestedScrollDispatcher = nestedScrollDispatcher.value
        val preConsumedByParent = nestedScrollDispatcher
            .dispatchPreScroll(delta, source)

        val scrollAvailable = delta - preConsumedByParent
        // Consume on a single axis
        val axisConsumed =
            scrollBy(scrollAvailable.reverseIfNeeded().toFloat()).toOffset().reverseIfNeeded()

        val leftForParent = scrollAvailable - axisConsumed
        val parentConsumed = nestedScrollDispatcher.dispatchPostScroll(
            axisConsumed,
            leftForParent,
            source
        )

        preConsumedByParent + axisConsumed + parentConsumed
    }

    return if (overscrollEffect != null && shouldDispatchOverscroll) {
        overscrollEffect.applyToScroll(scrollDelta, source, performScroll)
    } else {
        performScroll(scrollDelta)
    }
}

9
3
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
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?