Listas con Compose para Wear OS


Las listas les permiten a los usuarios seleccionar un elemento de un conjunto de opciones en dispositivos Wear OS.

Muchos dispositivos Wear OS usan pantallas redondas, lo que dificulta la visualización de los elementos de la lista que aparecen cerca de la parte inferior y superior de la pantalla. Por este motivo, Compose para Wear OS incluye una versión de la clase LazyColumn llamada TransformingLazyColumn, que admite animaciones de escalamiento y transformación. Cuando los elementos se mueven hacia los bordes, se hacen más pequeños y se atenúan.

Para aplicar los efectos de desplazamiento y escalamiento recomendados, sigue estos pasos:

  1. Usa Modifier.transformedHeight para permitir que Compose calcule el cambio de altura a medida que el elemento se desplaza por la pantalla.
  2. Usa transformation = SurfaceTransformation(transformationSpec) para aplicar los efectos visuales, incluido el ajuste del tamaño del contenido del elemento.
  3. Usa un TransformationSpec personalizado para los componentes que no toman transformation como parámetro, como Text.

En la siguiente animación, se muestra cómo un elemento de lista cambia de forma y escala cuando se acerca a la parte superior e inferior de la pantalla:

En el siguiente fragmento de código, se muestra cómo crear una lista con el diseño TransformingLazyColumn para crear contenido que se vea excelente en una variedad de tamaños de pantalla de Wear OS.

El fragmento también demuestra el uso del modificador minimumVerticalContentPadding, que debes establecer en los elementos de la lista para aplicar el padding correcto en la parte superior e inferior de la lista.

Para mostrar el indicador de desplazamiento, comparte el columnState entre el ScreenScaffold y el 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,
                )
            }
        }
    }
}

Cómo agregar un efecto de ajustar y deslizar.

El ajuste garantiza que, cuando un usuario finaliza un gesto de desplazamiento o deslizamiento, la lista se detiene con un elemento posicionado con precisión en un punto específico, por lo general, el centro de la pantalla. En las pantallas redondas, en las que los elementos se ajustan y transforman a medida que se alejan del centro, el ajuste es particularmente útil para garantizar que el elemento más relevante permanezca completamente visible y legible en el área de visualización óptima.

Para agregar un comportamiento de ajuste y desplazamiento, establece el parámetro flingBehavior en TransformingLazyColumnDefaults.snapFlingBehavior(columnState). Configura el rotaryScrollableBehavior para que coincida con el uso de RotaryScrollableDefaults.snapBehavior(columnState) para una experiencia coherente cuando uses la corona o el bisel físicos.

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

Diseño inverso

De forma predeterminada, una lista desplazable se fija a su borde superior. Si un usuario se desplazó hasta el final de una lista estándar y se agrega un elemento nuevo al final, la lista mantiene la vista del usuario en el elemento actual. Por ejemplo, si el usuario está viendo el elemento 10 en la parte inferior de la pantalla y se agrega el elemento 11, la vista sigue enfocada en el elemento 10, y el elemento 11 aparece fuera de la pantalla debajo de la vista actual.

En casos de uso como aplicaciones de mensajería o registros en vivo, este comportamiento no suele ser deseable. Cuando llegan elementos nuevos, los usuarios suelen querer ver el contenido más reciente de inmediato si ya están en la parte inferior de la lista. Si llegan muchos elementos a la vez, la lista debe omitir algunos para mostrar el más reciente en la parte inferior (es decir, es posible que no se muestren algunos elementos intermedios a menos que el usuario se desplace hacia arriba).

Para admitir estos casos de uso, TransformingLazyColumn te permite invertir el diseño estableciendo reverseLayout = true. Esto cambia el ancla de la lista del borde superior al borde inferior.

Para mayor comodidad, establecer reverseLayout = true también invierte el orden visual de los elementos y la dirección de los gestos de desplazamiento:

  • Los elementos se componen de abajo hacia arriba, lo que significa que el índice 0 aparece en la parte inferior de la pantalla.
  • Si te desplazas hacia arriba, se revelan elementos con índices más altos.

Para agregar un comportamiento de ajuste y desplazamiento junto con el diseño inverso, puedes combinar flingBehavior y rotaryScrollableBehavior, como se muestra en el siguiente fragmento:

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

En las siguientes imágenes, se muestra la diferencia entre una lista normal y una invertida:

Un TransformingLazyColumn con diseño normal, que muestra el elemento 1 en la parte superior y los elementos en orden ascendente.
Figura 1: Es un diseño de lista estándar en el que el contenido se completa de arriba hacia abajo.
Un TransformingLazyColumn con diseño inverso, que muestra el elemento 1 en la parte inferior y los elementos en orden descendente hacia la parte superior.
Figura 2: Diseño de lista invertido en el que el contenido se completa de abajo hacia arriba.

Botones de borde en las listas

En Material 3, puedes agregar un EdgeButton, que es un botón que se ajusta al borde en la parte inferior de las listas. Sin embargo, ten cuidado de no agregar esto como un elemento dentro de TransformingLazyColumn, sino de usar la ranura edgeButton en ScreenScaffold.

Usar la ranura edgeButton garantiza que el botón se posicione correctamente en la parte inferior de la pantalla y se comporte de manera adecuada cuando se desplaza la lista.

En el siguiente fragmento de código, se muestra cómo 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 en Listas

El componente SwipeToReveal te permite acceder a acciones para un elemento de lista, como Card o Chip, con un deslizamiento. Por lo general, deslizar el dedo revela uno o dos botones de acción (como “Borrar” o “Más”) desde el costado.

Cuando uses SwipeToReveal dentro de un TransformingLazyColumn, sigue estos lineamientos:

  • Restablecer al desplazarse: Cuando el usuario se desplaza por la lista, restablece los elementos abiertos con un deslizamiento a su estado cubierto.
  • Alturas coherentes: Establece las alturas de los botones de acción para que coincidan con el elemento interno deslizado (ya sea un Button o un Card) y garantizar una apariencia coherente.
  • Transforma el contenedor: Aplica el modificador transformedHeight y transformationSpec al componente SwipeToReveal.
  • No apliques una doble transformación: No apliques modificadores transformedHeight ni transformation al elemento interno deslizado (la tarjeta o el botón dentro del contenedor SwipeToReveal).

Elementos componibles personalizados en listas

Cuando compiles componentes de superficie personalizados para un TransformingLazyColumn, sigue estas prácticas recomendadas para que tu elemento componible se ajuste, se desvanezca y se transforme sin problemas cerca de los bordes de la pantalla:

  • Exponer SurfaceTransformation: Acepta un parámetro SurfaceTransformation opcional (con el valor predeterminado null) que coincide con los componentes estándar de Wear Compose Material 3, como Card y Button. Esto permite que las personas que llaman en un pase de TransformingLazyColumn SurfaceTransformation(transformationSpec) y, al mismo tiempo, permite que el componente funcione normalmente fuera de una lista.
  • Aplica Modifier.transformedHeight primero en el código de llamada: Cuando coloques tu elemento componible personalizado en un TransformingLazyColumn, pasa Modifier.transformedHeight(this, transformationSpec) como el primer modificador en la cadena de modificadores del código de llamada. Si bien SurfaceTransformation aplica los efectos visuales de ajuste y atenuación, transformedHeight es fundamental para indicarle al diseño de la lista que vuelva a calcular la altura del elemento a medida que se reduce.
  • Aplica las capas de transformación, el llamador modifier y el pintor en orden:
    1. Capa de transformación del contenedor: Inicia la cadena de modificadores del contenedor raíz con Modifier.graphicsLayer y applyContainerTransformation(), de modo que el fondo y el contenido se dibujen dentro del espacio de coordenadas inclinado y escalado.
    2. Llamador modifier: Aplica el parámetro modifier que pasa el llamador (que incluye Modifier.transformedHeight) a continuación, antes de cualquier relleno o tamaño interno.
    3. Recorte de forma cuando no hay transformación: Si transformation es null, aplica Modifier.clip(shape) antes de dibujar el fondo. El objeto Painter que devuelve createContainerPainter() se recorta a la forma, pero un objeto Painter simple no lo hace, por lo que, sin esto, el fondo se dibuja con esquinas cuadradas fuera de una lista.
    4. Pintor de fondo de transformación: Dibuja el fondo dentro de la capa del contenedor con Modifier.drawBehind y un pintor creado a partir de createContainerPainter().
    5. Capa de transformación de contenido: Aplica un segundo Modifier.graphicsLayer con applyContentTransformation() y recorta el contenido según la forma del contenedor para que el contenido interno se desvanezca antes a medida que se acerca al bisel.

En el siguiente fragmento, se muestra cómo implementar un elemento BoardingPassCard personalizado componible que aplica estas transformaciones en orden:

@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: Un elemento componible de tarjeta de embarque personalizado que se transforma a medida que se desplaza por una lista.

Luego, puedes usar BoardingPassCard dentro de un TransformingLazyColumn pasando Modifier.transformedHeight como el primer modificador, junto con 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)
                )
            }
        }
    }
}