2 次元スクロール: scrollable2D、draggable2D

Jetpack Compose では、scrollable2D と draggable2D は、2 次元でポインタ入力を処理するように設計された低レベルの修飾子です。標準の 1D 修飾子 scrollable と draggable は 1 つの方向に制限されますが、2D バリアントは X 軸と Y 軸の両方の動きを同時に追跡します。

たとえば、既存の scrollable 修飾子は単一方向のスクロールとフリングに使用され、scrollable2d は 2D のスクロールとフリングに使用されます。これにより、スプレッドシートや画像ビューアなど、あらゆる方向に移動するより複雑なレイアウトを作成できます。scrollable2d 修飾子は、2D シナリオでのネストされたスクロールもサポートしています。

図 1. 地図上での双方向のパン。

scrollable2D または draggable2D を選択

適切な API の選択は、移動する UI 要素と、それらの要素に望ましい物理的な動作によって異なります。

Modifier.scrollable2D: コンテナでこの修飾子を使用して、コンテナ内のコンテンツを移動します。たとえば、地図、スプレッドシート、フォトビューアなど、コンテナのコンテンツを水平方向と垂直方向の両方にスクロールする必要がある場合に使用します。フリングのサポートが組み込まれているため、スワイプ後もコンテンツが移動し続け、ページ上の他のスクロール コンポーネントと連携します。

Modifier.draggable2D: この修飾子を使用して、コンポーネント自体を移動します。これは軽量の修飾子であるため、ユーザーの指が止まると、動きが正確に止まります。フリングのサポートは含まれていません。

コンポーネントをドラッグ可能にする必要があるものの、フリングやネストされたスクロールのサポートは必要ない場合は、draggable2D を使用します。

2D 修飾子を実装する

以降のセクションでは、2D 修飾子の使用方法の例を示します。

Modifier.scrollable2D を実装する

この修飾子は、ユーザーがコンテンツを全方向に移動する必要があるコンテナに使用します。

2D 動作データをキャプチャする

次の例は、未加工の 2D 移動データをキャプチャして X、Y オフセットを表示する方法を示しています。

@Composable
private fun Scrollable2DSample() {
    // 1. Manually track the total distance the user has moved in both X and Y directions
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            // ...
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                // 2. Attach the 2D scroll logic to capture XY movement deltas
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        // 3. Update the cumulative offset state with the new movement delta
                        offset += delta

                        // Return the delta to indicate the entire movement was handled by this box
                        delta
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // 4. Display the current X and Y values from the offset state in real-time
                Text(
                    text = "X: ${offset.x.roundToInt()}",
                    // ...
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Y: ${offset.y.roundToInt()}",
                    // ...
                )
            }
        }
    }
}

図 2. ユーザーがポインタをドラッグすると、現在の X 座標と Y 座標のオフセットを追跡して表示する紫色のボックス。

上記のスニペットは次の処理を行います。

  • ユーザーがスクロールした合計距離を保持する状態として offset を使用します。
  • rememberScrollable2DState 内で、ユーザーの指によって生成されたすべてのデルタを処理するラムダ関数が定義されています。コード offset.value += delta は、手動状態を新しい位置で更新します。
  • Text コンポーネントは、offset 状態の現在の X 値と Y 値を表示します。これらの値は、ユーザーがドラッグするとリアルタイムで更新されます。

大きなビューポートをパンする

この例では、キャプチャした 2D スクロール可能データを使用し、親コンテナよりも大きいコンテンツに translationX と translationY を適用する方法を示します。

@Composable
private fun Panning2DImage() {

    // Manually track the total distance the user has moved in both X and Y directions
    val offset = remember { mutableStateOf(Offset.Zero) }

    // Define how gestures are captured. The lambda is called for every finger movement
    val scrollState = rememberScrollable2DState { delta ->
        offset.value += delta
        delta
    }

    // The Viewport (Container): A fixed-size box that acts as a window into the larger content
    Box(
        modifier = Modifier
            .size(600.dp, 400.dp) // The visible area dimensions
            // ...
            // Hide any parts of the large content that sit outside this container's boundaries
            .clipToBounds()
            // Apply the 2D scroll modifier to intercept touch and fling gestures in all directions
            .scrollable2D(state = scrollState),
        contentAlignment = Alignment.Center,
    ) {
        // The Content: An image given a much larger size than the container viewport
        Image(
            painter = painterResource(R.drawable.cheese_5),
            contentDescription = null,
            modifier = Modifier
                .requiredSize(1200.dp, 800.dp)
                // Manual Scroll Effect: Since scrollable2D doesn't move content automatically,
                // we use graphicsLayer to shift the drawing position based on the tracked offset.
                .graphicsLayer {
                    translationX = offset.value.x
                    translationY = offset.value.y
                },
            contentScale = ContentScale.FillBounds
        )
    }
}

図 3.Modifier.scrollable2D で作成された、双方向パンニング画像ビューポート。
図 4. Modifier.scrollable2D で作成された、双方向パンニング テキスト ビューポート。

上記のスニペットには、次のものが含まれています。

  • コンテナは固定サイズ(600x400dp)に設定され、コンテンツは親サイズにサイズ変更されないように、はるかに大きいサイズ(1200x800dp)に設定されています。
  • コンテナの clipToBounds() 修飾子により、600x400 ボックスの外側にある大きなコンテンツの部分が非表示になります。
  • LazyColumn などの高レベルのコンポーネントとは異なり、scrollable2D はコンテンツを自動的に移動しません。代わりに、graphicsLayer 変換またはレイアウト オフセットを使用して、トラッキングされた offset をコンテンツに適用する必要があります。
  • graphicsLayer ブロック内の translationX = offset.value.x と translationY = offset.value.y は、指の動きに基づいて画像またはテキストの描画位置をシフトし、スクロールの視覚効果を作成します。

scrollable2D を使用してネストされたスクロールを実装する

この例は、双方向コンポーネントを、縦方向のニュース フィードなどの標準的な 1 次元親に統合する方法を示しています。

ネストされたスクロールを実装する際は、次の点に注意してください。

  • rememberScrollable2DState のラムダは、消費されたデルタのみを返す必要があります。これにより、子が上限に達したときに、親リストが自然に引き継ぐことができます。
  • ユーザーが斜めにフリックすると、2D 速度が共有されます。アニメーション中に子が境界に達すると、残りの運動量が親に伝播され、スクロールが自然に継続されます。

@Composable
private fun NestedScrollable2DSample() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    val maxScrollDp = 250.dp
    val maxScrollPx = with(LocalDensity.current) { maxScrollDp.toPx() }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .background(Color(0xFFF5F5F5)),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            "Scroll down to find the 2D Box",
            modifier = Modifier.padding(top = 100.dp, bottom = 500.dp),
            style = TextStyle(fontSize = 18.sp, color = Color.Gray)
        )

        // The Child: A 2D scrollable box with nested scroll coordination
        Box(
            modifier = Modifier
                .size(250.dp)
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        val oldOffset = offset

                        // Calculate new potential offset and clamp it to our boundaries
                        val newX = (oldOffset.x + delta.x).coerceIn(-maxScrollPx, maxScrollPx)
                        val newY = (oldOffset.y + delta.y).coerceIn(-maxScrollPx, maxScrollPx)

                        val newOffset = Offset(newX, newY)

                        // Calculate exactly how much was consumed by the child
                        val consumed = newOffset - oldOffset

                        offset = newOffset

                        // IMPORTANT: Return ONLY the consumed delta.
                        // The remaining (unconsumed) delta propagates to the parent Column.
                        consumed
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                val density = LocalDensity.current
                Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
                Spacer(Modifier.height(8.dp))
                Text("X: ${with(density) { offset.x.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
                Text("Y: ${with(density) { offset.y.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
            }
        }

        Text(
            "Once the Purple Box hits Y: 250 or -250,\nthis parent list will take over the vertical scroll.",
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 40.dp, bottom = 800.dp),
            style = TextStyle(fontSize = 14.sp, color = Color.Gray)
        )
    }
}

図 5. 垂直スクロール リスト内の紫色のボックス。内部の 2D 移動が可能ですが、ボックスの内部 Y オフセットが 300 ピクセルの上限に達すると、垂直スクロール コントロールが親リストに渡されます。

上記のスニペットでは、次のようになっています。

  • 2D コンポーネントは、X 軸の動きを消費して内部でパンしながら、子の垂直方向の境界に達すると、Y 軸の動きを親リストにディスパッチできます。
  • このシステムでは、ユーザーを 2D サーフェス内に閉じ込めるのではなく、消費されたデルタを計算し、残りを階層の上位に渡します。これにより、ユーザーは指を離さずにページの残りの部分をスクロールし続けることができます。

Modifier.draggable2D を実装する

Modifier.draggable2D の使用方法については、Modifier.draggable2D を使用して 2 次元でドラッグするをご覧ください。