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() }
}
}
図にするとこんな感じです。
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
に渡しています。
@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パターン目に対応するやり方で、親コンポーネントのNestedScrollConnection
がonPreScroll
とonPostScroll
だけ実装すればよくなる方法です。
@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
を指定しています。この情報は、親コンポーネントの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
の実装が必要になります。親コンポーネント側の実装は増えますが、その分子コンポーネント側の実装量は少なくて済みます。
@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
とほぼ同じ内容になっています。自分自身のスクロール処理の前後で、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を実現する方法について確認します。