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
orFling
)
onPostScroll
スクロール可能な Composable fun でスクロールが発生した時にスクロール量が処理された後に呼ばれます。
onPreScroll
でスクロール量が全て消費された場合はここでは値が 0 になります。
-
consumed
: スクロール可能な Composable fun + 子のNestedScrollConnection
のonPostScroll
で消費されたスクロール量 -
available
: スクロール可能な Composable fun で消費されなかったスクロール量 -
source
: スクロールのイベントの種類(Drag
orFling
)
onPreFling
スクロール可能な Composable fun で慣性が発生した時(スクロールされている状態で指を離した時)に慣性の速度が処理される前に呼ばれます。
onPreFling
で消費した速度を返すことで消費されなかった速度でスクロールされます。
-
available
: 発生した慣性の速度
onPostFling
スクロール可能な Composable fun で慣性が発生した時に慣性の速度が処理された後に呼ばれます。
onPostFling
で速度が全て消費された場合はここでは値が 0 になります。
-
consumed
: スクロール可能な Composable fun + 子のNestedScrollConnection
のonPostFling
で消費された慣性の速度 -
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)
}
}