ネストされたスクロールは、相互に格納された複数のスクロール コンポーネントが、1 つのスクロール操作に反応してスクロールの差分(変化)を伝達することで、連携して動作するシステムです。
ネストされたスクロール システムにより、スクロール可能で階層的にリンクされたコンポーネント間の調整が可能になります(ほとんどの場合、同じ親を共有します)。このシステムは、スクロール コンテナをリンクし、伝播および共有されるスクロール デルタとのインタラクションを可能にします。
Compose には、コンポーザブル間でネストされたスクロールを処理する方法が複数用意されています。ネストされたスクロールの典型的な例は、別のリスト内のリストです。より複雑なケースは、折りたたみ可能なツールバーです。
自動ネスト スクロール
シンプルなネスト スクロールでは、アプリ側のアクションは必要ありません。スクロール アクションを開始する操作は、子から親に自動的に伝播されます。これにより、子がそれ以上スクロールできなくなると、親要素によって操作が処理されます。
自動ネスト スクロールは Compose のいくつかのコンポーネントと修飾子(verticalScroll、horizontalScroll、scrollable、Lazy API および TextField)によってサポートされており、すぐに利用できます。つまり、ユーザーがネストされたコンポーネントの内部の子をスクロールすると、以前の修飾子によって、ネスト スクロールをサポートする親にスクロール差分が伝播されます。
次の例では、verticalScroll 修飾子が適用されているコンテナ内の要素にも verticalScroll 修飾子が適用されています。
@Composable private fun AutomaticNestedScroll() { val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White) Box( modifier = Modifier .background(Color.LightGray) .verticalScroll(rememberScrollState()) .padding(32.dp) ) { Column { repeat(6) { Box( modifier = Modifier .height(128.dp) .verticalScroll(rememberScrollState()) ) { Text( "Scroll here", modifier = Modifier .border(12.dp, Color.DarkGray) .background(brush = gradient) .padding(24.dp) .height(150.dp) ) } } } } }
nestedScroll 修飾子を使用する
複数の要素の間で高度に調整されたスクロールを作成する必要がある場合は、nestedScroll 修飾子を使用すると、ネスト スクロール階層を定義することにより柔軟性を高めることができます。前のセクションで説明したように、一部のコンポーネントには、ネスト スクロールのサポートが組み込まれています。ただし、自動的にスクロールできないコンポーザブル(Box や Column など)の場合、そのようなコンポーネントのスクロールのデルタは、ネスト スクロール システム内で伝播せず、デルタは NestedScrollConnection にも親コンポーネントにも到達しません。このための対応策として、nestedScroll を使用すると、カスタム コンポーネントを含む他のコンポーネントでもそのようなサポートを利用できるようになります。
ネスト スクロール サイクル
ネストされたスクロール サイクルとは、スクロール可能なコンポーネントと修飾子、または nestedScroll を使用するなどして、ネストされたスクロール システムの一部であるすべてのコンポーネント(またはノード)を介して階層ツリーを上下にディスパッチされるスクロール デルタのフローです。
ネストされたスクロール サイクルのフェーズ
スクロール可能なコンポーネントによってトリガー イベント(ジェスチャーなど)が検出されると、実際のスクロール アクションがトリガーされる前に、生成されたデルタがネストされたスクロール システムに送信され、スクロール前、ノード消費、スクロール後の 3 つのフェーズを通過します。
最初のスクロール前のフェーズでは、トリガー イベントのデルタを受け取ったコンポーネントが、階層ツリーを介して最上位の親にこれらのイベントをディスパッチします。デルタ イベントはバブリングされます。つまり、デルタは最上位の親からネスト スクロール サイクルを開始した子に向かって伝播されます。
これにより、ネストされたスクロールの親(nestedScroll またはスクロール可能な修飾子を使用するコンポーザブル)は、ノード自体が差分を消費する前に、差分に対して何らかの処理を行うことができます。
ノード消費フェーズでは、ノード自体が親で使用されなかった差分を使用します。このとき、スクロール動作が実際に行われ、表示されます。
このフェーズでは、お子様は残りのスクロールの全部または一部を使用することを選択できます。残りのものはすべて、スクロール後のフェーズに進むために戻されます。
最後に、スクロール後のフェーズでは、ノード自体が消費しなかったものは、消費のために祖先に再び送信されます。
スクロール後のフェーズは、スクロール前のフェーズと同様に動作します。親は消費するかどうかを選択できます。
スクロールと同様に、ドラッグ操作が終了すると、ユーザーの意図が速度に変換され、スクロール可能なコンテナをフリング(アニメーションを使用したスクロール)するために使用されることがあります。フリングもネストされたスクロール サイクルの一部であり、ドラッグ イベントによって生成される速度は、フリング前、ノード消費、フリング後という同様のフェーズを経ます。フリング アニメーションはタップ操作にのみ関連付けられており、a11y やハードウェア スクロールなどの他のイベントではトリガーされません。
ネストされたスクロールのサイクルに参加する
サイクルへの参加とは、階層に沿って差分をインターセプト、消費、消費レポートを行うことです。Compose は、ネストされたスクロール システムの動作と、そのシステムとの直接的なやり取りの方法に影響を与える一連のツールを提供します。たとえば、スクロール可能なコンポーネントがスクロールを開始する前に、スクロール デルタで何かを行う必要がある場合などです。
ネストされたスクロール サイクルがノードのチェーンに作用するシステムである場合、nestedScroll 修飾子は、これらの変更をインターセプトして挿入し、チェーンで伝播されるデータ(スクロール デルタ)に影響を与える方法です。この修飾子は階層内のどこにでも配置でき、ツリーの上位にあるネストされたスクロール修飾子インスタンスと通信して、このチャネルを介して情報を共有できます。この修飾子の構成要素は NestedScrollConnection と NestedScrollDispatcher です。
NestedScrollConnection
は、ネストされたスクロール サイクルのフェーズに応答し、ネストされたスクロール システムに影響を与える方法を提供します。4 つのコールバック メソッドで構成され、それぞれが消費フェーズ(スクロール前/後、フリング前/後)を表します。
val nestedScrollConnection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { println("Received onPreScroll callback.") return Offset.Zero } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { println("Received onPostScroll callback.") return Offset.Zero } }
各コールバックは、伝播される差分に関する情報も提供します。available は特定のフェーズの差分、consumed は前のフェーズで消費された差分です。階層の上位への差分の伝播を停止したい場合は、ネストされたスクロール接続を使用します。
val disabledNestedScrollConnection = remember { object : NestedScrollConnection { override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { return if (source == NestedScrollSource.SideEffect) { available } else { Offset.Zero } } } }
すべてのコールバックは、NestedScrollSource 型に関する情報を提供します。
NestedScrollDispatcher
は、ネストされたスクロール サイクルを初期化します。ディスパッチャを使用してそのメソッドを呼び出すと、サイクルがトリガーされます。スクロール可能なコンテナには、ジェスチャー中にキャプチャされた差分をシステムに送信するディスパッチャが組み込まれています。このため、ネストされたスクロールをカスタマイズするほとんどのユースケースでは、ディスパッチャではなく NestedScrollConnection を使用して、新しいデルタを送信するのではなく、既存のデルタに対応します。その他の使用方法については、NestedScrollDispatcherSample をご覧ください。
スクロール時に画像のサイズを変更する
ユーザーがスクロールすると、スクロール位置に基づいて画像のサイズが変化する動的な視覚効果を作成できます。
スクロール位置に基づいて画像のサイズを変更する
このスニペットは、垂直スクロール位置に基づいて LazyColumn 内の画像のサイズを変更する方法を示しています。ユーザーが下にスクロールすると画像が縮小し、上にスクロールすると拡大します。ただし、定義された最小サイズと最大サイズの範囲内に収まります。
@Composable fun ImageResizeOnScrollExample( modifier: Modifier = Modifier, maxImageSize: Dp = 300.dp, minImageSize: Dp = 100.dp ) { var currentImageSize by remember { mutableStateOf(maxImageSize) } var imageScale by remember { mutableFloatStateOf(1f) } val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { // Calculate the change in image size based on scroll delta val delta = available.y val newImageSize = currentImageSize + delta.dp val previousImageSize = currentImageSize // Constrain the image size within the allowed bounds currentImageSize = newImageSize.coerceIn(minImageSize, maxImageSize) val consumed = currentImageSize - previousImageSize // Calculate the scale for the image imageScale = currentImageSize / maxImageSize // Return the consumed scroll amount return Offset(0f, consumed.value) } } } Box(Modifier.nestedScroll(nestedScrollConnection)) { LazyColumn( Modifier .fillMaxWidth() .padding(15.dp) .offset { IntOffset(0, currentImageSize.roundToPx()) } ) { // Placeholder list items items(100, key = { it }) { Text( text = "Item: $it", style = MaterialTheme.typography.bodyLarge ) } } Image( painter = ColorPainter(Color.Red), contentDescription = "Red color image", Modifier .size(maxImageSize) .align(Alignment.TopCenter) .graphicsLayer { scaleX = imageScale scaleY = imageScale // Center the image vertically as it scales translationY = -(maxImageSize.toPx() - currentImageSize.toPx()) / 2f } ) } }
コードに関する主なポイント
- このコードでは、
NestedScrollConnectionを使用してスクロール イベントをインターセプトしています。 onPreScrollは、スクロール デルタに基づいて画像サイズの変更を計算します。currentImageSize状態変数には画像の現在のサイズが格納され、minImageSizeとmaxImageSize. imageScaleの間に制約される値はcurrentImageSizeから派生します。LazyColumnはcurrentImageSizeに基づいてオフセットされます。Imageは、graphicsLayer修飾子を使用して、計算されたスケールを適用します。graphicsLayer内のtranslationYにより、画像が拡大縮小されても垂直方向の中央に配置されます。
結果
上記のスニペットにより、スクロール時に画像が拡大縮小されます。
ネスト スクロールの相互運用
スクロール可能なコンポーザブルにスクロール可能な View 要素をネストしようとした場合、またはその逆を行おうとした場合、問題が発生することがあります。特に想定されるのが、子をスクロールしてその開始位置または終了位置に到達し、親にスクロールが引き継がれることを期待している場合です。この期待される動作が発生しないか、期待どおりに動作しない可能性があります。
この問題は、スクロール可能なコンポーザブルに組み込まれている前提の結果です。スクロール可能なコンポーザブルには「デフォルトでネスト スクロール」というルールがあります。つまり、スクロール可能なコンテナはすべて、ネスト スクロール チェーンに、NestedScrollConnection を介して親として参加し、NestedScrollDispatcher を介して子として参加する必要があります。そうすることで、子が境界にあるときに、子が親のネスト スクロールを行います。このようなルールにより、たとえば Compose の Pager と Compose の LazyRow が適切に連携します。ただし、相互運用スクロールが ViewPager2 または RecyclerView で行われている場合、これらは NestedScrollingParent3 を実装していないため、子から親への連続スクロールはできません。
スクロール可能な View 要素とスクロール可能なコンポーザブルが両方向でネストされており、それらの間でネスト スクロールの相互運用 API を有効にする場合、以下のシナリオでネスト スクロールの相互運用 API を使用することで、このような問題を軽減できます。
子 ComposeView を含んでいる連携する親 View
連携する親 View とは、すでに NestedScrollingParent3 を実装しているため、連携するネストされた子コンポーザブルからスクロールのデルタを受け取ることができる View です。この場合、ComposeView は子となるので、(間接的に)NestedScrollingChild3 を実装する必要があります。連携する親の例には androidx.coordinatorlayout.widget.CoordinatorLayout があります。
スクロール可能な View である親コンテナとネストされたスクロール可能な子コンポーザブルの間にネスト スクロールの相互運用が必要な場合は、rememberNestedScrollInteropConnection() を使用できます。
rememberNestedScrollInteropConnection() により、NestedScrollingParent3 を実装する親 View と子 Compose との間でネスト スクロールの相互運用を有効にする NestedScrollConnection の許可と保存が可能になります。これは nestedScroll 修飾子と組み合わせて使用する必要があります。ネスト スクロールは Compose 側でデフォルトで有効になっているため、この接続を使用すると、View 側でネスト スクロールの両方を有効にし、Views とコンポーザブルの間に必要なグルーロジックを追加できます。
頻繁に使用されるユースケースでは、以下の例のように、CoordinatorLayout、CollapsingToolbarLayout、子コンポーザブルを使用します。
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.appbar.AppBarLayout android:id="@+id/app_bar" android:layout_width="match_parent" android:layout_height="100dp" android:fitsSystemWindows="true"> <com.google.android.material.appbar.CollapsingToolbarLayout android:id="@+id/collapsing_toolbar_layout" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" app:layout_scrollFlags="scroll|exitUntilCollapsed"> <!--...--> </com.google.android.material.appbar.CollapsingToolbarLayout> </com.google.android.material.appbar.AppBarLayout> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" app:layout_behavior="@string/appbar_scrolling_view_behavior" android:layout_width="match_parent" android:layout_height="match_parent"/> </androidx.coordinatorlayout.widget.CoordinatorLayout>
次のように、アクティビティまたはフラグメントで、子コンポーザブルと必要な NestedScrollConnection をセットアップする必要があります。
open class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewById<ComposeView>(R.id.compose_view).apply { setContent { val nestedScrollInterop = rememberNestedScrollInteropConnection() // Add the nested scroll connection to your top level @Composable element // using the nestedScroll modifier. LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) { items(20) { item -> Box( modifier = Modifier .padding(16.dp) .height(56.dp) .fillMaxWidth() .background(Color.Gray), contentAlignment = Alignment.Center ) { Text(item.toString()) } } } } } } }
子 AndroidView を含んでいる親コンポーザブル
このシナリオでは、子 AndroidView を含んでいる親コンポーザブルがある場合の Compose 側のネスト スクロールの相互運用 API の実装を扱います。AndroidView は、スクロールにおける親である Compose に対しては子になるので NestedScrollDispatcher を実装し、スクロールにおける子である View に対しては親になるので NestedScrollingParent3 を実装します。すると、Compose である親は、ネストされたスクロール可能な子 View からネスト スクロールの差分を受け取ることができるようになります。
次の例は、このシナリオでネスト スクロールの相互運用を、Compose の折りたたみツールバーとともに実現する方法を示しています。
@Composable private fun NestedScrollInteropComposeParentWithAndroidChildExample() { val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() } val toolbarOffsetHeightPx = remember { mutableStateOf(0f) } // Sets up the nested scroll connection between the Box composable parent // and the child AndroidView containing the RecyclerView val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { // Updates the toolbar offset based on the scroll to enable // collapsible behaviour val delta = available.y val newOffset = toolbarOffsetHeightPx.value + delta toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f) return Offset.Zero } } } Box( Modifier .fillMaxSize() .nestedScroll(nestedScrollConnection) ) { TopAppBar( title = {}, modifier = Modifier .height(ToolbarHeight) .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) } ) AndroidView( { context -> LayoutInflater.from(context) .inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply { with(findViewById<RecyclerView>(R.id.main_list)) { layoutManager = LinearLayoutManager(context, VERTICAL, false) adapter = NestedScrollInteropAdapter() } }.also { // Nested scrolling interop is enabled when // nested scroll is enabled for the root View ViewCompat.setNestedScrollingEnabled(it, true) } }, // ... ) } } private class NestedScrollInteropAdapter : Adapter<NestedScrollInteropAdapter.NestedScrollInteropViewHolder>() { val items = (1..10).map { it.toString() } override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): NestedScrollInteropViewHolder { return NestedScrollInteropViewHolder( LayoutInflater.from(parent.context) .inflate(R.layout.list_item, parent, false) ) } override fun onBindViewHolder(holder: NestedScrollInteropViewHolder, position: Int) { // ... } class NestedScrollInteropViewHolder(view: View) : ViewHolder(view) { fun bind(item: String) { // ... } } // ... }
次の例は、scrollable 修飾子で、この API を使用する方法を示しています。
@Composable fun ViewInComposeNestedScrollInteropExample() { Box( Modifier .fillMaxSize() .scrollable( rememberScrollableState { // View component deltas should be reflected in Compose // components that participate in nested scrolling it }, Orientation.Vertical ) ) { AndroidView( { context -> LayoutInflater.from(context) .inflate(R.layout.list_item, null) .apply { // Nested scrolling interop is enabled when // nested scroll is enabled for the root View ViewCompat.setNestedScrollingEnabled(this, true) } } ) } }
次にある最後の例では、ネスト スクロールの相互運用 API を BottomSheetDialogFragment で使用して、ドラッグして閉じる動作を実現する方法を示しています。
class BottomSheetFragment : BottomSheetDialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val rootView: View = inflater.inflate(R.layout.fragment_bottom_sheet, container, false) rootView.findViewById<ComposeView>(R.id.compose_view).apply { setContent { val nestedScrollInterop = rememberNestedScrollInteropConnection() LazyColumn( Modifier .nestedScroll(nestedScrollInterop) .fillMaxSize() ) { item { Text(text = "Bottom sheet title") } items(10) { Text( text = "List item number $it", modifier = Modifier.fillMaxWidth() ) } } } return rootView } } }
rememberNestedScrollInteropConnection() がアタッチ先の要素に NestedScrollConnection をインストールします。NestedScrollConnection は、デルタを Compose レベルから View レベルに送信します。これにより、その要素はネスト スクロールに参加できるようになりますが、要素のスクロールは自動的には有効になりません。自動的にスクロールできないコンポーザブル(Box や Column など)には、そのようなコンポーネントのスクロールのデルタは、ネスト スクロール システム内で伝播せず、デルタは rememberNestedScrollInteropConnection() が提供する NestedScrollConnection にも到達しないため、それらのデルタは親である View コンポーネントにも到達しません。この問題を解決するには、scrollable 修飾子をこれらのタイプのネストされたコンポーザブルにも設定します。詳細については、ネスト スクロールに関する前のセクションをご覧ください。
子 ComposeView を含んでいる連携しない親 View
連携しない View とは、View 側で必要な NestedScrolling インターフェースを実装していない View のことです。つまり、こうした Views とのネスト スクロールの相互運用は、そのままでは機能しません。連携しない Views は、RecyclerView と ViewPager2 です。
参考情報
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- ジェスチャーについて
CoordinatorLayoutを Compose に移行する- Compose でビューを使用する