Desplazamiento bidimensional: scrollable2D, draggable2D

En Jetpack Compose, scrollable2D y draggable2D son modificadores de bajo nivel diseñados para controlar la entrada del puntero en dos dimensiones. Mientras que los modificadores 1D estándar scrollable y draggable se restringen a una sola orientación, las variantes 2D realizan un seguimiento del movimiento en los ejes X e Y de forma simultánea.

Por ejemplo, el modificador scrollable existente se usa para el desplazamiento y el deslizamiento de una sola orientación, mientras que scrollable2d se usa para el desplazamiento y el deslizamiento en 2D. Esto te permite crear diseños más complejos que se muevan en todas las direcciones, como hojas de cálculo o visores de imágenes. El modificador scrollable2d también admite el desplazamiento anidado en situaciones en 2D.

Figura 1. Un desplazamiento bidireccional en un mapa

Elige scrollable2D o draggable2D.

La elección de la API adecuada depende de los elementos de la IU que quieras mover y del comportamiento físico preferido para estos elementos.

Modifier.scrollable2D: Usa este modificador en un contenedor para mover el contenido dentro de él. Por ejemplo, úsalo con mapas, hojas de cálculo o visores de fotos, en los que el contenido del contenedor debe desplazarse en dirección horizontal y vertical. Incluye compatibilidad integrada con el deslizamiento rápido, por lo que el contenido sigue moviéndose después de deslizar el dedo, y se coordina con otros componentes de desplazamiento de la página.

Modifier.draggable2D: Usa este modificador para mover un componente. Es un modificador ligero, por lo que el movimiento se detiene exactamente cuando se detiene el dedo del usuario. No incluye compatibilidad con el lanzamiento.

Si quieres que un componente se pueda arrastrar, pero no necesitas compatibilidad con el desplazamiento rápido o el desplazamiento anidado, usa draggable2D.

Implementa modificadores 2D

En las siguientes secciones, se proporcionan ejemplos para mostrar cómo usar los modificadores 2D.

Implementa Modifier.scrollable2D.

Usa este modificador para contenedores en los que el usuario necesite mover contenido en todas las direcciones.

Captura datos de movimiento en 2D

En este ejemplo, se muestra cómo capturar datos de movimiento 2D sin procesar y mostrar el desplazamiento 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 cuadro de color púrpura que hace un seguimiento de los desplazamientos de las coordenadas X e Y actuales y los muestra a medida que el usuario arrastra el puntero por su superficie.

El fragmento anterior hace lo siguiente:

  • Usa offset como un estado que contiene la distancia total que el usuario se desplazó.
  • Dentro de rememberScrollable2DState, se define una función lambda para controlar cada delta que genera el dedo del usuario. El código offset.value += delta actualiza el estado manual con la nueva posición.
  • Los componentes Text muestran los valores X e Y actuales de ese estado offset, que se actualizan en tiempo real a medida que el usuario arrastra el elemento.

Desplázate por un viewport grande

En este ejemplo, se muestra cómo usar datos desplazables en 2D capturados y aplicar un translationX y un translationY al contenido que es más grande que su contenedor superior:

@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: Un viewport de imagen con desplazamiento bidireccional, creado con Modifier.scrollable2D.
Figura 4: Es un viewport de texto con desplazamiento bidireccional, creado con Modifier.scrollable2D.

El fragmento anterior incluye lo siguiente:

  • El contenedor se establece en un tamaño fijo (600x400dp), mientras que el contenido tiene un tamaño mucho mayor (1200x800dp) para evitar que se cambie el tamaño al de su elemento superior.
  • El modificador clipToBounds() en el contenedor garantiza que cualquier parte del contenido grande que se encuentre fuera del cuadro 600x400 se oculte de la vista.
  • A diferencia de los componentes de alto nivel, como LazyColumn, scrollable2D no mueve el contenido automáticamente. En su lugar, debes aplicar el offset con seguimiento a tu contenido, ya sea con transformaciones de graphicsLayer o compensaciones de diseño.
  • Dentro del bloque graphicsLayer, translationX = offset.value.x y translationY = offset.value.y cambian la posición de dibujo de la imagen o el texto según el movimiento del dedo, lo que crea el efecto visual de desplazamiento.

Implementa el desplazamiento anidado con scrollable2D

En este ejemplo, se muestra cómo se puede integrar un componente bidireccional en un elemento superior unidimensional estándar, como un feed de noticias vertical.

Ten en cuenta los siguientes puntos cuando implementes el desplazamiento anidado:

  • La lambda para rememberScrollable2DState solo debe mostrar el delta consumido para permitir que la lista superior tome el control de forma natural cuando el elemento secundario alcance su límite.
  • Cuando un usuario realiza un deslizamiento diagonal, se comparte la velocidad en 2D. Si el elemento secundario alcanza un límite durante la animación, el impulso restante se propaga al elemento superior para continuar el desplazamiento de forma natural.

@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: Un cuadro de color púrpura dentro de una lista de desplazamiento vertical que permite el movimiento interno en 2D, pero pasa el control de desplazamiento vertical a la lista superior una vez que el desplazamiento en Y interno del cuadro alcanza su límite de 300 píxeles.

En el fragmento anterior, ocurre lo siguiente:

  • El componente 2D puede consumir el movimiento del eje X para desplazarse de forma interna y, al mismo tiempo, enviar el movimiento del eje Y a la lista superior una vez que se alcancen los límites verticales del elemento secundario.
  • En lugar de atrapar al usuario dentro de la superficie 2D, el sistema calcula el delta consumido y pasa el resto a la jerarquía. Esto garantiza que el usuario pueda seguir desplazándose por el resto de la página sin levantar el dedo.

Implementa Modifier.draggable2D.

Para obtener información sobre cómo usar Modifier.draggable2D, consulta Cómo arrastrar en dos dimensiones con Modifier.draggable2D.