Arrastar elementos combináveis

Permitir que os usuários arrastem componentes fisicamente pela tela é fundamental para apps Android modernos. Seja para criar um controle deslizante personalizado, um componente de deslizar para dispensar ou uma tela interativa, o Jetpack Compose oferece um conjunto abrangente de APIs para processar eventos de arrastar com facilidade.

As APIs de arrastar do Compose eliminam a complexidade dos eventos de toque brutos. Em vez de calcular manualmente as coordenadas de toque e gerenciar os ciclos de vida de gestos com pointerInput, use modificadores integrados que se conectam diretamente aos sistemas de gerenciamento de estado e animação do Compose.

Nesta página, descrevemos como e quando usar cada API de arrastar.

Escolher a API de arrastar correta

Há várias APIs que podem ser usadas para implementar conteúdo arrastável.

  • Escolha componentes padrão do Material Design (como SwipeToDismissBox ou gavetas de navegação) quando: você precisar do comportamento padrão de deslizar ou arrastar do Material 3 (por exemplo, excluir um e-mail, arquivar uma tarefa ou abrir um menu lateral / páginas inferiores). Eles oferecem comportamentos integrados sem exigir gerenciamento de estado ou layout personalizado.
  • Escolha Modifier.draggable quando: você só precisa de deltas de arrastar brutos (por exemplo, arrastar um controle deslizante personalizado). Para mais informações, consulte Arrastar ao longo de um único eixo com o modificador draggable.
  • Escolha Modifier.anchoredDraggable quando: você estiver criando componentes interativos personalizados que precisam se ajustar entre duas ou mais posições discretas (por exemplo, uma página inferior, uma gaveta de navegação ou uma ação de deslizar para revelar). Para mais informações, consulte Arrastar componentes entre pontos de fixação com anchoredDraggable.

Arrastar ao longo de um único eixo com draggable

O modificador draggable é o ponto de entrada de alto nível para gestos de arrastar em uma única orientação e informa a distância da ação de arrastar em pixels.

É importante observar que esse modificador é semelhante a scrollable, porque ele detecta apenas o gesto. Você precisa manter o estado e representá-lo na tela, por exemplo, movendo o elemento usando o 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!"
    )
}

Se você precisar controlar todo o gesto de arrastar, use o detector de gestos de arrastar com o 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
                    }
                }
        )
    }
}

INSIRA O TEXTO ALTERNATIVO AQUI
Figura 1. Elemento da interface sendo arrastado por um dedo

Arrastar componentes entre pontos de fixação com anchoredDraggable

O modificador anchoredDraggable permite arrastar um componente entre um conjunto de posições predefinidas chamadas de pontos de fixação. Quando o usuário solta o gesto de arrastar, o componente se fixa na âncora mais próxima. anchoredDraggable é útil para criar componentes como páginas inferiores personalizadas, controles deslizantes complexos ou painéis de gaveta.

Superfície da API

A API AnchoredDraggable consiste nas seguintes partes principais:

  • Âncoras (DraggableAnchors): um builder que define o mapeamento entre seus estados personalizados (por exemplo, uma classe de tipo enumerado para Start, Center, End) e os valores de deslocamento de pixel correspondentes.
  • Estado (AnchoredDraggableState<T>): um detentor de estado que gerencia a posição atual do componente (deslocamento), a âncora em que ele está fixado e o progresso de arrastar. Ele precisa ser criado usando rememberSaveable e AnchoredDraggableState.Saver<T> para preservar o estado em todas as mudanças de configuração.
  • Modificador de gesto (Modifier.anchoredDraggable): um modificador que detecta gestos de arrastar ao longo de um Orientation, atualiza o estado com o delta de arrastar e processa o gesto de deslizar para animar o componente até a âncora mais próxima ao soltar.

Exemplo: criar um componente SwipeToDismiss personalizado

Este exemplo demonstra como criar um componente SwipeToDismiss personalizado (um banner branco) que o usuário pode deslizar para a esquerda para dispensar ou arrastar para a direita para ver um efeito de rolagem esticada.

  1. Defina valores de âncora. Defina um tipo enumerado que represente as posições discretas em que o conteúdo arrastável pode se fixar:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. Instancie e salve AnchoredDraggableState. Use rememberSaveable com AnchoredDraggableState.Saver() para persistir a posição atual do componente durante a recriação da Activity e as mudanças de configuração (por exemplo, rotação da tela ou mudanças de tema):

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

  3. Aplique os modificadores layout, offset e draggable. Para tornar o componente arrastável e garantir que os pontos de fixação se adaptem ao tamanho, aplique os seguintes modificadores ao conteúdo que precisa se mover visualmente:

    • Modifier.layout: as posições de âncora geralmente dependem do tamanho de um componente, que não é conhecido durante a composição inicial. Use o modificador layout para medir o componente, calcular o mapa DraggableAnchors dinamicamente com base na largura dele e atualizar o estado usando state.updateAnchors().

    • Modifier.anchoredDraggable: conecta-se ao estado, envia deltas de arrasto na orientação especificada e processa o gesto de deslizar para animar o componente até a fixação mais próxima quando solto. Adicione esse modificador ao componente que precisa responder aos gestos de arrastar e transmita a ele o state e o orientation de arrastar (e, opcionalmente, overscrollEffect).
    • Modifier.offset: lê o deslocamento de state.requireOffset() dentro do lambda de deslocamento. A leitura do deslocamento dentro dessa lambda garante que o valor seja lido apenas durante a fase de posicionamento, evitando recomposições desnecessárias durante o arrasto. Aplique esse modificador ao conteúdo que precisa se mover 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] Integrar a rolagem esticada. A API overscrollEffect implementa um efeito visual que aparece quando as bordas de um contêiner de rolagem são alcançadas com uma rolagem ou um deslize. Você pode fornecer feedback visual quando um usuário arrasta além dos limites de fixação definidos com um efeito de rolagem esticada (geralmente um alongamento ou brilho).

    1. Envio: transmita uma instância de overscrollEffect para o modificador anchoredDraggable. Isso envia deltas de arrastar para o estado AnchoredDraggable ou para o efeito de rolagem esticada, dependendo se o gesto está dentro ou fora dos limites da âncora.
    2. Renderização: aplique o Modifier.overscroll com a mesma instância OverscrollEffect ao componente que renderiza visualmente o efeito.

O snippet abaixo mostra como adicionar um efeito de alongamento padrão:

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

O snippet de código anterior resulta em um banner branco com o texto "Arraste-me para dispensar!", que o usuário pode deslizar para a esquerda para dispensar (ou arrastar para a direita para ver o efeito de rolagem esticada):

Vídeo 2. Uma implementação personalizada do componente de deslizar para dispensar.

Exemplo: criar uma gaveta de navegação personalizada orientada por gestos

Este exemplo demonstra como criar um painel de navegação personalizado acionado por gestos. Conforme o usuário arrasta horizontalmente, a tela em primeiro plano (ScreenContents) desliza para a direita, reduz a escala vertical de 1.0f para 0.8f e arredonda os cantos para revelar o painel de navegação abaixo.

  1. Defina estados e posições de âncora. Neste exemplo, as âncoras são os estados aberto e fechado do painel. Defina um tipo enumerado para os estados Open e Closed e mapeie-os para deslocamentos de pixel usando 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ção de anchoredDraggableState ao clicar em um item. Coloque HomeScreenDrawer na parte de baixo do Box e ScreenContents na parte de cima. A gaveta permanece parada, enquanto a camada superior se move e é dimensionada sobre ela.

    Quando um usuário selecionar um item do painel, atualize o estado da tela ativa e dispense o painel automaticamente usando 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. Use state.requireOffset() para mudar várias propriedades visuais. Em vez de apenas deslocar o conteúdo, é possível mudar várias propriedades com Modifier.graphicsLayer para animar simultaneamente a translação, a escala e o raio do canto com base no progresso do arrasto:

    • Use state.requireOffset() para ver o deslocamento de arrasto atual.
    • Use lerp() (interpolação linear) para calcular a escala proporcional (1f a 0.8f) e o raio do canto (0.dp a 32.dp) em relação ao deslocamento e à largura do painel.
    • Modifier.graphicsLayer executa mudanças na fase de desenho, garantindo animações de alta performance sem acionar recomposições durante o arrasto.

    @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. Acione animações programáticas com animateTo. Controle o painel de forma programática (por exemplo, ao tocar no ícone de menu ou selecionar um item do painel) iniciando state.animateTo() em um escopo de corrotina:

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

Resultado

Vídeo 3. A tela em primeiro plano (Sleep) é arrastada para a direita, mostrando a gaveta de navegação abaixo.

Arraste em duas dimensões com Modifier.draggable2D

Use o modificador draggable2D para mover elementos individuais da interface nos eixos X e Y simultaneamente.

Arrastar um elemento combinável

Este exemplo mostra o caso de uso mais comum para draggable2D: permitir que um usuário selecione um elemento da interface e o reposicione em qualquer lugar dentro de um contêiner pai.

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

Vídeo 4. Uma pequena caixa roxa sendo reposicionada em um fundo cinza, demonstrando o arrasto 2D direto em que o elemento para de se mover no instante em que o usuário levanta o dedo.

O snippet de código anterior inclui o seguinte:

  • Rastreia a posição da caixa usando um estado offset.
  • Usa o modificador offset para mudar a posição do componente com base nos deltas de arrastar.
  • Como não há suporte para deslizamento, a caixa para de se mover no instante em que o usuário levanta o dedo.

Arrastar um elemento combinável filho com base na área de arrastar do pai

Este exemplo demonstra como usar draggable2D para criar uma área de entrada 2D em que um botão seletor é restrito a uma superfície específica. Ao contrário do exemplo de elemento arrastável, que move o próprio componente, essa implementação usa os deltas 2D para mover um "seletor" de composição filho em um seletor de cores:

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

Vídeo 5. Um gradiente de cor com um botão seletor circular branco que pode ser arrastado em qualquer direção, demonstrando como os deltas 2D são fixados aos limites do contêiner para atualizar os valores de cor selecionados.

O snippet anterior inclui o seguinte:

  • Ele usa o modificador onSizeChanged para capturar as dimensões reais do contêiner de gradiente. O seletor sabe exatamente onde estão as bordas.
  • Dentro do graphicsLayer, ele ajusta o translationX e o translationY para garantir que o seletor permaneça centralizado ao ser arrastado.