Arrastrar elementos componibles

Permitir que los usuarios arrastren componentes físicamente por la pantalla es fundamental para las apps para Android modernas. Ya sea que estés creando un control deslizante personalizado, un componente para deslizar para descartar o un lienzo interactivo, Jetpack Compose proporciona un conjunto completo 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 vinculen directamente con los sistemas de administración de estados 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

Existen varias APIs que puedes usar para implementar contenido arrastrable.

  • Elige componentes estándar de Material (como SwipeToDismissBox o paneles laterales de navegación) en los siguientes casos: Necesitas el comportamiento predeterminado de deslizamiento o arrastre de Material 3 (por ejemplo, borrar un correo electrónico, archivar una tarea o abrir un menú lateral o una hoja inferior). Proporcionan comportamientos integrados sin necesidad de administrar el estado o el diseño personalizados.
  • Elige Modifier.draggable cuando solo necesites diferencias de arrastre sin procesar (por ejemplo, arrastrar un control deslizante personalizado). Para obtener más información, consulta Cómo arrastrar a lo largo de un solo eje con draggable.
  • Cuándo elegir Modifier.anchoredDraggable: Cuando creas 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 Cómo arrastrar 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, además, informa 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 con el modificador pointerInput.

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

INSERT ALT TEXT HERE
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 arrastrar, el componente se ubica en el punto de anclaje más cercano. anchoredDraggable es útil para crear componentes como hojas inferiores personalizadas, controles deslizantes complejos o paneles laterales.

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 de enumeración 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 del componente (desplazamiento), el ancla en la que se encuentra y su progreso de arrastre. Se debe crear con rememberSaveable y AnchoredDraggableState.Saver<T> para preservar su estado durante los cambios de configuración.
  • Modificador de gestos (Modifier.anchoredDraggable): Es un modificador que detecta gestos de arrastre a lo largo de un Orientation, actualiza el estado con el delta de arrastre y controla el gesto de deslizamiento rápido para animar el componente al ancla más cercana cuando se suelta.

Ejemplo: Crea 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 descartarlo o arrastrarlo hacia la derecha para ver un efecto de estiramiento de sobredesplazamiento.

  1. Define los valores de anclaje. Define un enum que represente las posiciones discretas en las que se puede colocar el contenido arrastrable:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. Crea una instancia 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 la 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 debería 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 diferencias de arrastre en la orientación especificada y controla el gesto de deslizamiento para animar el componente al anclaje más cercano cuando se suelta. Agrega este modificador al componente que debería responder a los gestos de arrastre y pásale el state y el 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 posicionamiento, lo que evita recomposiciones innecesarias durante el arrastre. Aplica este modificador al contenido que debería 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. Integra el sobredesplazamiento (opcional). La API de overscrollEffect implementa un efecto visual que se muestra cuando se llega a los bordes de un contenedor de desplazamiento con un desplazamiento o un deslizamiento rápido. 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 instancia de overscrollEffect al modificador anchoredDraggable. Esto envía diferencias 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 instancia de OverscrollEffect al componente que debería 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 genera un banner blanco con el texto "Drag me to dismiss!", que el usuario puede deslizar hacia la izquierda para descartarlo (o arrastrarlo hacia la derecha para ver el efecto de estiramiento de sobredesplazamiento):

Video 2. Es una implementación de componente personalizada para deslizar y descartar.

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

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

  1. Define los estados y las posiciones de fijación. En este ejemplo, los puntos de anclaje son los estados abierto y cerrado del panel lateral. Define un enum para los estados Open y Closed, y asígnalos a compensaciones 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 lateral permanece fijo, mientras que la capa superior se traslada y se escala sobre él.

    Cuando un usuario selecciona un elemento del panel lateral, actualiza el estado de la pantalla activa y descarta automáticamente el panel lateral 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 de forma simultánea la traslación, la escala y el radio de las esquinas según el progreso del arrastre:

    • Usa state.requireOffset() para obtener el desplazamiento de arrastre actual.
    • Usa lerp() (interpolación lineal) para calcular la escala proporcional (de 1f a 0.8f) y el radio de la esquina (de 0.dp a 32.dp) en relación con el desplazamiento y el ancho del panel lateral.
    • 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 lateral de manera programática (por ejemplo, cuando se presiona el ícono de menú o se selecciona un elemento del panel lateral) 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 en primer plano (Sleep) se arrastra hacia la derecha, lo que muestra el panel lateral de navegación subyacente.

Arrastra en dos dimensiones con Modifier.draggable2D

Usa el modificador draggable2D para mover elementos individuales de la IU por los ejes X e Y de forma simultánea.

Arrastra un elemento componible

En este ejemplo, se muestra el caso de uso más común de draggable2D: permitir que un usuario elija un elemento de la IU y lo vuelva a colocar 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 de color morado 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 deslizamiento rápido, el cuadro deja de moverse en el momento en que el usuario levanta el dedo.

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

En este ejemplo, se muestra cómo usar draggable2D para crear un área de entrada en 2D en la que un selector se limita a una superficie específica. A diferencia del ejemplo del elemento arrastrable, que mueve el componente en sí, esta implementación usa los deltas 2D para mover un "selector" de un elemento componible secundario en 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 selectora circular blanca que se puede arrastrar en cualquier dirección, lo que demuestra cómo se restringen 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, se ajustan translationX y translationY para garantizar que el selector permanezca centrado mientras se arrastra.