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. Si bien los modificadores 1D estándar scrollable y draggable se limitan a una sola orientación, las variantes 2D registran el movimiento en los ejes X e Y de forma simultánea.

Por ejemplo, el modificador scrollable existente se usa para el desplazamiento y el lanzamiento con un solo sentido, mientras que scrollable2d se usa para el desplazamiento y el lanzamiento en 2D. Esto te permite crear diseños más complejos que se mueven 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 2D.

Figura 1: Desplazamiento panorámico bidireccional en un mapa.

Elige scrollable2D o draggable2D

Elegir la API correcta 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 direcciones tanto horizontales como verticales. Incluye compatibilidad integrada con el gesto de lanzar, por lo que el contenido sigue en movimiento después de un deslizamiento, 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 la función de transmisión.

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

Implementa modificadores en 2D

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

Implementa Modifier.scrollable2D.

Usa este modificador para los contenedores en los que el usuario necesita 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 en X e 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 morado que hace un seguimiento de las compensaciones de coordenadas X e Y actuales y las 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 se desplazó el usuario.
  • 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 actuales de X e Y de ese estado offset, que se actualizan en tiempo real a medida que el usuario arrastra.

Desplaza una ventana gráfica grande

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

@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: Una ventana gráfica de texto con desplazamiento panorámico bidireccional, creada con Modifier.scrollable2D.

El fragmento anterior incluye lo siguiente:

  • El contenedor se establece en un tamaño fijo (600x400dp), mientras que el contenido recibe un tamaño mucho mayor (1200x800dp) para evitar que cambie de tamaño al tamaño de su elemento superior.
  • El modificador clipToBounds() en el contenedor garantiza que cualquier parte del contenido grande que se encuentre fuera de la caja clipToBounds() esté oculta a la vista.600x400
  • A diferencia de los componentes de alto nivel, como LazyColumn, scrollable2D no mueve el contenido automáticamente. En su lugar, debes aplicar el offset rastreado a tu contenido, ya sea con transformaciones de graphicsLayer o con desplazamientos de diseño.
  • Dentro del bloque graphicsLayer, translationX = offset.value.x y translationY = offset.value.y desplazan 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 componente principal unidireccional estándar, como un feed de noticias vertical.

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

  • La expresión lambda para rememberScrollable2DState solo debe devolver el delta consumido, para permitir que la lista principal se haga cargo de forma natural cuando el elemento secundario alcance su límite.
  • Cuando un usuario realiza un lanzamiento diagonal, se comparte la velocidad 2D. Si el elemento secundario alcanza un límite durante la animación, el impulso restante se propaga al elemento principal 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: Una caja 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 principal una vez que el desplazamiento interno en Y de la caja alcanza su límite de 300 píxeles.

En el fragmento anterior, sucede lo siguiente:

  • El componente 2D puede consumir el movimiento del eje X para desplazarse internamente y, al mismo tiempo, enviar el movimiento del eje Y a la lista principal una vez que se alcanzan los límites verticales propios del elemento secundario.
  • En lugar de atrapar al usuario dentro de la superficie 2D, el sistema calcula el delta consumido y pasa el resto hacia arriba en 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.

Usa el modificador draggable2D para mover elementos individuales de la IU.

Arrastra un elemento componible

En este ejemplo, se muestra el caso de uso más común de draggable2D: permitir que un usuario tome un elemento de la IU y lo coloque en cualquier lugar dentro de un contenedor principal.

@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: Una pequeña caja morada que se reposiciona sobre un fondo gris, lo que demuestra el arrastre directo en 2D en el que el elemento deja de moverse en el instante en que se levanta el dedo del usuario.

El fragmento de código anterior incluye lo siguiente:

  • Realiza un seguimiento de la posición de la caja con un estado offset.
  • Usa el modificador offset para cambiar la posición del componente según los deltas de arrastre.
  • Como no hay compatibilidad con el gesto de deslizar, la caja deja de moverse en el instante en que el usuario levanta el dedo.

Arrastrar un elemento componible secundario según el área de arrastre del elemento principal

En este ejemplo, se muestra cómo usar draggable2D para crear un área de entrada 2D en la que un botón selector está restringido dentro de una superficie específica. A diferencia del ejemplo de elemento arrastrable, que mueve el componente en sí, esta implementación usa los deltas 2D para mover un elemento componible secundario "selector" a través de un selector de color:

@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. Un gradiente de color con un selector circular blanco que se puede arrastrar en cualquier dirección, lo que demuestra cómo los deltas 2D se fijan a los límites del contenedor para actualizar los valores de color seleccionados.

El fragmento anterior incluye lo siguiente:

  • Usa el modificador onSizeChanged para capturar las dimensiones reales del contenedor de gradiente. El selector sabe exactamente dónde están los bordes.
  • Dentro de graphicsLayer, ajusta translationX y translationY para asegurarse de que el selector permanezca centrado mientras se arrastra.