Listes avec Compose pour Wear OS


Grâce aux listes, les utilisateurs peuvent faire leur choix parmi un ensemble d'éléments sur des appareils Wear OS.

De nombreux appareils Wear OS utilisent des écrans ronds, ce qui rend plus difficile la visibilité des éléments de liste qui apparaissent en haut et en bas de l'écran. C'est la raison pour laquelle Compose pour Wear OS inclut une version de la classe LazyColumn appelée TransformingLazyColumn, qui prend en charge les animations de mise à l'échelle et de morphing. Lorsque les éléments se déplacent vers les bords, ils deviennent plus petits et s'estompent.

Pour appliquer les effets de scaling et de défilement recommandés :

  1. Utilisez Modifier.transformedHeight pour permettre à Compose de calculer la variation de hauteur lorsque l'élément défile sur l'écran.
  2. Utilisez transformation = SurfaceTransformation(transformationSpec) pour appliquer les effets visuels, y compris la réduction du contenu de l'élément.
  3. Utilisez un TransformationSpec personnalisé pour les composants qui ne prennent pas transformation comme paramètre, comme Text.

L'animation suivante montre comment un élément de liste est mis à l'échelle et change de forme lorsqu'il s'approche du haut et du bas de l'écran :

L'extrait de code suivant montre comment créer une liste à l'aide de la mise en page TransformingLazyColumn pour créer du contenu qui s'affiche correctement sur différentes tailles d'écran Wear OS.

L'extrait montre également l'utilisation du modificateur minimumVerticalContentPadding, que vous devez définir sur les éléments de la liste pour appliquer la marge intérieure correcte en haut et en bas de la liste.

Pour afficher l'indicateur de défilement, partagez columnState entre ScreenScaffold et 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,
                )
            }
        }
    }
}

Ajouter un effet d'ancrage et de glissement

L'accrochage garantit que lorsqu'un utilisateur termine un geste de défilement ou de balayage, la liste s'arrête avec un élément positionné précisément à un point spécifique, généralement au centre de l'écran. Sur les écrans ronds, où les éléments sont mis à l'échelle et se transforment lorsqu'ils s'éloignent du centre, l'accrochage est particulièrement utile pour s'assurer que l'élément le plus pertinent reste entièrement visible et lisible dans la zone de visionnage optimale.

Pour ajouter un comportement de type "snap-and-fling", définissez le paramètre flingBehavior sur TransformingLazyColumnDefaults.snapFlingBehavior(columnState). Définissez rotaryScrollableBehavior sur la même valeur, en utilisant RotaryScrollableDefaults.snapBehavior(columnState) pour une expérience cohérente lorsque vous utilisez la couronne ou la lunette physique.

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

Inverser la mise en page

Par défaut, une liste déroulante est ancrée à son bord supérieur. Si un utilisateur a fait défiler une liste standard jusqu'en bas et qu'un nouvel élément est ajouté à la fin, la liste conserve la vue de l'utilisateur sur l'élément actuel. Par exemple, si l'utilisateur consulte l'élément 10 en bas de l'écran et que l'élément 11 est ajouté, la vue reste axée sur l'élément 10, et l'élément 11 apparaît hors écran sous la vue actuelle.

Pour les cas d'utilisation tels que les applications de messagerie ou les journaux en direct, ce comportement n'est généralement pas souhaité. Lorsque de nouveaux éléments arrivent, les utilisateurs souhaitent généralement voir immédiatement les derniers contenus s'ils sont déjà en bas de la liste. Si de nombreux éléments arrivent en même temps, la liste doit passer directement au dernier élément en bas (ce qui signifie que certains éléments intermédiaires peuvent ne pas s'afficher du tout, sauf si l'utilisateur fait défiler la liste vers le haut).

Pour prendre en charge ces cas d'utilisation, TransformingLazyColumn vous permet d'inverser la mise en page en définissant reverseLayout = true. L'ancrage de la liste passe du bord supérieur au bord inférieur.

Pour plus de commodité, la définition de reverseLayout = true inverse également l'ordre visuel des éléments et le sens des gestes de défilement :

  • Les éléments sont composés de bas en haut, ce qui signifie que l'index 0 apparaît en bas de l'écran.
  • Faites défiler l'écran vers le haut pour afficher les éléments avec des index plus élevés.

Pour ajouter un comportement d'accrochage et de balayage avec une mise en page inversée, vous pouvez combiner flingBehavior et rotaryScrollableBehavior, comme indiqué dans l'extrait suivant :

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

Les images suivantes montrent la différence entre une liste normale et une liste inversée :

Un TransformingLazyColumn avec une mise en page normale, affichant l'élément 1 en haut et les éléments dans l'ordre croissant.
Figure 1. Mise en page de liste standard où le contenu remplit l'écran de haut en bas.
Un TransformingLazyColumn avec une mise en page inversée, montrant l'élément 1 en bas et les éléments dans l'ordre décroissant vers le haut.
Figure 2. Mise en page de liste inversée où le contenu se remplit de bas en haut.

Boutons Edge dans les listes

Pour Material 3, vous pouvez ajouter un EdgeButton, qui est un bouton bord à bord en bas des listes. Toutefois, veillez à ne pas l'ajouter en tant qu'élément dans TransformingLazyColumn, mais à utiliser plutôt le slot edgeButton dans ScreenScaffold.

L'utilisation du slot edgeButton permet de s'assurer que le bouton est correctement positionné en bas de l'écran et qu'il se comporte de manière appropriée lorsque la liste est défilée.

L'extrait de code suivant montre comment implémenter 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,
    ) {
        // ...
        // ...
    }
}

Balayer pour afficher dans les listes

Le composant SwipeToReveal vous permet d'accéder aux actions d'un élément de liste, telles que Card ou Chip, en balayant l'écran. Balayer l'écran révèle généralement un ou deux boutons d'action (comme "Supprimer" ou "Plus") sur le côté.

Lorsque vous utilisez SwipeToReveal dans un TransformingLazyColumn, suivez ces consignes :

  • Réinitialiser au défilement : lorsque l'utilisateur fait défiler la liste, réinitialisez tous les éléments ouverts par balayage à leur état couvert.
  • Hauteurs cohérentes : définissez la hauteur des boutons d'action pour qu'elle corresponde à celle de l'élément intérieur balayé (qu'il s'agisse d'un Button ou d'un Card) afin d'assurer une apparence cohérente.
  • Transformer le conteneur : appliquez le modificateur transformedHeight et transformationSpec au composant SwipeToReveal lui-même.
  • Ne doublez pas la transformation : n'appliquez pas les modificateurs transformedHeight ou transformation à l'élément balayé intérieur (la carte ou le bouton à l'intérieur du conteneur SwipeToReveal).

Composables personnalisés dans les listes

Lorsque vous créez des composants de surface personnalisés pour un TransformingLazyColumn, suivez ces bonnes pratiques pour que votre composable évolue, s'estompe et se transforme en douceur près des bords de l'écran :

  • Exposer SurfaceTransformation : accepter un paramètre SurfaceTransformation facultatif (par défaut, null), correspondant aux composants Material 3 de Wear Compose standards tels que Card et Button. Cela permet aux appelants d'un TransformingLazyColumn de SurfaceTransformation(transformationSpec) tout en permettant au composant de fonctionner normalement en dehors d'une liste.
  • Appliquez Modifier.transformedHeight en premier dans le code appelant : lorsque vous placez votre composable personnalisé dans un TransformingLazyColumn, transmettez Modifier.transformedHeight(this, transformationSpec) en tant que premier modificateur dans la chaîne de modificateurs du code appelant. Alors que SurfaceTransformation applique les effets visuels de mise à l'échelle et de fondu, transformedHeight est essentiel pour indiquer à la mise en page de la liste de recalculer la hauteur de l'élément à mesure qu'il rétrécit.
  • Appliquez les calques de transformation, l'appelant modifier et le peintre dans l'ordre :
    1. Couche de transformation du conteneur : démarrez la chaîne de modificateurs du conteneur racine avec Modifier.graphicsLayer et applyContainerTransformation(), afin que l'arrière-plan et le contenu soient dessinés dans l'espace de coordonnées mis à l'échelle et incliné.
    2. Appelant modifier : appliquez ensuite le paramètre modifier transmis par l'appelant (qui inclut Modifier.transformedHeight), avant toute taille ou marge intérieure.
    3. Découper la forme en l'absence de transformation : si transformation est null, appliquez Modifier.clip(shape) avant de dessiner l'arrière-plan. Le peintre renvoyé par createContainerPainter() se découpe sur la forme, contrairement à un peintre simple. Sans cela, l'arrière-plan est dessiné avec des angles carrés en dehors d'une liste.
    4. Peintre d'arrière-plan morphing : dessinez l'arrière-plan à l'intérieur du calque de conteneur à l'aide de Modifier.drawBehind et d'un peintre créé à partir de createContainerPainter().
    5. Calque de transformation du contenu : appliquez un deuxième Modifier.graphicsLayer avec applyContentTransformation() et découpez-le sur la forme du conteneur afin que le contenu intérieur s'estompe plus tôt à mesure qu'il s'approche de la lunette.

L'extrait suivant montre comment implémenter un composable BoardingPassCard personnalisé qui applique ces transformations dans l'ordre :

@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
    }
}
Figure 3 : Composable de carte d'embarquement personnalisée qui se transforme lors du défilement d'une liste.

Vous pouvez ensuite utiliser BoardingPassCard dans un TransformingLazyColumn en transmettant Modifier.transformedHeight comme premier modificateur, ainsi que 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)
                )
            }
        }
    }
}