スクロール

Scroll 修飾子

verticalScroll 修飾子と horizontalScroll 修飾子は、コンテンツの境界が最大サイズ制約より大きい場合にユーザーが要素をスクロールできるようにする最も簡単な方法を提供します。verticalScroll 修飾子と horizontalScroll 修飾子では、コンテンツを変換またはオフセットする必要はありません。

@Composable
private fun ScrollBoxes() {
    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .verticalScroll(rememberScrollState())
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

スクロール操作に応答するシンプルな垂直方向のリスト

ScrollState により、スクロール位置を変更したり、現在の状態を取得したりできます。デフォルトのパラメータでこれを作成するには、rememberScrollState() を使用します。

@Composable
private fun ScrollBoxesSmooth() {
    // Smoothly scroll 100px on first composition
    val state = rememberScrollState()
    LaunchedEffect(Unit) { state.animateScrollTo(100) }

    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .padding(horizontal = 8.dp)
            .verticalScroll(state)
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

Scrollable 修飾子

scrollable 修飾子が scroll 修飾子と異なる点は、scrollable はスクロール操作を検出して差分をキャプチャしますが、コンテンツを自動的にオフセットしないことです。代わりに、ScrollableState を介してユーザーに委任されます。この修飾子が正しく動作するためには、ScrollableState が必要です。

ScrollableState を作成する際は、各スクロール ステップで(操作入力、スムーズ スクロール、またはフリングによって)呼び出される consumeScrollDelta 関数をピクセル単位のデルタで提供する必要があります。この関数は、scrollable 修飾子を持つネスト要素がある場合にイベントが適切に伝播されるように、消費したスクロール距離の量を返します。

次のスニペットは、操作を検出してオフセットの数値を表示しますが、要素のオフセットは行いません。

@Composable
private fun ScrollableSample() {
    // actual composable state
    var offset by remember { mutableStateOf(0f) }
    Box(
        Modifier
            .size(150.dp)
            .scrollable(
                orientation = Orientation.Vertical,
                // Scrollable state: describes how to consume
                // scrolling delta and update offset
                state = rememberScrollableState { delta ->
                    offset += delta
                    delta
                }
            )
            .background(Color.LightGray),
        contentAlignment = Alignment.Center
    ) {
        Text(offset.toString())
    }
}

指の押下を検出して指の位置の数値を表示する UI 要素

ネストされたスクロール

ネスト スクロールは、相互に含まれる複数のスクロール コンポーネントが、単一のスクロール操作に反応してスクロールのデルタ(変化)を通信することで連携するシステムです。

ネストされたスクロール システムでは、スクロール可能で階層的にリンクされたコンポーネント(ほとんどの場合、同じ親を共有)を調整できます。このシステムはスクロール コンテナをリンクし、伝播され共有されるスクロール デルタの操作を可能にします。

Compose には、コンポーザブル間のネストされたスクロールを処理する複数の方法が用意されています。ネスト スクロールの一般的な例としては、別のリストに含まれるリストがあります。より複雑なケースとしては、折りたたみツールバーがあります。

自動ネスト スクロール

シンプルなネスト スクロールでは、アプリ側のアクションは必要ありません。スクロール アクションを開始する操作は、子から親に自動的に伝播されます。これにより、子がそれ以上スクロールできなくなると、親要素によって操作が処理されます。

自動ネスト スクロールは Compose のいくつかのコンポーネントと修飾子(verticalScrollhorizontalScrollscrollableLazy 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)
                    )
                }
            }
        }
    }
}

内部要素の内側と外側の操作に応答する、2 つの垂直方向のネスト スクロールの UI 要素

nestedScroll 修飾子を使用する

複数の要素の間で高度に調整されたスクロールを作成する必要がある場合は、nestedScroll 修飾子を使用すると、ネスト スクロール階層を定義することにより柔軟性を高めることができます。前のセクションで説明したように、一部のコンポーネントには、ネスト スクロールのサポートが組み込まれています。ただし、自動的にスクロールできないコンポーザブル(BoxColumn など)の場合、そのようなコンポーネントのスクロールのデルタは、ネスト スクロール システム内で伝播せず、デルタは NestedScrollConnection にも親コンポーネントにも到達しません。この問題を解決するには、nestedScroll を使用して、カスタム コンポーネントを含む他のコンポーネントにもこのようなサポートを付与します。

ネストされたスクロール サイクル

ネストされたスクロール サイクルは、スクロール可能なコンポーネントと修飾子、または nestedScroll を使用して、ネストされたスクロール システムの一部であるすべてのコンポーネント(またはノード)を介して階層ツリーを上下にディスパッチされるスクロール デルタのフローです。

ネストされたスクロール サイクルのフェーズ

スクロール可能なコンポーネントによってトリガー イベント(ジェスチャーなど)が検出されると、実際のスクロール アクションがトリガーされる前に、生成された差分はネストされたスクロール システムに送信され、スクロール前、ノードの消費、スクロール後の 3 つのフェーズを経ます。

ネストされたスクロール サイクルのフェーズ

最初のスクロール前フェーズでは、トリガー イベントのデルタを受け取ったコンポーネントが、それらのイベントを階層ツリーを介して最上位の親にディスパッチします。デルタイベントは下方向にバブルアップされます。つまり、デルタはルート親から、ネストされたスクロール サイクルを開始した子に伝播されます。

スクロール前フェーズ - アップディスパッチ

これにより、ネストされたスクロールの親(nestedScroll またはスクロール可能な修飾子を使用するコンポーザブル)は、ノード自体が差分を消費する前に、差分に対して何かを行うことができます。

スクロール前フェーズ - 下方向へのバブルアップ

ノード使用フェーズでは、ノード自体が親で使用されていない差分を使用します。スクロール モーションが実際に完了し、表示されている状態です。

ノードの消費フェーズ

このフェーズでは、子ビューは残りのスクロールの一部またはすべてを消費できます。残りのアイテムは、スクロール後のフェーズに進むために上に戻されます。

最後に、スクロール後のフェーズで、ノード自体が消費しなかったものは、消費のために再びその祖先に送信されます。

スクロール後のフェーズ - アップディスパッチ

スクロール後フェーズは、スクロール前フェーズと同様に機能します。親のいずれかが消費するかどうかを選択できます。

スクロール後のフェーズ - 下方向へのバブルアップ

スクロールと同様に、ドラッグ ジェスチャーが終了すると、ユーザーの意図が速度に変換され、スクロール可能なコンテナのフリング(アニメーションによるスクロール)に使用されます。フリングもネストされたスクロール サイクルの一部であり、ドラッグ イベントによって生成された速度は、フリング前、ノードの消費、フリング後の同様のフェーズを経ます。フリング アニメーションはタップ ジェスチャーにのみ関連付けられ、a11y やハードウェア スクロールなどの他のイベントではトリガーされません。

ネストされたスクロール サイクルに参加する

サイクルに参加するということは、階層に沿って差分消費をインターセプト、消費、レポートすることを意味します。Compose には、ネストされたスクロール システムの動作とそのシステムを直接操作する方法に影響を与える一連のツールが用意されています。たとえば、スクロール可能なコンポーネントがスクロールを開始する前にスクロール デルタで何かを行う必要がある場合などです。

ネストされたスクロール サイクルがノードのチェーンに作用するシステムである場合、nestedScroll 修飾子は、これらの変更をインターセプトして挿入し、チェーンに伝播されるデータ(スクロール デルタ)に影響を与える手段です。この修飾子は階層内の任意の場所に配置できます。この修飾子は、ツリー上のネストされたスクロール修飾子インスタンスと通信し、このチャネルを介して情報を共有できます。この修飾子の構成要素は NestedScrollConnectionNestedScrollDispatcher です。

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 状態変数には、minImageSizemaxImageSize. imageScale の間で制約される画像の現在のサイズが保存されます。maxImageSize. imageScalecurrentImageSize から派生します。
  • currentImageSize に基づく LazyColumn オフセット。
  • Image は、graphicsLayer 修飾子を使用して、計算されたスケールを適用します。
  • graphicsLayer 内の translationY により、画像はスケーリングしても垂直方向で中央に配置されます。

結果

上記のスニペットを使用すると、スクロール時に画像が拡大されます。

図 1. スクロール時の画像スケーリング効果。

ネストされたスクロールの相互運用

スクロール可能なコンポーザブルにスクロール可能な 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 とコンポーザブルの間に必要なグルーロジックを追加できます。

頻繁に使用されるユースケースでは、以下の例のように、CoordinatorLayoutCollapsingToolbarLayout、子コンポーザブルを使用します。

<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(
            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(android.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 レベルに送信します。これにより、その要素はネスト スクロールに参加できるようになりますが、要素のスクロールは自動的には有効になりません。自動的にスクロールできないコンポーザブル(BoxColumn など)には、そのようなコンポーネントのスクロールのデルタは、ネスト スクロール システム内で伝播せず、デルタは rememberNestedScrollInteropConnection() が提供する NestedScrollConnection にも到達しないため、それらのデルタは親である View コンポーネントにも到達しません。この問題を解決するには、scrollable 修飾子をこれらのタイプのネストされたコンポーザブルにも設定します。詳細については、ネスト スクロールに関する前のセクションをご覧ください。

ComposeView を含む連携しない親 View

連携しない View とは、View 側で必要な NestedScrolling インターフェースを実装していない View のことです。つまり、こうした Views とのネスト スクロールの相互運用は、そのままでは機能しません。連携しない Views は、RecyclerViewViewPager2 です。