Arrastrar elementos componibles

Permitir que los usuarios arrastren físicamente componentes por una pantalla es fundamental para las apps modernas para Android. Ya sea que compiles un control deslizante personalizado, un componente de deslizar para descartar o un lienzo interactivo, Jetpack Compose proporciona un conjunto integral de APIs para controlar fácilmente los eventos de arrastre.

Las APIs de arrastre de Compose abstraen la complejidad de los eventos táctiles sin procesar. En lugar de calcular manualmente las coordenadas táctiles y administrar los ciclos de vida de los gestos con pointerInput, puedes usar modificadores integrados que se vinculan directamente a los sistemas de administración de estado y animación de Compose.

En esta página, se describe cómo y cuándo usar cada API de arrastre.

Elige la API de arrastre correcta

Puedes usar varias APIs para implementar contenido que se pueda arrastrar.

  • Elige componentes estándar de Material (como SwipeToDismissBox o paneles laterales de navegación) cuando: Necesitas el comportamiento predeterminado de deslizar o arrastrar de Material 3 (por ejemplo, borrar un correo electrónico, archivar una tarea o abrir un menú lateral o hojas inferiores). Proporcionan comportamientos integrados sin requerir un estado personalizado ni administración de diseño.
  • Elige Modifier.draggable cuando: Solo necesitas deltas de arrastre sin procesar (por ejemplo, arrastrar un control deslizante personalizado). Para obtener más información, consulta Arrastra a lo largo de un solo eje con draggable.
    • Elige Modifier.draggable2D cuando: Necesitas un componente para que se mueva de forma continua a través de los ejes X y Y de forma simultánea (por ejemplo, desplazar un mapa o crear un botón de acción flotante que el usuario pueda arrastrar a cualquier lugar de la pantalla). Para obtener más información, consulta Arrastra en dos dimensiones con Modifier.draggable2D.
  • Elige Modifier.anchoredDraggable cuando: Estás compilando componentes interactivos personalizados que deben ajustarse entre dos o más posiciones discretas (por ejemplo, una hoja inferior, un panel lateral de navegación o una acción de deslizar para revelar). Para obtener más información, consulta Arrastra componentes entre puntos de anclaje con anchoredDraggable.

Arrastra a lo largo de un solo eje con draggable

El modificador draggable es el punto de entrada de alto nivel para arrastrar gestos en una sola orientación y muestra la distancia de arrastre en píxeles.

Es importante tener en cuenta que este modificador es similar a scrollable, ya que solo detecta el gesto. Debes mantener el estado y representarlo en la pantalla por ejemplo, moviendo el elemento a través del modificador offset:

@Composable
private fun DraggableText() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    Text(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                }
            ),
        text = "Drag me!"
    )
}

Si necesitas controlar todo el gesto de arrastre, considera usar el detector de gestos de arrastre en su lugar, a través del pointerInput modificador.

@Composable
private fun DraggableTextLowLevel() {
    Box(modifier = Modifier.fillMaxSize()) {
        var offsetX by remember { mutableFloatStateOf(0f) }
        var offsetY by remember { mutableFloatStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
        )
    }
}

INSERTAR TEXTO ALTERNATIVO AQUÍ
Figura 1. Un elemento de la IU que se arrastra con un dedo

Arrastra componentes entre puntos de anclaje con anchoredDraggable

El modificador anchoredDraggable te permite arrastrar un componente entre un conjunto de posiciones predefinidas llamadas puntos de anclaje. Cuando el usuario suelta el gesto de arrastre, el componente se asienta en el anclaje más cercano. anchoredDraggable es útil para crear componentes como hojas inferiores personalizadas, controles deslizantes complejos o paneles de cajones.

Superficie de la API

La API de AnchoredDraggable consta de las siguientes partes principales:

  • Anclajes (DraggableAnchors): Es un compilador que define la asignación entre tus estados personalizados (p.ej., una clase enum para Start, Center, End) y sus valores de desplazamiento de píxeles correspondientes.
  • Estado (AnchoredDraggableState<T>): Es un contenedor de estado que administra la posición actual (desplazamiento) del componente, el anclaje en el que se asienta y su progreso de arrastre. Se debe crear con rememberSaveable y AnchoredDraggableState.Saver<T> para conservar su estado en los cambios de configuración
  • Modificador de gestos (Modifier.anchoredDraggable): Es un modificador que detecta gestos de arrastre a lo largo de una Orientation, actualiza el estado con el delta de arrastre y controla el gesto de lanzamiento para animar el componente al anclaje más cercano cuando se suelta.

Ejemplo: Compila un componente SwipeToDismiss personalizado

En este ejemplo, se muestra cómo compilar un componente SwipeToDismiss personalizado (un banner blanco) que el usuario puede deslizar hacia la izquierda para descartar o arrastrar hacia la derecha para ver un efecto de sobredesplazamiento.

  1. Define valores de anclaje. Define un enum que represente las posiciones discretas en las que se puede asentar el contenido que se puede arrastrar:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. Crea instancias y guarda AnchoredDraggableState. Usa rememberSaveable con AnchoredDraggableState.Saver() para conservar la posición actual del componente en la recreación de la actividad y los cambios de configuración (p.ej., rotación de pantalla o cambios de tema):

    val state =
        rememberSaveable(saver = AnchoredDraggableState.Saver()) {
            AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting)
        }

  3. Aplica los modificadores layout, offset y draggable. Para que el componente se pueda arrastrar y garantizar que sus puntos de anclaje se adapten a su tamaño, aplica los siguientes modificadores al contenido que debe moverse visualmente:

    • Modifier.layout: Las posiciones de anclaje suelen depender del tamaño de un componente, que no se conoce durante la composición inicial. Usa el modificador layout para medir el componente, calcular el mapa DraggableAnchors de forma dinámica según su ancho y actualizar el estado con state.updateAnchors().

    • Modifier.anchoredDraggable: Se conecta al estado, envía deltas de arrastre en la orientación especificada y controla el gesto de lanzamiento para animar el componente al anclaje más cercano cuando se suelta. Agrega este modificador al componente que debe responder a los gestos de arrastre y pásale el state y la orientation de arrastre (y, de manera opcional, overscrollEffect).
    • Modifier.offset: Lee el desplazamiento de state.requireOffset() dentro de la lambda de desplazamiento. Leer el desplazamiento dentro de esta lambda garantiza que el valor solo se lea durante la fase de ubicación, lo que evita recomposiciones innecesarias durante el arrastre. Aplica este modificador al contenido que debe moverse visualmente.

    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }

  4. [Opcional] Integra el sobredesplazamiento. La API de overscrollEffect implementa un efecto visual que se muestra cuando se alcanzan los bordes de un contenedor de desplazamiento con un desplazamiento o un lanzamiento. Puedes proporcionar comentarios visuales cuando un usuario arrastra más allá de los límites de anclaje definidos con un efecto de sobredesplazamiento (por lo general, un estiramiento o un brillo).

    1. Envío: Pasa una overscrollEffect instancia a anchoredDraggable modificador. Esto envía deltas de arrastre al estado AnchoredDraggable o al efecto de sobredesplazamiento, según si el gesto está dentro o fuera de los límites de anclaje.
    2. Renderización: Aplica el Modifier.overscroll con la misma OverscrollEffect instancia al componente que debe renderizar visualmente el efecto.

En el siguiente fragmento, se muestra cómo agregar un efecto de estiramiento estándar:

val overscrollEffect = rememberOverscrollEffect()
Box(
    modifier.fillMaxWidth()
) {
    Box(Modifier.fillMaxWidth()) {
        background()
    }
    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }
}

Resultado

El fragmento de código anterior da como resultado un banner blanco, con el texto "¡Arrastra para descartar!", que el usuario puede deslizar hacia la izquierda para descartar (o arrastrar hacia la derecha para ver el efecto de estiramiento de desplazamiento excesivo):

Video 2: Implementación de un componente personalizado de deslizar para descartar

Ejemplo: Crea un panel lateral de navegación personalizado controlado por gestos

En este ejemplo, se muestra cómo compilar un panel de navegación personalizado controlado por gestos. A medida que el usuario arrastra horizontalmente, la pantalla de primer plano (ScreenContents) se desliza hacia la derecha, se reduce de 1.0f a 0.8f y redondea sus esquinas para revelar el panel de navegación subyacente.

  1. Define estados y posiciones de anclaje. En este ejemplo, los anclajes son los estados abiertos y cerrados del panel. Define un enum para los estados Open y Closed, y asígnalos a desplazamientos de píxeles con DraggableAnchors:

    enum class DrawerState {
        Open,
        Closed
    }
    
        val anchors = DraggableAnchors {
            DrawerState.Open at drawerWidth
            DrawerState.Closed at 0f
        }
        val state = remember {
            AnchoredDraggableState(
                initialValue = DrawerState.Closed,
                anchors = anchors,
            )
        }

  2. Anima anchoredDraggableState cuando se hace clic en un elemento. Coloca HomeScreenDrawer en la parte inferior de Box y ScreenContents en la parte superior. El panel permanece inmóvil, mientras que la capa superior se traduce y se escala sobre él.

    Cuando un usuario selecciona un elemento del panel, actualiza el estado de la pantalla activa y descarta automáticamente el panel con state.animateTo(DrawerState.Closed):

    @Composable
    private fun AnchoredDraggableDrawerOnClickSample(
        modifier: Modifier = Modifier,
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        var screenState by remember { mutableStateOf(ScreenState.Home) }
    
        Box(modifier = modifier.fillMaxSize()) {
            HomeScreenDrawer(
                selectedScreen = screenState,
                onScreenSelected = { screen ->
                    screenState = screen
                    // Dismiss drawer when a destination is selected
                    coroutineScope.launch { state.animateTo(DrawerState.Closed) }
                }
            )
    
            // ScreenContents(...)
        }
    }

  3. Usa state.requireOffset() para cambiar varias propiedades visuales. En lugar de solo desplazar el contenido, puedes cambiar varias propiedades con Modifier.graphicsLayer para animar simultáneamente la traducción, la escala y el radio de la esquina según el progreso de arrastre:

    • Usa state.requireOffset() para obtener el desplazamiento de arrastre actual.
    • Usa lerp() (interpolación lineal) para calcular la escala proporcional (1f a 0.8f) y el radio de la esquina (0.dp a 32.dp) en relación con el desplazamiento y el ancho del panel.
    • Modifier.graphicsLayer ejecuta cambios en la fase de dibujo, lo que garantiza animaciones de alto rendimiento sin activar recomposiciones durante el arrastre.

    @Composable
    private fun AnchoredDraggableDrawerGraphicsLayerSample(
        state: AnchoredDraggableState<DrawerState>,
        drawerWidth: Float,
        selectedScreen: ScreenState,
        onDrawerClicked: () -> Unit
    ) {
        ScreenContents(
            selectedScreen = selectedScreen,
            onDrawerClicked = onDrawerClicked,
            modifier = Modifier
                .graphicsLayer {
                    this.translationX = state.requireOffset()
                    val scale = lerp(1f, 0.8f, state.requireOffset() / drawerWidth)
                    this.scaleX = scale
                    this.scaleY = scale
                    val cornerRadius = lerp(0f, 32f, state.requireOffset() / drawerWidth)
                    this.shape = RoundedCornerShape(cornerRadius.dp)
                    this.clip = true
                }
                .anchoredDraggable(state, Orientation.Horizontal)
        )
    }

  4. Activa animaciones programáticas con animateTo. Controla el panel de forma programática (por ejemplo, cuando presionas el ícono de menú o seleccionas un elemento del panel) iniciando state.animateTo() dentro de un alcance de corrutina:

    fun toggleDrawerState(
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        coroutineScope.launch {
            if (state.currentValue == DrawerState.Open) {
                state.animateTo(DrawerState.Closed)
            } else {
                state.animateTo(DrawerState.Open)
            }
        }
    }

Resultado

Video 3: La pantalla de primer plano (Sleep) se arrastra hacia la derecha, lo que muestra el panel de navegación subyacente.

Arrastra en dos dimensiones con Modifier.draggable2D

Usa el modificador draggable2D para mover elementos individuales de la IU a través de los ejes X y Y de forma simultánea.

Arrastra un elemento componible

En este ejemplo, se muestra el caso de uso más común para draggable2D, que permite que un usuario tome un elemento de la IU y lo cambie de posición en cualquier lugar dentro de un contenedor superior.

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

Video 4: Una pequeña caja morada que se cambia de posición 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 lanzamiento, la caja deja de moverse en el instante en que el usuario levanta el dedo.

Arrastra un elemento secundario componible según el área de arrastre del elemento superior

En este ejemplo, se muestra cómo usar draggable2D para crear un área de entrada 2D en la que una perilla de selector está restringida dentro de una superficie específica. A diferencia del ejemplo de elemento que se puede arrastrar, que mueve el componente en sí, esta implementación usa los deltas 2D para mover un "selector" componible secundario 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)
                    }
                )
        )
    }
}

Video 5: Un gradiente de color con una perilla de selector circular blanca que se puede arrastrar en cualquier dirección, lo que demuestra cómo se sujetan los deltas 2D 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.