Scorrimento bidimensionale: scrollable2D, draggable2D

In Jetpack Compose, scrollable2D e draggable2D sono modificatori di basso livello progettati per gestire l'input del puntatore in due dimensioni. Mentre i modificatori 1D standard scrollable e draggable sono limitati a un solo orientamento, le varianti 2D monitorano il movimento su entrambi gli assi X e Y contemporaneamente.

Ad esempio, il modificatore scrollable esistente viene utilizzato per lo scorrimento e lo scorrimento rapido a orientamento singolo, mentre scrollable2d viene utilizzato per lo scorrimento e lo scorrimento rapido in 2D. In questo modo puoi creare layout più complessi che si muovono in tutte le direzioni, come fogli di lavoro o visualizzatori di immagini. Il modificatore scrollable2d supporta anche lo scorrimento nidificato negli scenari 2D.

Figura 1. Panoramica bidirezionale su una mappa.

Scegli scrollable2D o draggable2D

La scelta dell'API giusta dipende dagli elementi dell'interfaccia utente che vuoi spostare e dal comportamento fisico preferito per questi elementi.

Modifier.scrollable2D: utilizza questo modificatore su un contenitore per spostare i contenuti al suo interno. Ad esempio, utilizzalo con mappe, fogli di lavoro o visualizzatori di foto, dove i contenuti del contenitore devono scorrere sia in orizzontale che in verticale. Include il supporto integrato per lo scorrimento veloce, in modo che i contenuti continuino a muoversi dopo uno scorrimento e si coordina con gli altri componenti di scorrimento della pagina.

Modifier.draggable2D: utilizza questo modificatore per spostare un componente. È un modificatore leggero, quindi il movimento si interrompe esattamente quando il dito dell'utente si ferma. Non include il supporto di Fling.

Se vuoi rendere trascinabile un componente, ma non hai bisogno del supporto per lo scorrimento rapido o nidificato, utilizza draggable2D.

Implementare i modificatori 2D

Le sezioni seguenti forniscono esempi per mostrare come utilizzare i modificatori 2D.

Implementa Modifier.scrollable2D

Utilizza questo modificatore per i contenitori in cui l'utente deve spostare i contenuti in tutte le direzioni.

Acquisire dati di movimento 2D

Questo esempio mostra come acquisire i dati di movimento 2D non elaborati e visualizzare l'offset 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()}",
                    // ...
                )
            }
        }
    }
}

Figura 2. Un riquadro viola che tiene traccia e mostra gli offset delle coordinate X e Y correnti mentre l'utente trascina il puntatore sulla sua superficie.

Lo snippet precedente esegue le seguenti operazioni:

  • Utilizza offset come stato che contiene la distanza totale percorsa dall'utente.
  • All'interno di rememberScrollable2DState, viene definita una funzione lambda per gestire ogni delta generato dal dito dell'utente. Il codice offset.value += delta aggiorna lo stato manuale con la nuova posizione.
  • I componenti Text mostrano i valori X e Y correnti dello stato offset, che si aggiornano in tempo reale mentre l'utente trascina.

Panoramica di un'area visibile di grandi dimensioni

Questo esempio mostra come utilizzare i dati scorrevoli 2D acquisiti e applicare translationX e translationY ai contenuti più grandi del contenitore principale:

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

Figura 3. Una finestra di visualizzazione dell'immagine con panoramica bidirezionale, creata con Modifier.scrollable2D.
Figura 4. Una finestra di visualizzazione del testo con panning bidirezionale, creata con Modifier.scrollable2D.

Lo snippet precedente include quanto segue:

  • Il contenitore è impostato su una dimensione fissa (600x400dp), mentre ai contenuti viene assegnata una dimensione molto più grande (1200x800dp) per evitare che vengano ridimensionati in base alle dimensioni del contenitore principale.
  • Il modificatore clipToBounds() sul contenitore assicura che qualsiasi parte dei contenuti di grandi dimensioni che si trova al di fuori della casella 600x400 sia nascosta alla visualizzazione.
  • A differenza dei componenti di alto livello come LazyColumn, scrollable2D non sposta automaticamente i contenuti. Devi invece applicare il offset monitorato ai tuoi contenuti utilizzando le trasformazioni graphicsLayer o gli offset del layout.
  • All'interno del blocco graphicsLayer, translationX = offset.value.x e translationY = offset.value.y spostano la posizione di disegno dell'immagine o del testo in base al movimento del dito, creando l'effetto visivo di scorrimento.

Implementa lo scorrimento nidificato con scrollable2D

Questo esempio mostra come un componente bidirezionale può essere integrato in un componente principale unidimensionale standard, come un feed di notizie verticale.

Tieni presente quanto segue durante l'implementazione dello scorrimento nidificato:

  • La lambda per rememberScrollable2DState deve restituire solo il delta consumato, per consentire alla lista principale di subentrare naturalmente quando la lista secondaria raggiunge il limite.
  • Quando un utente esegue uno scorrimento diagonale, viene condivisa la velocità 2D. Se il bambino raggiunge un limite durante l'animazione, l'inerzia rimanente viene propagata al genitore per continuare lo scorrimento in modo naturale.

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

Figura 5. Una casella viola all'interno di un elenco a scorrimento verticale che consente il movimento 2D interno, ma passa il controllo dello scorrimento verticale all'elenco principale una volta che l'offset Y interno della casella raggiunge il limite di 300 pixel.

Nello snippet precedente:

  • Il componente 2D può consumare il movimento sull'asse X per spostarsi internamente, mentre invia simultaneamente il movimento sull'asse Y all'elenco principale una volta raggiunti i limiti verticali del componente secondario.
  • Anziché intrappolare l'utente all'interno della superficie 2D, il sistema calcola il delta consumato e passa il resto alla gerarchia. In questo modo, l'utente può continuare a scorrere il resto della pagina senza sollevare il dito.

Implementa Modifier.draggable2D

Utilizza il modificatore draggable2D per spostare i singoli elementi dell'interfaccia utente.

Trascinare un elemento componibile

Questo esempio mostra il caso d'uso più comune per draggable2D: consentire a un utente di selezionare un elemento UI e riposizionarlo ovunque all'interno di un contenitore principale.

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

Figura 6. Un piccolo riquadro viola viene riposizionato su uno sfondo grigio, mostrando il trascinamento 2D diretto in cui l'elemento smette di muoversi nel momento in cui viene sollevato il dito dell'utente.

Lo snippet di codice precedente include quanto segue:

  • Traccia la posizione della casella utilizzando uno stato offset.
  • Utilizza il modificatore offset per spostare la posizione del componente in base ai delta di trascinamento.
  • Poiché non è supportato lo scorrimento, la casella smette di muoversi non appena l'utente solleva il dito.

Trascina un elemento componibile figlio in base all'area di trascinamento del genitore

Questo esempio mostra come utilizzare draggable2D per creare un'area di input 2D in cui un selettore è vincolato all'interno di una superficie specifica. A differenza dell'esempio di elemento trascinabile, che sposta il componente stesso, questa implementazione utilizza i delta 2D per spostare un componente componibile secondario "selettore" in un selettore colori:

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

Figura 7. Una mappa a gradiente di colore con un selettore circolare bianco che può essere trascinato in qualsiasi direzione, che mostra come i delta 2D vengono clampati ai limiti del contenitore per aggiornare i valori di colore selezionati.

Lo snippet precedente include quanto segue:

  • Utilizza il modificatore onSizeChanged per acquisire le dimensioni effettive del container del gradiente. Il selettore sa esattamente dove si trovano i bordi.
  • All'interno del graphicsLayer, regola translationX e translationY per assicurarsi che il selettore rimanga centrato durante il trascinamento.