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.
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()}", // ... ) } } } }
El fragmento anterior hace lo siguiente:
- Usa
offsetcomo 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ódigooffset.value += deltaactualiza el estado manual con la nueva posición. - Los componentes
Textmuestran los valores actuales de X e Y de ese estadooffset, 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 ) } }
Modifier.scrollable2D.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 cajaclipToBounds()esté oculta a la vista.600x400 - A diferencia de los componentes de alto nivel, como
LazyColumn,scrollable2Dno mueve el contenido automáticamente. En su lugar, debes aplicar eloffsetrastreado a tu contenido, ya sea con transformaciones degraphicsLayero con desplazamientos de diseño. - Dentro del bloque
graphicsLayer,translationX = offset.value.xytranslationY = offset.value.ydesplazan 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
rememberScrollable2DStatesolo 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) ) } }
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) } } }
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
offsetpara 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) } ) ) } }
El fragmento anterior incluye lo siguiente:
- Usa el modificador
onSizeChangedpara capturar las dimensiones reales del contenedor de gradiente. El selector sabe exactamente dónde están los bordes. - Dentro de
graphicsLayer, ajustatranslationXytranslationYpara asegurarse de que el selector permanezca centrado mientras se arrastra.