Listas com o Compose para Wear OS


Listas permitem que os usuários selecionem um item de um conjunto de opções em dispositivos Wear OS.

Muitos dispositivos Wear OS usam telas redondas, o que dificulta a visualização de itens de lista que aparecem perto das partes de cima e de baixo da tela. Por esse motivo, o Compose para Wear OS inclui uma versão da classe LazyColumn chamada TransformingLazyColumn, que oferece suporte a animações de dimensionamento e transformação. Quando os itens se movem para as bordas, eles ficam menores e desaparecem.

Para aplicar os efeitos de rolagem e escalonamento recomendados:

  1. Use Modifier.transformedHeight para permitir que o Compose calcule a mudança de altura à medida que o item rola pela tela.
  2. Use transformation = SurfaceTransformation(transformationSpec) para aplicar os efeitos visuais, incluindo a redução do conteúdo do item.
  3. Use um TransformationSpec personalizado para componentes que não usam transformation como parâmetro, como Text.

A animação a seguir mostra como um elemento de lista é dimensionado e muda de forma ao se aproximar da parte de cima e de baixo da tela:

O snippet de código a seguir mostra como criar uma lista usando o layout TransformingLazyColumn para criar conteúdo que fica ótimo em vários tamanhos de tela do Wear OS.

O snippet também demonstra o uso do modificador minimumVerticalContentPadding, que deve ser definido nos itens da lista para aplicar o padding correto na parte de cima e de baixo da lista.

Para mostrar o indicador de rolagem, compartilhe o columnState entre o ScreenScaffold e o TransformingLazyColumn:

val columnState = rememberTransformingLazyColumnState()
val transformationSpec = rememberTransformationSpec()
ScreenScaffold(
    scrollState = columnState
) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        contentPadding = contentPadding
    ) {
        item {
            ListHeader(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ListHeaderDefaults.minimumTopListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            ) {
                Text(text = "Header")
            }
        }
        // ... other items
        item {
            Button(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ButtonDefaults.minimumVerticalListContentPadding),
                transformation = SurfaceTransformation(transformationSpec),
                onClick = { /* ... */ },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Build,
                        contentDescription = "build",
                    )
                },
            ) {
                Text(
                    text = "Build",
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
        }
    }
}

Adicionar um efeito de ajuste e deslize rápido

O ajuste garante que, quando um usuário termina um gesto de rolagem ou deslize, a lista se ajuste com um item posicionado precisamente em um ponto específico, geralmente o centro da tela. Em telas redondas, em que os itens são dimensionados e transformados à medida que se afastam do centro, o ajuste é particularmente útil para garantir que o item mais relevante permaneça totalmente visível e legível na área de visualização ideal.

Para adicionar um comportamento de ajuste e movimento rápido, defina o parâmetro flingBehavior como TransformingLazyColumnDefaults.snapFlingBehavior(columnState). Defina o rotaryScrollableBehavior para corresponder, usando RotaryScrollableDefaults.snapBehavior(columnState) para uma experiência consistente ao usar a coroa ou o aro físico.

val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(scrollState = columnState) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        flingBehavior = TransformingLazyColumnDefaults.snapFlingBehavior(columnState),
        rotaryScrollableBehavior = RotaryScrollableDefaults.snapBehavior(columnState)
    ) {
        // ...
        // ...
    }
}

Layout invertido

Por padrão, uma lista rolável é ancorada na borda superior. Se um usuário rolar até a parte de baixo de uma lista padrão e um novo item for adicionado ao final, a lista vai manter a visualização do usuário no item atual. Por exemplo, se o usuário estiver visualizando o item 10 na parte de baixo da tela e o item 11 for adicionado, a visualização vai continuar focada no item 10, e o item 11 vai aparecer fora da tela abaixo da visualização atual.

Para casos de uso como aplicativos de mensagens ou registros ativos, esse comportamento geralmente não é desejado. Quando novos itens chegam, os usuários geralmente querem ver o conteúdo mais recente imediatamente se já estiverem na parte de baixo da lista. Se muitos itens chegarem de uma vez, a lista vai pular para mostrar o item mais recente na parte de baixo. Isso significa que alguns itens intermediários podem não ser exibidos, a menos que o usuário role para cima.

Para oferecer suporte a esses casos de uso, o TransformingLazyColumn permite inverter o layout definindo reverseLayout = true. Isso muda a âncora da lista da borda superior para a inferior.

Para sua conveniência, definir reverseLayout = true também inverte a ordem visual dos itens e a direção dos gestos de rolagem:

  • Os itens são compostos de baixo para cima, ou seja, o índice 0 aparece na parte de baixo da tela.
  • Ao rolar para cima, os itens com índices mais altos aparecem.

Para adicionar um comportamento de ajuste e movimento rápido junto com o layout invertido, combine flingBehavior e rotaryScrollableBehavior, conforme mostrado no snippet a seguir:

val columnState = rememberTransformingLazyColumnState()
val transformationSpec = rememberTransformationSpec()
ScreenScaffold(scrollState = columnState) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        contentPadding = contentPadding,
        reverseLayout = true,
        modifier = Modifier.fillMaxWidth()
    ) {
        items(10) { index ->
            Button(
                label = {
                    Text(
                        text = "Item ${index + 1}"
                    )
                },
                onClick = {},
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ButtonDefaults.minimumVerticalListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            )
        }
        item {
            // With reverseLayout = true, the last item declared appears at the top.
            ListHeader(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ListHeaderDefaults.minimumTopListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            ) {
                Text("Header")
            }
        }
    }
}

As imagens a seguir mostram a diferença entre uma lista normal e uma invertida:

Uma TransformingLazyColumn com layout normal, mostrando o item 1 na parte superior e os itens em ordem crescente.
Figura 1. Um layout de lista padrão em que o conteúdo preenche de cima para baixo.
Uma TransformingLazyColumn com layout invertido, mostrando o item 1 na parte de baixo e os itens em ordem decrescente para cima.
Figura 2. Um layout de lista invertida em que o conteúdo é preenchido de baixo para cima.

Botões de borda em listas

No Material 3, você pode adicionar um EdgeButton, que é um botão que fica na borda da parte de baixo das listas. No entanto, tome cuidado para não adicionar isso como um item no TransformingLazyColumn. Em vez disso, use o slot edgeButton no ScreenScaffold.

Usar o slot edgeButton garante que o botão seja posicionado corretamente na parte de baixo da tela e se comporte de maneira adequada quando a lista for rolada.

O snippet de código a seguir mostra como implementar EdgeButton:

val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(
    scrollState = columnState,
    edgeButton = {
        EdgeButton(
            onClick = { /* TODO */ },
            modifier = Modifier.scrollable(
                columnState,
                orientation = Orientation.Vertical,
                reverseDirection = true,
                // Apply overscroll to the EdgeButton for proper scrolling behavior.
                overscrollEffect = rememberOverscrollEffect(),
            )
        ) {
            Text("More")
        }
    }
) { contentPadding ->
    TransformingLazyColumn(
        contentPadding = contentPadding,
        state = columnState,
    ) {
        // ...
        // ...
    }
}

Deslizar para revelar em listas

O componente SwipeToReveal permite acessar ações de um item de lista, como um Card ou Chip, deslizando. Ao deslizar, geralmente aparecem um ou dois botões de ação (como "Excluir" ou "Mais") na lateral.

Ao usar SwipeToReveal em um TransformingLazyColumn, siga estas diretrizes:

  • Redefinir ao rolar: quando o usuário rola a lista, redefina todos os itens abertos com um deslize para o estado coberto.
  • Alturas consistentes: defina as alturas dos botões de ação para corresponder ao item interno deslizado (seja um Button ou um Card) para garantir uma aparência consistente.
  • Transformar o contêiner: aplique o modificador transformedHeight e transformationSpec ao próprio componente SwipeToReveal.
  • Não faça uma transformação dupla: não aplique modificadores transformedHeight ou transformation ao item interno deslizado (o card ou botão dentro do contêiner SwipeToReveal).

Combináveis personalizados em listas

Ao criar componentes de superfície personalizados para um TransformingLazyColumn, siga estas práticas recomendadas para que seu elemento combinável seja dimensionado, desapareça e se transforme sem problemas perto das bordas da tela:

  • Expor SurfaceTransformation: aceitar um parâmetro SurfaceTransformation opcional (padrão null), correspondente a componentes padrão do Wear Compose Material 3, como Card e Button. Isso permite que os autores de chamadas em um TransformingLazyColumn passem SurfaceTransformation(transformationSpec), enquanto o componente funciona normalmente fora de uma lista.
  • Aplique Modifier.transformedHeight primeiro no código de chamada: ao colocar seu elemento combinável personalizado em um TransformingLazyColumn, transmita Modifier.transformedHeight(this, transformationSpec) como o primeiro modificador na cadeia de modificadores do código de chamada. Enquanto SurfaceTransformation aplica os efeitos visuais de escalonamento e fade, transformedHeight é essencial para informar ao layout da lista que recalcule a altura do item à medida que ele diminui.
  • Aplique camadas de transformação, o chamador modifier e o pintor em ordem:
    1. Camada de transformação de contêiner: inicie a cadeia de modificadores do contêiner raiz com Modifier.graphicsLayer e applyContainerTransformation() para que o plano de fundo e o conteúdo sejam desenhados no espaço de coordenadas dimensionado e inclinado.
    2. Chamador modifier: aplique o parâmetro modifier transmitido pelo chamador (que inclui Modifier.transformedHeight) em seguida, antes de qualquer dimensionamento ou padding interno.
    3. Corte de forma quando não há transformação: se transformation for null, aplique Modifier.clip(shape) antes de desenhar o plano de fundo. O pintor retornado por createContainerPainter() se ajusta à forma, mas um pintor simples não faz isso. Portanto, sem isso, o plano de fundo é desenhado com cantos quadrados fora de uma lista.
    4. Pintor de plano de fundo com transformação: desenhe o plano de fundo dentro da camada de contêiner usando Modifier.drawBehind e um pintor criado com createContainerPainter().
    5. Camada de transformação de conteúdo: aplique um segundo Modifier.graphicsLayer com applyContentTransformation() e corte na forma do contêiner para que o conteúdo interno desapareça mais cedo à medida que se aproxima da moldura.

O snippet a seguir mostra como implementar um elemento combinável BoardingPassCard personalizado que aplica essas transformações em ordem:

@Composable
fun BoardingPassCard(
    flightNumber: String,
    origin: String,
    destination: String,
    gate: String,
    seat: String,
    departureTime: String,
    modifier: Modifier = Modifier,
    transformation: SurfaceTransformation? = null,
    shape: Shape = RoundedCornerShape(18.dp),
    statusBadge: @Composable () -> Unit = {}
) {
    // 1. Create morphing container painter
    val backgroundPainter = ColorPainter(MaterialTheme.colorScheme.surfaceContainer)
    val finalPainter = if (transformation != null) {
        remember(transformation, backgroundPainter, shape) {
            transformation.createContainerPainter(backgroundPainter, shape, border = null)
        }
    } else {
        backgroundPainter
    }

    Column(
        modifier = Modifier
            // 2a. Container layer: Scales, fades, and tilts the whole card surface
            .then(
                if (transformation != null) {
                    Modifier.graphicsLayer {
                        transformation.run { applyContainerTransformation() }
                    }
                } else Modifier
            )
            // 2b. Caller modifier: Includes Modifier.transformedHeight in a list
            .then(modifier)
            .fillMaxWidth()
            // 2c. Shape clip: Only needed without a transformation, because the
            // painter from createContainerPainter clips itself to the shape
            .then(if (transformation == null) Modifier.clip(shape) else Modifier)
            // 2d. Morphing background: Drawn inside the transformed container layer
            .drawBehind {
                with(finalPainter) {
                    draw(size)
                }
            }
            // 2e. Content layer: Fades content earlier and clips children to shape
            .then(
                if (transformation != null) {
                    Modifier.graphicsLayer {
                        this.shape = shape
                        this.clip = true
                        transformation.run { applyContentTransformation() }
                    }
                } else Modifier
            )
            .padding(horizontal = 14.dp, vertical = 10.dp)
    ) {
        // Card content goes here
    }
}
Figura 3. Um cartão de embarque personalizado combinável que se transforma ao rolar uma lista.

Em seguida, use BoardingPassCard em um TransformingLazyColumn transmitindo Modifier.transformedHeight como o primeiro modificador, junto com SurfaceTransformation(transformationSpec):

@Composable
fun BoardingPassListSample(flights: List<FlightInfo>) {
    val listState = rememberTransformingLazyColumnState()
    val transformationSpec = rememberTransformationSpec()


    ScreenScaffold(scrollState = listState) { contentPadding ->
        TransformingLazyColumn(
            state = listState,
            contentPadding = contentPadding,
            modifier = Modifier.fillMaxSize()
        ) {
            items(flights.size) { index ->
                val flight = flights[index]
                BoardingPassCard(
                    flightNumber = flight.number,
                    origin = flight.origin,
                    destination = flight.destination,
                    gate = flight.gate,
                    seat = flight.seat,
                    departureTime = flight.time,
                    modifier = Modifier
                        .transformedHeight(this, transformationSpec)
                        .minimumVerticalContentPadding(
                            CardDefaults.minimumVerticalListContentPadding
                        ),
                    transformation = SurfaceTransformation(transformationSpec)
                )
            }
        }
    }
}