Deep dive NestedScrollConnection

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


https://developer.android.com/reference/kotlin/androidx/compose/ui/input/nestedscroll/NestedScrollConnection



## NestedScrollConnection とは

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


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

## NestedScrollConnection の API

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

```kotlin
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 + 子の `NestedScrollConnection` の `onPostScroll` で消費されたスクロール量
- `available` : スクロール可能な Composable fun で消費されなかったスクロール量
- `source` : スクロールのイベントの種類(`Drag` or `Fling`)


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

- `available` : 発生した慣性の速度

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

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


## 実装例

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

```kotlin
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
    }
}
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126298/a670dcc9-c123-b2b5-8b9a-0fe36cbb23d9.gif" width="300px" />

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126298/f705c33b-acbb-07c5-b148-d46f1529a178.gif" width="300px" />

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

```kotlin
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
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126298/9fc92093-9310-55f7-6677-a92aede2c60f.gif" width="300px" />




## 補足
### `NestedScrollConnection` がネストされている時の処理順番
スクロール可能な Composable fun に近い方から処理されます。
子→親へと伝搬するイメージです。

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

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

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

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

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

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

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

```kotlin
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)
    }
}
```

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt;l=693-722