Como implementar o recurso Swipe-to-Action usando AnchoredDraggable no Jetpack Compose

Tempo de leitura: 6 minutes

Histórico

Nesta postagem do blog, vamos nos aprofundar em uma nova e empolgante ferramenta: o modificador AnchoredDraggable. Veremos como usar esse modificador para criar algo muito legal: deslizar para a ação. É aquele recurso que você vê com frequência nos aplicativos modernos em que você desliza algo e ele revela ações como excluir, editar, compartilhar etc.

O AnchoredDraggable foi projetado para criar componentes que você pode arrastar entre estados específicos, exatamente como aquelas folhas de fundo modais que você já viu. A melhor parte? Essa API assume a função da API Swipeable do Material, oferecendo uma abordagem mais versátil.

Essa implementação é inspirada no flutter_slidable.

O que implementamos neste blog? Três variantes de swipe-to-action

BehindMotionSwipe
BehindMotionSwipe
ScrollMotionSwipe
ScrollMotionSwipe
DrawerMotionSwipe
DrawerMotionSwipe

 

Adicionar dependência

Vamos primeiro adicionar uma dependência necessária para usar a API arrastável Anchored.

implementation("androidx.compose.foundation:foundation:1.7.0-alpha05")

 

Modificador AnchoredDraggable

Antes de prosseguirmos com a implementação, vamos dar uma olhada rápida no modificador AnchoredDraggable.

AnchoredDraggableState

@ExperimentalFoundationApi
class AnchoredDraggableState<T>(
    initialValue: T,
    internal val positionalThreshold: (totalDistance: Float) -> Float,
    internal val velocityThreshold: () -> Float,
    val animationSpec: AnimationSpec<Float>,
    internal val confirmValueChange: (newValue: T) -> Boolean = { true }
)

O AnchoredDraggableState ajuda a gerenciar e controlar elementos arrastáveis com pontos de ancoragem em seu aplicativo. Veja a seguir o que você precisa saber:

Posição inicial: Você pode definir a posição inicial de um elemento arrastável usando initialValue.

Limites: Permite que você defina dois limites:

  1. positionalThreshold: Determina quando o elemento deve se ajustar a uma nova posição enquanto você o arrasta. É como se você dissesse: “Mova-o até aqui e depois se encaixe na âncora mais próxima”.
  2. velocityThreshold: Define a velocidade na qual o elemento precisa estar se movendo quando você o solta para que ele se encaixe em uma nova posição, mesmo que não tenha atingido o limite posicional.

Animações: Você pode especificar como o elemento deve fazer uma transição suave entre os estados usando o animationSpec.

Controle: Há um retorno de chamada opcional chamado confirmValueChange que lhe dá controle sobre a ocorrência ou não de uma mudança de estado.

Modifier.anchoredDraggable

@ExperimentalFoundationApi
fun <T> Modifier.anchoredDraggable(
    state: AnchoredDraggableState<T>,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    interactionSource: MutableInteractionSource? = null
) = draggable(
    state = state.draggableState,
    orientation = orientation,
    enabled = enabled,
    interactionSource = interactionSource,
    reverseDirection = reverseDirection,
    startDragImmediately = state.isAnimationRunning,
    onDragStopped = { velocity -> launch { state.settle(velocity) } }
)

Parâmetros principais:

  • state: Associa o elemento arrastável a um AnchoredDraggableState, gerenciando sua posição e animação.
  • orientation (orientação): Especifica a orientação do arrasto como horizontal ou vertical.
  • enabled: Determina se o comportamento arrastável está ativo ou não.
  • reverseDirection: Parâmetro opcional para reverter a direção do arrasto.
  • interactionSource: Uma fonte opcional para eventos de interação.

Como funciona:

  1. Quando o usuário inicia um gesto de arrastar, a posição do composable é atualizada de acordo com o delta de arrastar, criando um efeito visualmente responsivo.
  2. Ao liberar o composable, ele é animado suavemente em direção ao ponto de ancoragem predefinido mais próximo.
  3. O AnchoredDraggableState mantém o controle da âncora atual, permitindo que você reaja às alterações de forma adequada.

Agora vamos iniciar a implementação do primeiro e mais simples Swipe-to-action. Para simplificar, demos nomes a todas as três variantes de acordo com seu movimento.

Para manter a implementação simples, não estou mostrando a composição de ações e conteúdo.

Usaremos o DraggableItem composable comum em todos os exemplos.

enum class DragAnchors {
    Start,
    Center,
    End,
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DraggableItem(
    state: AnchoredDraggableState<DragAnchors>,
    content: @Composable BoxScope.() -> Unit,
    startAction: @Composable (BoxScope.() -> Unit)? = {},
    endAction: @Composable (BoxScope.() -> Unit)? = {}
) {

    Box(
        modifier = Modifier
            .padding(16.dp)
            .fillMaxWidth()
            .height(100.dp)
            .clip(RectangleShape)
    ) {

        endAction?.let {
            endAction()
        }

        startAction?.let {
            startAction()
        }
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .align(Alignment.CenterStart)
                .offset {
                    IntOffset(
                        x = -state
                            .requireOffset()
                            .roundToInt(),
                        y = 0,
                    )
                }
                .anchoredDraggable(state, Orientation.Horizontal, reverseDirection = true),
            content = content
        )
    }
}

Parâmetros:

  • state: Esse parâmetro é do tipo AnchoredDraggableState<DragAnchors> e controla como o item é arrastado, ancorado e animado.
  • content: Representa o conteúdo principal do item arrastável.
  • startAction e endAction: Esses parâmetros são opcionais. Define os botões de deslizar para ação que aparecem quando o item é arrastado para a posição inicial ou final.

 

BehindMotionSwipe

Aqui implementaremos uma animação de deslizamento que revela a ação à medida que você arrasta. Primeiro, vamos definir o AnchoredDraggableState

val density = LocalDensity.current
    val defaultActionSize = 80.dp
    val endActionSizePx = with(density) { (defaultActionSize * 2).toPx() }
    val startActionSizePx = with(density) { defaultActionSize.toPx() }

    val state = remember {
        AnchoredDraggableState(
            initialValue = DragAnchors.Center,
            anchors = DraggableAnchors {
                DragAnchors.Start at -startActionSizePx
                DragAnchors.Center at 0f
                DragAnchors.End at endActionSizePx
            },
            positionalThreshold = { distance: Float -> distance * 0.5f },
            velocityThreshold = { with(density) { 100.dp.toPx() } },
            animationSpec = tween(),
        )
    }

DraggableAnchors { ... }: Define os pontos de ancoragem para o elemento arrastável. Ele usa a DSL (linguagem específica de domínio) DraggableAnchors para especificar três pontos de ancoragem:

  • Âncora “Start” a uma distância negativa de startActionSizePx pixels do centro.
  • Âncora “Center” no centro (0 pixels do centro).
  • Âncora “End” (fim) a endActionSizePx pixels do centro.

Agora, só precisamos usar isso para animar o DraggableItem. Veja como

Box(
    modifier = Modifier
        .fillMaxWidth()
        .align(Alignment.CenterStart)
        .offset {
            IntOffset(
                x = -state
                    .requireOffset()
                    .roundToInt(),
                y = 0,
            )
        }
        .anchoredDraggable(state, Orientation.Horizontal, reverseDirection = true),
    content = content
)

E pronto. Muito simples, certo? Veja a seguir o que teremos com a implementação acima

O código-fonte completo do BehindMotionSwipe está disponível no GitHub.

 

ScrollMotionSwipe

Para ter um efeito deslizante à medida que arrastamos, precisamos animar a posição das ações.

Nosso AnchoredDraggableState seria o mesmo da implementação acima.

Vamos ver primeiro a ação inicial, que é a Save Action.

DraggableItem(state = state,
    startAction = {
        Box(
            modifier = Modifier
                .fillMaxHeight()
                .align(Alignment.CenterStart),
        ) {
            SaveAction(
                Modifier
                    .width(defaultActionSize)
                    .fillMaxHeight()
                    .offset {
                        IntOffset(
                            ((-state
                                .requireOffset() - actionSizePx))
                                .roundToInt(), 0
                        )
                    }
            )
        }
    }, endAction = { ... }, content = { ... }

 

((-state.requireOffset() - actionSizePx)).roundToInt(): Esse cálculo determina a coordenada X da SaveAction. Vamos decompô-lo:

  • state.requireOffset(): Isso recupera o deslocamento atual do objeto de estado. O deslocamento representa a distância em que o elemento arrastável foi arrastado.
  • - actionSizePx: Subtrai o actionSizePx do deslocamento. Isso é usado para ajustar a posição do SaveAction em relação à posição do elemento arrastável.

Agora vamos ver a animação de deslizamento das ações finais.

endAction = {
    Row(
        modifier = Modifier
            .fillMaxHeight()
            .align(Alignment.CenterEnd)
            .offset {
                IntOffset(
                    (-state
                        .requireOffset() + endActionSizePx)
                        .roundToInt(), 0
                )
            }
        )
    {
        // Action composables
    }
}

Aqui, o cálculo de -state.requireOffset() + endActionSizePx determina a coordenada X da Row e + endActionSizePx adiciona endActionSizePx ao deslocamento. Esse ajuste é usado para posicionar a “ação final” em relação à posição do elemento arrastável.

Agora terminamos a configuração. Vamos ver o resultado.

ScrollMotionSwipe
ScrollMotionSwipe

 

DrawerMotionSwipe

Esse efeito de deslizamento é bastante semelhante ao anterior, com apenas um pequeno ajuste na posição horizontal de uma ação.

Nesse caso, em vez de alterar o deslocamento do composable pai (como feito na implementação anterior, em que era uma linha), ajustaremos o deslocamento de cada ação individual.

Vejamos primeiro a EditAction.

EditAction(
    Modifier
        .width(defaultActionSize)
        .fillMaxHeight()
        .offset {
            IntOffset(
                ((-state
                    .requireOffset()) + actionSizePx)
                    .roundToInt(), 0
            )
        }
)

Nada de especial, apenas alterando o deslocamento horizontal da ação com base no estado arrastável.

DeleteAction(
    Modifier
        .width(defaultActionSize)
        .fillMaxHeight()
        .offset {
            IntOffset(
                ((-state
                    .requireOffset() * 0.5f) + actionSizePx)
                    .roundToInt(), 0
            )
        }
)

Assim como a “EditAction”, a “DeleteAction” também é posicionada com base no deslocamento do estado atual. No entanto, há uma diferença distinta na forma como ele é calculado. A “DeleteAction” foi intencionalmente projetada para reduzir seu movimento horizontal pela metade em comparação com a “EditAction”, fazendo com que pareça uma gaveta que desliza suavemente para fora. E aqui está o resultado.

DrawerMotionSwipe
DrawerMotionSwipe

 

Conclusão

Ao implementar a funcionalidade de deslizar para revelar usando AnchoredDraggable, mostramos como a composibilidade e a abordagem declarativa do Compose podem simplificar interações complexas. Ao experimentar essa técnica, você encontrará inúmeras possibilidades de aprimorar as experiências do usuário.

Boa programação!

O código-fonte está disponível no GitHub.