スクロール

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 を作成する際は、各スクロール ステップで(操作入力、スムーズ スクロール、またはフリングによって)呼び出される 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 またはスクロール可能な修飾子を使用するコンポーザブル)は、ノード自体が差分を使用する前に、差分に対して何らかの処理を行うことができます。

スクロール前フェーズ - バブリング ダウン

ノード消費フェーズでは、ノード自体が親で使用されなかった差分を使用します。スクロール動作が実際に行われ、表示されるときです。

ノード消費フェーズ

このフェーズでは、お子様は残りのスクロールの全部または一部を消費することを選択できます。残りのアイテムは、スクロール後のフェーズに進むために戻されます。

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

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

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

スクロール後のフェーズ - バブリング ダウン

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

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

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

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

NestedScrollConnection は、ネストされたスクロール サイクルのフェーズに応答し、ネストされたスクロール システムに影響を与える方法を提供します。4 つのコールバック メソッドで構成され、それぞれがスクロール前/後、フリング前/後という消費フェーズの 1 つを表します。

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 の間で制約され、currentImageSize から派生します。
  • LazyColumncurrentImageSize に基づいてオフセットされます。
  • 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 です。

参考情報

現在、おすすめはありません。

Google アカウントにしてください。