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
): Modifier
ScrollableState
は、rememberScrollableState
を使って取得することができます。rememberScrollableState
の引数のラムダに、スクロール検出時の動作を実装します。利用可能なスクロール量がPixelで渡されるので、対応するスクロール処理を記述して、実際に消費したスクロール量を返すようにします。このラムダは、ドラッグ中もフリング動作中も呼び出されるので、実装を1か所にまとめることができます。
@Composable
fun rememberScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableState
Modifier.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について説明しました。