Rolagem bidimensional: scrollable2D, draggable2D

No Jetpack Compose, scrollable2D e draggable2D são modificadores de baixo nível projetados para processar a entrada do ponteiro em duas dimensões. Enquanto os modificadores 1D padrão scrollable e draggable são restritos a uma única orientação, as variantes 2D rastreiam o movimento nos eixos X e Y simultaneamente.

Por exemplo, o modificador scrollable atual é usado para rolagem e movimento em uma única orientação, enquanto scrollable2d é usado para rolagem e movimento em 2D. Isso permite criar layouts mais complexos que se movem em todas as direções, como planilhas ou visualizadores de imagens. O modificador scrollable2d também oferece suporte à rolagem aninhada em cenários 2D.

Figura 1. Um movimento bidirecional em um mapa.

Escolher scrollable2D ou draggable2D

A escolha da API certa depende dos elementos da interface que você quer mover e do comportamento físico preferido para esses elementos.

Modifier.scrollable2D: use esse modificador em um contêiner para mover o conteúdo dentro dele. Por exemplo, use-o com mapas, planilhas ou visualizadores de fotos, em que o conteúdo do contêiner precisa rolar nas direções horizontal e vertical. Ele inclui suporte integrado para deslizar rapidamente, para que o conteúdo continue se movendo após um deslize, e coordena com outros componentes de rolagem na página.

Modifier.draggable2D: use esse modificador para mover um componente. É um modificador leve, então o movimento para exatamente quando o dedo do usuário para. Ele não inclui suporte para deslizar rapidamente.

Se você quiser tornar um componente arrastável, mas não precisar de suporte para deslizar rapidamente ou rolagem aninhada, use draggable2D.

Implementar modificadores 2D

As seções a seguir fornecem exemplos para mostrar como usar os modificadores 2D.

Implementar Modifier.scrollable2D

Use esse modificador para contêineres em que o usuário precisa mover o conteúdo em todas as direções.

Capturar dados de movimento 2D

Este exemplo mostra como capturar dados de movimento 2D brutos e mostrar o deslocamento X,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()}",
                    // ...
                )
            }
        }
    }
}

Figura 2. Uma caixa roxa que rastreia e mostra os deslocamentos atuais das coordenadas X e Y enquanto o usuário arrasta o ponteiro pela superfície.

O snippet anterior faz o seguinte:

  • Usa offset como um estado que contém a distância total que o usuário rolou.
  • Dentro de rememberScrollable2DState, uma função lambda é definida para processar cada delta gerado pelo dedo do usuário. O código offset.value += delta atualiza o estado manual com a nova posição.
  • Os componentes Text mostram os valores X e Y atuais desse estado offset, que são atualizados em tempo real enquanto o usuário arrasta.

Girar uma janela de visualização grande

Este exemplo mostra como usar dados roláveis 2D capturados e aplicar um translationX e translationY ao conteúdo maior que o contêiner pai:

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

Figura 3. Uma janela de visualização de imagem bidirecional, criada com Modifier.scrollable2D.
Figura 4. Uma janela de visualização de texto panorâmica bidirecional, criada com Modifier.scrollable2D.

O snippet anterior inclui o seguinte:

  • O contêiner é definido como um tamanho fixo (600x400dp), enquanto o conteúdo recebe um tamanho muito maior (1200x800dp) para evitar que ele seja redimensionado para o tamanho do pai.
  • O modificador clipToBounds() no contêiner garante que qualquer parte do conteúdo grande que esteja fora da caixa 600x400 fique oculta.
  • Ao contrário de componentes de alto nível, como LazyColumn, scrollable2D não move o conteúdo automaticamente. Em vez disso, é necessário aplicar o offset rastreado ao conteúdo, usando transformações graphicsLayer ou deslocamentos de layout.
  • Dentro do bloco graphicsLayer, translationX = offset.value.x e translationY = offset.value.y mudam a posição de desenho da imagem ou do texto com base no movimento do dedo, criando o efeito visual de rolagem.

Implementar rolagem aninhada com scrollable2D

Este exemplo demonstra como um componente bidirecional pode ser integrado a um pai unidimensional padrão, como um feed de notícias vertical.

Considere os seguintes pontos ao implementar a rolagem aninhada:

  • A lambda para rememberScrollable2DState precisa retornar apenas o delta consumido, para permitir que a lista pai assuma o controle naturalmente quando o filho atingir o limite.
  • Quando um usuário realiza um movimento diagonal, a velocidade 2D é compartilhada. Se o filho atingir um limite durante a animação, o momento restante será propagado para o pai para continuar a rolagem naturalmente.

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

Figura 5. Uma caixa roxa em uma lista de rolagem vertical que permite o movimento 2D interno, mas passa o controle de rolagem vertical para a lista pai quando o deslocamento Y interno da caixa atinge o limite de 300 pixels.

No snippet anterior:

  • O componente 2D pode consumir o movimento do eixo X para fazer uma panorâmica interna enquanto envia simultaneamente o movimento do eixo Y para a lista pai quando os limites verticais do filho são atingidos.
  • Em vez de prender o usuário na superfície 2D, o sistema calcula o delta consumido e passa o restante para cima na hierarquia. Isso garante que o usuário possa continuar rolando pelo restante da página sem levantar o dedo.

Implementar Modifier.draggable2D

Use o modificador draggable2D para mover elementos individuais da interface.

Arrastar um elemento combinável

Este exemplo mostra o caso de uso mais comum para draggable2D: permitir que um usuário pegue 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)
        }
    }
}

Figura 6. Uma pequena caixa roxa sendo reposicionada em um fundo cinza, demonstrando o arrastar 2D direto em que o elemento para de se mover no instante em que o dedo do usuário é levantado.

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 deslizar rapidamente, a caixa para de se mover no instante em que o usuário levanta o dedo.

Arrastar um 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 componente em si, essa implementação usa os deltas 2D para mover um combinável filho "seletor" 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)
                    }
                )
        )
    }
}

Figura 7. Um gradiente de cores 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 cores 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 durante o arrastar.