ComposeのNestedScrollについて詳しく解説するシリーズの第2回です。今回は、親コンポーネント側のNestedScrollConnection
の実装を説明していきます。
第1回はこちら。NestedScrollの基本的な仕組みを説明しました。
サンプル
今回のサンプルは、LazyColumn
のスクロールに合わせて画面上部のエリアを伸縮するものです。同じような動きはMaterial3のTopAppBarでも実現できますが、今回はこれを手作りします。ソースコードはGitHubに公開しています。(https://github.com/usuiat/NestedScrollSamples)
上スクロール(指を下から上へ移動)するときは、LazyColumn
のスクロールの前に画面上部のGrayBox
が縮みます。反対に、下スクロール(指を上から下に移動)するときはLazyColumn
のスクロールの後でGrayBox
が広がります。フリングにも対応しています。
画面の構成
画面を構成するコンポーザブルのコードは以下のようになっています。Box
の中にGrayBox
(中身はシンプルなBox
です)とLazyColumn
を配置しています。
@Composable
fun ConnectionSampleScreen(scrollState: ConnectionSampleScrollState) {
Box(
modifier = Modifier
.fillMaxSize().nestedScroll(scrollState.nestedScrollConnection)
) {
GrayBox(modifier = Modifier.height(scrollState.offset))
LazyColumn(
modifier = Modifier.fillMaxSize().offset(y = scrollState.offset)
) {
items(50) {
Text(
text = "Item $it",
modifier = Modifier.fillMaxWidth().height(40.dp)
)
}
}
}
}
ConnectionSampleScrollState
はスクロール状態管理クラスです。この記事では2種類の実装を紹介するので、下記のようにインターフェースを定義しました。
interface ConnectionSampleScrollState {
val nestedScrollConnection: NestedScrollConnection
val offset: Dp
}
親コンポーネントであるBox
に対してModifier.nestedScroll
でNestedScrollConnection
をセットすることにより、子コンポーネントであるLazyColumn
からスクロールイベントを受け取ります。そして、ConnectionSampleScrollState
内でoffset
を変更することによって、親コンポーネント内での子コンポーネントの位置を変更し、NestedScrollを実現しています。
なお、LazyColumn
は内部でModifier.scrollable
を使っており、scrollable
にはNestedScrollDispatcher
が実装されています。したがって、NestedScrollDispatcher
を自前で実装する必要はありません。
NestedScrollConnectionの実装例1
では、ConnectionSampleScrollState
内のNestedScrollConnection
の実装を見ていきます。まずは1パターン目の実装例です。ConnectionSampleScrollStateImpl1
をremember
で作成して、上で説明したConnectionSampleScreen
に渡しています。
@Composable
fun ConnectionSample1() {
val density = LocalDensity.current
val scrollState = remember {
ConnectionSampleScrollStateImpl1(
maxOffset = 200.dp,
initialOffset = 200.dp,
density = density,
)
}
ConnectionSampleScreen(scrollState = scrollState)
}
@Stable
class ConnectionSampleScrollStateImpl1(
maxOffset: Dp,
initialOffset: Dp,
private val density: Density,
) : ConnectionSampleScrollState {
private val maxOffsetPx = with(density) { maxOffset.toPx() }
private val initialOffsetPx = with(density) { initialOffset.toPx() }
private var _offsetPx by mutableFloatStateOf(initialOffsetPx)
override val offset: Dp
get() = with(density) { _offsetPx.toDp() }
override val nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if ((available.y >= 0f) or (_offsetPx <= 0f)) return Offset.Zero
val consumedY = doScroll(available.y)
return Offset(0f, consumedY)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if ((available.y <= 0f) or (_offsetPx >= maxOffsetPx)) return Offset.Zero
val consumedY = doScroll(available.y)
return Offset(0f, consumedY)
}
}
private fun doScroll(delta: Float): Float {
val oldOffset = _offsetPx
_offsetPx = (_offsetPx + delta).coerceIn(0f, maxOffsetPx)
return _offsetPx - oldOffset
}
}
offset
のバッキングフィールドはMutableFloatState
を使っています。スクロールイベントはピクセルで扱うのに対し、コンポーネントの配置はDp
で扱うので、相互の変換も行っています。
NestedScrollConnection
では、onPreScroll
とonPostScroll
を実装し、スクロール処理の実体であるdoScroll
を呼び出しています。
スクロールはOffset
で扱われ、親コンポーネントで利用可能なOffset
はavailable
引数で渡されます。Offset.y
の符号は、下スクロールが正、上スクロールが負になります。今回は、onPreScroll
では、Offset.y
が負の場合のみスクロールしています。これにより、上スクロールの場合にLazyColumn
のスクロールの前にoffset
を変更できます。反対にonPostScroll
では、Offset.y
が正の場合のみスクロールしています。これにより、下スクロールの場合にLazyColumn
のスクロールの後にoffset
を変更できます。
onPreScroll
とonPostScroll
の戻り値は、実際にスクロールしたOffset
です。戻り値は特にonPreScroll
で重要です。第1回で説明した通り、onPreScroll
の戻り値によって、子コンポーネントのスクロール量が決まります。Offset.Zero
を返した場合は全量を子コンポーネントのスクロールに使用可能ですが、available
と同量を返した場合は、すべてのスクロールを親コンポーネントが消費したことになり、子コンポーネントはスクロールしません。
これで実装は完了なのですが、onPreFling
とonPostFling
を実装していないのにフリングが動作するのはなぜでしょうか。その理由は、LazyColumn
内部のscrollable
にあります。scrollable
は、それ自身がフリングを実装していて、フリング動作中にスクロールが進むたびにdispatchPreScroll
とdispatchPostScroll
を呼び出してくれています。そのため、親コンポーネントのNestedScrollConnection
でonPreFling
とonPostFling
を実装しなくてもフリングに対応できるのです。
onPreScroll
とonPostScroll
では、source
引数のNestedScrollSource
を見ることで、ドラッグ中なのかフリング中なのかを区別することができます。また、scrollable
はdispatchPreFling
とdispatchPostFling
も呼び出しているので、全体のシーケンスは下図のようになります。
NestedScrollConnectionの実装例2
次に、もう一つのパターンを見ていきます。
@Composable
fun ConnectionSample2() {
val density = LocalDensity.current
val scrollState = remember {
ConnectionSampleScrollStateImpl2(
maxOffset = 200.dp,
initialOffset = 200.dp,
density = density,
)
}
ConnectionSampleScreen(scrollState = scrollState)
}
@Stable
class ConnectionSampleScrollStateImpl2(
maxOffset: Dp,
initialOffset: Dp,
private val density: Density,
) : ConnectionSampleScrollState {
private val maxOffsetPx = with(density) { maxOffset.toPx() }
private val initialOffsetPx = with(density) { initialOffset.toPx() }
private var _offsetPx = Animatable(initialValue = initialOffsetPx).apply {
updateBounds(0f, maxOffsetPx)
}
override val offset: Dp
get() = with(density) { _offsetPx.value.toDp() }
override val nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.Fling) return Offset.Zero
if ((available.y >= 0f) or (_offsetPx.value <= 0f)) return Offset.Zero
val consumed = doScroll(available.y)
return Offset(0f, consumed)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (source == NestedScrollSource.Fling) return Offset.Zero
if ((available.y <= 0f) or (_offsetPx.value >= maxOffsetPx)) return Offset.Zero
val consumedY = doScroll(available.y)
return Offset(0f, consumedY)
}
override suspend fun onPreFling(available: Velocity): Velocity {
if ((available.y >= 0f) or (_offsetPx.value <= 0f)) return Velocity.Zero
val consumedY = doFling(available.y)
return Velocity(0f, consumedY)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
if ((available.y <= 0f) or (_offsetPx.value >= maxOffsetPx)) return Velocity.Zero
val consumedY = doFling(available.y)
return Velocity(0f, consumedY)
}
}
private fun doScroll(delta: Float): Float {
val oldOffset = _offsetPx.value
runBlocking {
_offsetPx.snapTo(_offsetPx.value + delta)
}
return _offsetPx.value - oldOffset
}
private suspend fun doFling(velocity: Float): Float {
val result = _offsetPx.animateDecay(
initialVelocity = velocity,
animationSpec = splineBasedDecay(density)
)
if (result.endReason == AnimationEndReason.BoundReached) {
return (velocity - result.endState.velocity)
}
return velocity
}
}
こちらのサンプルはonPreFling
とonPostFling
を実装し、velocity
引数で受け取った速度を初期値としたDecayアニメーションを実行しています。そのため、offset
のバッキングフィールドはAnimatable
にしてあります。
フリング動作の実体はdoFling
に書いてあります。戻り値は実際に消費した速度です。Decayアニメーションが最後まで(自然にスクロールが停止するまで)実行された場合は、引数で与えられた初速度をすべて消費したことになるので、引数をそのまま返しています。offset
が上限・下限に達して停止した場合は、実際に消費した速度を算出するため、animateDecay
の戻り値のAnimationResult
から、アニメーション終了時の速度を算出しています。
また、onPreScroll
とonPostScroll
では、source
がFling
の場合は処理をしないように判定を追加しています。
どちらの実装方法を選択すべきか
実装が簡単なのは一つ目です。やりたいことを実現できるのであれば、一つ目の方法を選択すべきだと思います。
もし、フリング速度に対してなんらかの処理や判定(例えば、速度が一定以下の場合はフリングしないなど)が必要な場合は、二つ目の例のようにonPreFling
とonPostFling
を実装する必要があるでしょう。
また、今回は子コンポーネントがComposeの標準コンポーネントなので一つ目の方法でフリングが動作しますが、もし子コンポーネントがカスタムコンポーネントで、NestedScrollDispatcher
を自前で呼び出す場合は、結局、親か子のどちらかでアニメーションの実装が必要になります。その場合は、どちらに実装する方が合理的かを考えて判断することになると思います。
ハマりポイント
NestedScrollConnection
に実装したonPostScroll
が一度でもOffset.Zero
を返すと、それ以降、そのジェスチャーが完了するまで、onPostScroll
は呼び出されなくなります。一つ目の方法で実装していた場合、フリングも動作しなくなります。おそらく、onPostScroll
がZeroを返した時点で、親コンポーネントのスクロールは完了したとみなされているのだと思います。
第2回は以上です。次回は、NestedScrollDispatcher
の実装を説明します。