Zweidimensionales Scrollen: scrollable2D, draggable2D

In Jetpack Compose sind scrollable2D und draggable2D Modifikatoren auf niedriger Ebene, die für die Verarbeitung von Zeigereingaben in zwei Dimensionen entwickelt wurden. Während die Standardmodifikatoren scrollable und draggable auf eine einzige Ausrichtung beschränkt sind, verfolgen die 2D-Varianten Bewegungen gleichzeitig auf der X‑ und der Y‑Achse.

Der vorhandene Modifikator scrollable wird beispielsweise für das Scrollen und Flinging in einer einzigen Ausrichtung verwendet, während scrollable2d für das Scrollen und Flinging in 2D verwendet wird. So können Sie komplexere Layouts erstellen, die sich in alle Richtungen bewegen, z. B. Tabellen oder Bildbetrachter. Der Modifikator scrollable2d unterstützt auch das verschachtelte Scrollen in 2D-Szenarien.

Abbildung 1 Bidirektionales Schwenken auf einer Karte.

scrollable2D oder draggable2D auswählen

Die Auswahl der richtigen API hängt von den UI-Elementen ab, die Sie verschieben möchten, und vom bevorzugten physischen Verhalten dieser Elemente.

Modifier.scrollable2D: Verwenden Sie diesen Modifikator für einen Container, um Inhalte darin zu verschieben. Verwenden Sie ihn beispielsweise mit Karten, Tabellen oder Fotobetrachtern, bei denen der Inhalt des Containers sowohl horizontal als auch vertikal gescrollt werden muss. Er bietet integrierte Unterstützung für Flinging, sodass sich der Inhalt nach einem Wischen weiter bewegt, und er ist mit anderen Scrollkomponenten auf der Seite koordiniert.

Modifier.draggable2D: Verwenden Sie diesen Modifikator, um eine Komponente selbst zu verschieben. Es ist ein einfacher Modifikator, sodass die Bewegung genau dann endet, wenn der Finger des Nutzers aufhört, sich zu bewegen. Er bietet keine Unterstützung für Flinging.

Wenn Sie eine Komponente verschiebbar machen möchten, aber keine Unterstützung für Flinging oder verschachteltes Scrollen benötigen, verwenden Sie draggable2D.

2D-Modifikatoren implementieren

In den folgenden Abschnitten finden Sie Beispiele für die Verwendung der 2D-Modifikatoren.

Modifier.scrollable2D implementieren

Verwenden Sie diesen Modifikator für Container, in denen der Nutzer Inhalte in alle Richtungen verschieben muss.

2D-Bewegungsdaten erfassen

In diesem Beispiel wird gezeigt,wie Sie unaufbereitete 2D-Bewegungsdaten erfassen und den X‑ und Y‑Offset anzeigen:

@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()}",
                    // ...
                )
            }
        }
    }
}

Abbildung 2. Ein lila Kästchen, das die aktuellen X‑ und Y‑Koordinaten-Offsets verfolgt und anzeigt, während der Nutzer den Zeiger über die Oberfläche zieht.

Das obige Snippet führt Folgendes aus:

  • offset wird als Status verwendet, der die Gesamtstrecke enthält, die der Nutzer gescrollt hat.
  • In rememberScrollable2DState wird eine Lambda-Funktion definiert, um jedes Delta zu verarbeiten, das durch den Finger des Nutzers generiert wird. Der Code offset.value += delta aktualisiert den manuellen Status mit der neuen Position.
  • Die Text-Komponenten zeigen die aktuellen X‑ und Y‑Werte dieses offset-Status an, die in Echtzeit aktualisiert werden, während der Nutzer zieht.

Großen Darstellungsbereich schwenken

In diesem Beispiel wird gezeigt, wie Sie erfasste 2D-Scrollable-Daten verwenden und eine translationX und translationY auf Inhalte anwenden, die größer als der übergeordnete Container sind:

@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
        )
    }
}

Abbildung 3. Ein bidirektionaler Darstellungsbereich für Bilder, der mit Modifier.scrollable2D erstellt wurde.
Abbildung 4 Ein bidirektionaler Darstellungsbereich für Text, der mit Modifier.scrollable2D erstellt wurde.

Das obige Snippet enthält Folgendes:

  • Der Container hat eine feste Größe (600x400dp), während der Inhalt eine viel größere Größe (1200x800dp) hat, damit er nicht auf die Größe des übergeordneten Elements angepasst wird.
  • Der Modifikator clipToBounds() im Container sorgt dafür, dass alle Teile des großen Inhalts, die sich außerhalb des 600x400-Kästchens befinden, ausgeblendet werden.
  • Im Gegensatz zu Komponenten auf hoher Ebene wie LazyColumn verschiebt scrollable2D den Inhalt nicht automatisch. Stattdessen müssen Sie den verfolgten offset auf Ihre Inhalte anwenden, entweder mit graphicsLayer-Transformationen oder Layout-Offsets.
  • Im Block graphicsLayer verschieben translationX = offset.value.x und translationY = offset.value.y die Zeichenposition des Bildes oder Texts basierend auf der Bewegung Ihres Fingers, wodurch der visuelle Effekt des Scrollens entsteht.

Verschachteltes Scrollen mit scrollable2D implementieren

In diesem Beispiel wird gezeigt, wie eine bidirektionale Komponente in ein standardmäßiges eindimensionales übergeordnetes Element wie einen vertikalen Newsfeed eingebunden werden kann.

Beachten Sie bei der Implementierung des verschachtelten Scrollens die folgenden Punkte:

  • Die Lambda-Funktion für rememberScrollable2DState sollte nur das verbrauchte Delta zurückgeben, damit die übergeordnete Liste auf natürliche Weise übernimmt, wenn das untergeordnete Element sein Limit erreicht.
  • Wenn ein Nutzer einen diagonalen Fling ausführt, wird die 2D-Geschwindigkeit geteilt. Wenn das untergeordnete Element während der Animation eine Grenze erreicht, wird das verbleibende Momentum an das übergeordnete Element weitergegeben , um das Scrollen auf natürliche Weise fortzusetzen.

@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)
        )
    }
}

Abbildung 5. Ein lila Kästchen in einer vertikalen Scrollliste, das interne 2D-Bewegungen ermöglicht, aber die vertikale Scrollsteuerung an die übergeordnete Liste übergibt, sobald der interne Y‑Offset des Kästchens sein Limit von 300 Pixel erreicht.

Im obigen Snippet gilt Folgendes:

  • Die 2D-Komponente kann Bewegungen auf der X‑Achse verbrauchen, um intern zu schwenken, und gleichzeitig Bewegungen auf der Y‑Achse an die übergeordnete Liste senden, sobald die eigenen vertikalen Grenzen des untergeordneten Elements erreicht sind.
  • Anstatt den Nutzer auf der 2D-Oberfläche festzuhalten, berechnet das System das verbrauchte Delta und gibt den Rest an die übergeordnete Ebene weiter. So kann der Nutzer weiter durch den Rest der Seite scrollen, ohne den Finger zu heben.

Modifier.draggable2D implementieren

Verwenden Sie den Modifikator draggable2D, um einzelne UI-Elemente zu verschieben.

Zusammensetzbares Element ziehen

In diesem Beispiel wird der häufigste Anwendungsfall für draggable2D gezeigt: Ein Nutzer kann ein UI-Element aufnehmen und an einer beliebigen Stelle in einem übergeordneten Container neu positionieren.

@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)
        }
    }
}

Abbildung 6 Ein kleines lila Kästchen, das auf einem grauen Hintergrund neu positioniert wird. Dies zeigt das direkte 2D-Ziehen, bei dem sich das Element nicht mehr bewegt, sobald der Finger des Nutzers angehoben wird.

Das obige Code-Snippet enthält Folgendes:

  • Die Position des Kästchens wird mit einem offset-Status verfolgt.
  • Der Modifikator offset wird verwendet, um die Position der Komponente basierend auf den Zieh-Deltas zu verschieben.
  • Da es keine Unterstützung für Flinging gibt, hört sich das Kästchen auf zu bewegen, sobald der Nutzer den Finger anhebt.

Untergeordnetes zusammensetzbares Element basierend auf dem Ziehbereich des übergeordneten Elements ziehen

In diesem Beispiel wird gezeigt, wie Sie mit draggable2D einen 2D-Eingabebereich erstellen, in dem ein Auswahlknopf auf eine bestimmte Oberfläche beschränkt ist. Im Gegensatz zum Beispiel mit dem verschiebbaren Element, bei dem die Komponente selbst verschoben wird, werden in dieser Implementierung die 2D-Deltas verwendet, um einen untergeordneten zusammensetzbaren „Selektor“ über eine Farbauswahl zu bewegen:

@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)
                    }
                )
        )
    }
}

Abbildung 7. Ein Farbverlauf mit einem weißen kreisförmigen Auswahlknopf, der in jede Richtung gezogen werden kann. Dies zeigt, wie 2D-Deltas auf die Grenzen des Containers beschränkt werden, um die ausgewählten Farbwerte zu aktualisieren.

Das obige Snippet enthält Folgendes:

  • Der Modifikator onSizeChanged wird verwendet, um die tatsächlichen Abmessungen des Farbverlaufscontainers zu erfassen. Der Selektor weiß genau, wo sich die Ränder befinden.
  • In graphicsLayer werden translationX und translationY angepasst, damit der Selektor beim Ziehen zentriert bleibt.