Jetpack Compose では、scrollable2D と draggable2D は 2 次元でのポインタ入力を処理するように設計された低レベルの修飾子です。標準の 1D 修飾子 scrollable と draggable は単一の向きに制限されますが、2D バリアントは X 軸と Y 軸の両方の動きを同時に追跡します。
たとえば、既存の scrollable 修飾子は単一の向きの
スクロールとフリングに使用されますが、scrollable2d は 2D でのスクロールとフリング
に使用されます。これにより、スプレッドシートや画像ビューアなど、あらゆる方向に移動する複雑なレイアウトを作成できます。scrollable2d 修飾子は、2D シナリオでのネストされたスクロールもサポートしています。
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()}", // ... ) } } } }
上記のスニペットは次のことを行います。
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 ) } }
Modifier.scrollable2D で作成された双方向パン画像ビューポート。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) ) } }
上記のスニペットでは、次のようになります。
- 2D コンポーネントは、X 軸の動きを消費して内部的にパンできます。同時に、子要素の垂直方向の境界に達すると、Y 軸の動きを親リストにディスパッチします。
- ユーザーを 2D サーフェス内に閉じ込めるのではなく、消費されたデルタを計算し、残りを階層に渡します。これにより、ユーザーは指を離さずにページの残りの部分をスクロールできます。
Modifier.draggable2D を実装する
draggable2D 修飾子は、個々の UI 要素を移動するために使用します。
コンポーザブル要素をドラッグする
この例は、draggable2D の最も一般的なユースケースを示しています。ユーザーが UI 要素を選択して、親コンテナ内の任意の場所に再配置できます。
@Composable private fun DraggableComposableElement() { // 1. Track the position of the floating window var offset by remember { mutableStateOf(Offset.Zero) } Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) { Box( modifier = Modifier // 2. Apply the offset to the box's position .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) } // ... // 3. Attach the 2D drag logic .draggable2D( state = rememberDraggable2DState { delta -> // 4. Update the position based on the movement delta offset += delta } ), contentAlignment = Alignment.Center ) { Text("Video Preview", color = Color.White, fontSize = 12.sp) } } }
上記のコード スニペットには、次のものが含まれています。
offset状態を使用してボックスの位置を追跡します。offset修飾子を使用して、ドラッグ デルタに基づいてコンポーネントの位置をシフトします。- フリングのサポートがないため、ユーザーが指を離すとすぐにボックスの移動が停止します。
親のドラッグ領域に基づいて子コンポーザブルをドラッグする
この例では、draggable2D を使用して、セレクタ ノブが特定のサーフェス内に制約される 2D 入力領域を作成する方法を示します。コンポーネント自体を移動するドラッグ可能な要素の例とは異なり、この実装では 2D デルタを使用して、カラー選択ツール全体で子コンポーズ可能な関数「セレクタ」を移動します。
@Composable private fun ExampleColorSelector( // ... ) { // 1. Maintain the 2D position of the selector in state. var selectorOffset by remember { mutableStateOf(Offset.Zero) } // 2. Track the size of the background container. var containerSize by remember { mutableStateOf(IntSize.Zero) } Box( modifier = Modifier .size(300.dp, 200.dp) // Capture the actual pixel dimensions of the container when it's laid out. .onSizeChanged { containerSize = it } .clip(RoundedCornerShape(12.dp)) .background( brush = remember(hue) { // Create a simple gradient representing Saturation and Value for the given Hue. Brush.linearGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f))) } ) ) { Box( modifier = Modifier .size(24.dp) .graphicsLayer { // Center the selector on the finger by subtracting half its size. translationX = selectorOffset.x - (24.dp.toPx() / 2) translationY = selectorOffset.y - (24.dp.toPx() / 2) } // ... // 3. Configure 2D touch dragging. .draggable2D( state = rememberDraggable2DState { delta -> // 4. Calculate the new position and clamp it to the container bounds val newX = (selectorOffset.x + delta.x) .coerceIn(0f, containerSize.width.toFloat()) val newY = (selectorOffset.y + delta.y) .coerceIn(0f, containerSize.height.toFloat()) selectorOffset = Offset(newX, newY) } ) ) } }
上記のスニペットには、次のものが含まれています。
onSizeChanged修飾子を使用して、グラデーション コンテナの実際のサイズを取得します。セレクタはエッジの位置を正確に把握しています。graphicsLayer内で、セレクタがドラッグ中に中央に配置されるようにtranslationXとtranslationYを調整します。