詳解! 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)

画面の構成
画面を構成するコンポーザブルのコードは、以下のようになっています。
@Composablefun 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() } }}図にするとこんな感じです。

LazyColumnにGrayBoxとBoxを配置し、Boxの中にLargeContentを配置しています。GrayBoxとLargeContentの中身は、今回の内容に関係ないので省略します。
Boxの高さはscrollAreaHeight、LargeContentの高さはcontentHeightで指定しています。(contentHeightの方が大きいという前提です。)LargeContentにwrapContentHeight(unbounded = true)を指定することによって、Boxよりも大きなコンテンツの描画を許可し、なおかつBoxにclipToBoundsを指定することによって、そのコンテンツがBoxからはみ出さないようにしています。
pointerInputでは、Boxのドラッグジェスチャーを検出し、ドラッグ開始、ドラッグ中、ドラッグ終了の各タイミングで`DispatcherSampleScrollState`のメソッドを呼び出しています。DispatcherSampleScrollStateは、スクロール状態管理クラスです。drag、flingのメソッド呼び出しを通して、保持するoffsetの値が更新されます。そして、graphicsLayerでLargeContentの表示位置を変更することによって、スクロールを実現しています。
.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 })DispatcherSampleScrollStateはNestedScrollDispatcherを保持しています。これを、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パターン目の実装例です。DispatcherSampleScrollStateImpl1をrememberで作成して、上で説明したDispatcherSampleScreenに渡しています。
@Composablefun 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パターン目に対応するやり方で、親コンポーネントのNestedScrollConnectionがonPreScrollとonPostScrollだけ実装すればよくなる方法です。
@Stableclass 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を指定しています。この情報は、親コンポーネントのNestedScrollConnectionのonPreScrollとonPostScrollに渡ります。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, ) }dispatchPreFlingとdispatchPostFlingは呼び出していませんが、Decayアニメーションを実装した上でdispatchPreScrollとdispatchPostScrollを呼び出しているので、これで親コンポーネントも連動したフリング動作を実現することができます。
NestedScrollDispatcherの実装例2
次に2つ目の実装パターンです。この実装は、前回のNestedScrollConnectionの実装例の2パターン目に対応するやり方で、親コンポーネント側ではonPreScrollとonPostScrollに加えて、onPreFlingとonPostFlingの実装が必要になります。親コンポーネント側の実装は増えますが、その分子コンポーネント側の実装量は少なくて済みます。
@Stableclass 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とほぼ同じ内容になっています。自分自身のスクロール処理の前後で、dispatchPreScrollとdispatchPostScrollを呼び出しています。違うのは、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を使っています。その前後でdispatchPreFlingとdispatchPostFlingを呼び出します。dispatchPostFlingは、自分自身が端までスクロールしてもまだDecayが続いている場合に呼び出します。この場合のスクロール速度は、AnimationResultから取得しています。
課題
今回紹介したサンプルコードですが、まだ完ぺきではないです。ビルドして動かしていただくと分かるのですが、ときどき意図しないスクロールの挙動になることがあります。具体的には、子コンポーネントが動いてほしい場面で動いてくれない場合があります。問題点のご指摘、解決方法のご提案などありましたら、ご連絡いただけると嬉しいです。
以上、長くなりましたが、NestedScrollDispatcherの使い方を説明しました。
次回は、標準APIでNestedScrollを実現する方法について確認します。