ComposeのNestedScrollについて詳しく解説するシリーズの第4回です。今回は、Composeの標準APIでNestedScrollを実現する方法を確認します。今回のソースコードもGitHubに公開しています。(https://github.com/usuiat/NestedScrollSamples)
第1回はこちら。NestedScrollの基本的な仕組みを説明しました。
第2回はこちら。NestedScrollConnectionの実装を説明しました。
第3回はこちら。NestedScrollDiapatcherの実装を説明しました。
目次
標準APIのススメ
第2回・第3回で親コンポーネントと子コンポーネントの実装方法を紹介してきましたが、それなりに複雑で行数も多いコードになりました。特に第3回で紹介した子コンポーネントのNestedScrollDispatcherの実装は細かいケアが必要な部分が多いです。そのため、ネストスクロールの子コンポーネントは、なるべくLazyColumnなどネストスクロール対応済みの標準コンポーネントか、Modifier.scrollableやModifier.verticalScrollなどの標準APIを使うことをお勧めします。
サンプル(再掲)
この記事では、第3回で紹介したNestedScrollDispatcherのサンプルを標準APIのModifierを使って書き換えてみます。
まずはサンプルの内容を振り返っておきます。LazyColumnにBoxを配置して、そのBoxの中に大きなコンテンツLargeContentを配置するというものでした。LargeContentのジェスチャーをpointerInputで検出し、NestedScrollDispatcherのメソッドを呼び出してネストスクロールを実現していました。ソースコードの行数は100行を超えて、あまり見通しが良いとは言えないコードになっていました。
Modifier.verticalScroll
では、上記サンプルをModifier.verticalScrollを使って書き直してみます。
@Composable
fun VerticalScrollSample(
) {
val scrollAreaHeight = 600.dp
val contentHeight = 1000.dp
val scrollState = rememberScrollState()
LazyColumn {
item { GrayBox() }
item {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxWidth()
.height(scrollAreaHeight)
.verticalScroll(state = scrollState)
) {
LargeContent(
modifier = Modifier.height(contentHeight)
)
}
}
item { GrayBox() }
}
}Modifier.verticalScrollはScrollStateを引数にとります。ScrollStateはrememberScrollStateで取得できます。
第3回のサンプルでは、Modifier.pointerInputを使ってドラッグジェスチャーを検出し、NestedScrollDispatcherでスクロールイベントを発行し、Modifier.graphicsLayerで実際のスクロールを処理していました。また、NestesScrollDispatcherでイベントを発行するために必要なスクロール速度の計算や座標の計算なども必要でした。しかし今回のサンプルでは、Modifier.verticalScrollがそれらの処理を引き受けてくれるので、格段に少ないコードで実装できました。
Modifier.scrollable
Modifier.verticalScrollは、内部でModifier.scrollableを利用しています。Modifier.scrollableは、Modifier.verticalScrollより一段階低いレイヤーのModifierになっています。次はこのModifier.scrollableを使うとどのような実装になるかを見ていきます。
@Composable
fun ScrollableSample() {
val scrollAreaHeight = 600.dp
val contentHeight = 1000.dp
val offsetMax = with(LocalDensity.current) { (contentHeight - scrollAreaHeight).toPx() }
var offset by remember { mutableFloatStateOf(0f) }
val scrollableState = rememberScrollableState { delta ->
val oldOffset = offset
offset = (oldOffset + delta).coerceIn(-offsetMax, 0f)
offset - oldOffset
}
LazyColumn {
item { GrayBox() }
item {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxWidth()
.height(scrollAreaHeight)
.clipToBounds()
.scrollable(
state = scrollableState,
orientation = Orientation.Vertical,
)
) {
LargeContent(
modifier = Modifier
.wrapContentHeight(align = Alignment.Top, unbounded = true)
.height(contentHeight)
.graphicsLayer { translationY = offset }
)
}
}
item { GrayBox() }
}
}さきほどよりは少しボリュームのあるソースコードになりましたが、これでもまだ第3回のコードよりはかなり見通しが良いです。
Modifier.scrollableは、OrientationとScrollableStateの2つが必須の引数です。Orientationを指定することによって、縦スクロールと横スクロールの両方に対応可能です。
fun Modifier.scrollable(
state: ScrollableState,
orientation: Orientation,
enabled: Boolean = true,
reverseDirection: Boolean = false,
flingBehavior: FlingBehavior? = null,
interactionSource: MutableInteractionSource? = null
): ModifierScrollableStateは、rememberScrollableStateを使って取得することができます。rememberScrollableStateの引数のラムダに、スクロール検出時の動作を実装します。利用可能なスクロール量がPixelで渡されるので、対応するスクロール処理を記述して、実際に消費したスクロール量を返すようにします。このラムダは、ドラッグ中もフリング動作中も呼び出されるので、実装を1か所にまとめることができます。
@Composable
fun rememberScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableStateModifier.scrollableはModifier.verticalScrollとは異なり、検出したスクロールジェスチャーに対する処理は自分で実装する必要があります。コード量は増えますが、その分柔軟な実装が可能になると言えます。
Modifier.scrollableについて補足
Modifier.scrollableは、Composeのスクロール処理の根幹を担う関数で、さまざまなAPIの内部で利用されています。先ほどのModifier.verticalScrollの内部で使われている他にも、LazyColumnやLazyRowなどのコンポーネントもModifier.scrollableを利用しています。
Modifier.scrollableはNestedScrollConnectionとNestedScrollDispatcherの両方を実装しています。したがって、ネストスクロールの親にも子にもなれます。
Modifier.scrollableを利用しているコンポーネントでスクロールジェスチャーが発生すると、そのコンポーネントはネストスクロールの子コンポーネントとしてふるまうので、NestedScrollDispatcherが下図のようにイベントを発行します。指が触れてドラッグしている間は、dispatchPreScrollとdispatchPostScrollを発行します。このときのNestedScrollSourceはDragです。指が離れてドラッグが終了すると、dispatchPreFlingを発行し、Decayアニメーションが継続している間はdispatchPreScrollとdispatchPostScrollを発行します。このときのNestedScrollSourceはFlingです。最後に、アニメーションが終了するとdispatchPostFlingを発行します。

Modifier.scrollableを親コンポーネントで利用している場合、NestedScrollConnectionが子コンポーネントからのイベントを受け取ってスクロールを処理します。Modifier.scrollable内のNestedScrollConnectionはonPostScrollとonPostFlingをoverrideしているので、子コンポーネントのスクロールやフリングの後に親コンポーネントがスクロールする動作になります。
標準APIで対応できないケース
ここまで見てきたように、Modifier.verticalScrollのような標準APIを利用すれば、簡単にNestedScrollを実現できます。では、標準APIで実現できないのはどのようなケースでしょうか。
複雑なジェスチャーとNestedScrollを組み合わせる場合
縦横自在にドラッグできるようなコンポーネントや、カスタムジェスチャーを検出するコンポーネントをNestedScrollに組み入れたい場合です。このようなジェスチャーは、Modifier.pointerInputを使って実装しますが、Modifier.pointerInputはそのままではNestedScrollをサポートしていないので、NestedScrollDispatcherやNestedScrollConnectionを自前で実装する必要があります。
子コンポーネントより先に親コンポーネントをスクロールする場合
Modifier.scrollableやそれを利用したAPIは、PostScrollでNestedScrollのチェーンを形成しています。つまり、子コンポーネントがこれ以上スクロールできないという状態になって初めて、親コンポーネントがスクロールします。そのため、PreScrollを実装する必要がある場合は、自前での実装が必要になります。
ただし、TopAppBarについてはあらかじめ数種類のスクロール方式が用意されており、スクロール検出時にまずTopAppBarをスクロールしてから子コンポーネントのLazyColumnをスクロールする、といったような使い方ができるようになっています。第1回で紹介したサンプルが一例です。
以上、今回は標準APIを使ったNestedScrollについて説明しました。


