Elenchi con Scrivi per Wear OS


Gli elenchi consentono agli utenti di selezionare un elemento da un insieme di scelte sui dispositivi Wear OS.

Molti dispositivi Wear OS utilizzano schermi rotondi, il che rende più difficile vedere gli elementi dell'elenco che vengono visualizzati nella parte superiore e inferiore dello schermo. Per questo motivo, Compose for Wear OS include una versione della classe LazyColumn chiamata TransformingLazyColumn, che supporta le animazioni di scalabilità e morphing. Quando gli elementi si spostano verso i bordi, diventano più piccoli e sbiadiscono.

Per applicare gli effetti di ridimensionamento e scorrimento consigliati:

  1. Utilizza Modifier.transformedHeight per consentire a Compose di calcolare la variazione di altezza mentre l'elemento scorre sullo schermo.
  2. Utilizza transformation = SurfaceTransformation(transformationSpec) per applicare gli effetti visivi, inclusa la riduzione delle dimensioni dei contenuti dell'elemento.
  3. Utilizza un TransformationSpec personalizzato per i componenti che non accettano transformation come parametro, ad esempio Text.

L'animazione seguente mostra come un elemento di elenco viene scalato e cambia forma quando si avvicina alla parte superiore e inferiore dello schermo:

Lo snippet di codice seguente mostra come creare un elenco utilizzando il layout TransformingLazyColumn per creare contenuti che hanno un aspetto ottimale su schermi Wear OS di varie dimensioni.

Lo snippet mostra anche l'utilizzo del modificatore minimumVerticalContentPadding, che devi impostare sugli elementi dell'elenco per applicare la spaziatura interna corretta nella parte superiore e inferiore dell'elenco.

Per mostrare l'indicatore di scorrimento, condividi columnState tra ScreenScaffold e 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,
                )
            }
        }
    }
}

Aggiungere un effetto di scatto e lancio

Lo snapping assicura che, quando un utente termina un gesto di scorrimento o scorrimento rapido, l'elenco si stabilizzi con un elemento posizionato esattamente in un punto specifico, in genere il centro dello schermo. Sugli schermi rotondi, dove gli elementi vengono scalati e si trasformano man mano che si allontanano dal centro, lo snapping è particolarmente utile per garantire che l'elemento più pertinente rimanga completamente visibile e leggibile nell'area di visualizzazione ottimale.

Per aggiungere un comportamento di scorrimento rapido, imposta il parametro flingBehavior su TransformingLazyColumnDefaults.snapFlingBehavior(columnState). Imposta rotaryScrollableBehavior in modo che corrisponda, utilizzando RotaryScrollableDefaults.snapBehavior(columnState) per un'esperienza coerente quando utilizzi la corona o la ghiera fisica.

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

Layout inverso

Per impostazione predefinita, un elenco scorrevole è ancorato al bordo superiore. Se un utente ha scorri fino alla fine di un elenco standard e viene aggiunto un nuovo elemento alla fine, l'elenco mantiene la visualizzazione dell'utente sull'elemento corrente. Ad esempio, se l'utente visualizza l'elemento 10 nella parte inferiore dello schermo e viene aggiunto l'elemento 11, la visualizzazione rimane incentrata sull'elemento 10 e l'elemento 11 appare fuori dallo schermo sotto la visualizzazione corrente.

Per casi d'uso come le applicazioni di messaggistica o i log live, questo comportamento di solito non è desiderato. Quando arrivano nuovi elementi, gli utenti in genere vogliono vedere subito i contenuti più recenti se si trovano già in fondo all'elenco. Se arrivano molti elementi contemporaneamente, l'elenco deve saltare per visualizzare l'ultimo elemento in basso (il che significa che alcuni elementi intermedi potrebbero non essere visualizzati a meno che l'utente non scorra verso l'alto).

Per supportare questi casi d'uso, TransformingLazyColumn ti consente di invertire il layout impostando reverseLayout = true. In questo modo, l'ancoraggio dell'elenco viene spostato dal bordo superiore a quello inferiore.

Per comodità, l'impostazione reverseLayout = true inverte anche l'ordine visivo degli elementi e la direzione dei gesti di scorrimento:

  • Gli elementi sono composti dal basso verso l'alto, il che significa che l'indice 0 viene visualizzato nella parte inferiore dello schermo.
  • Se scorri verso l'alto, vengono visualizzati gli elementi con indici più elevati.

Per aggiungere un comportamento di scorrimento rapido insieme al layout inverso, puoi combinare flingBehavior e rotaryScrollableBehavior come mostrato nel seguente snippet:

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

Le seguenti immagini mostrano la differenza tra un elenco normale e un elenco invertito:

Un elemento TransformingLazyColumn con layout normale, che mostra l'elemento 1 in alto e gli elementi in ordine crescente.
Figura 1. Un layout di elenco standard in cui i contenuti vengono riempiti dall'alto verso il basso.
Un elemento TransformingLazyColumn con layout inverso, che mostra l'elemento 1 in basso e gli elementi in ordine decrescente verso l'alto.
Figura 2. Un layout di elenco invertito in cui i contenuti vengono riempiti dal basso verso l'alto.

Pulsanti laterali negli elenchi

Per Material 3, puoi aggiungere un EdgeButton, ovvero un pulsante che aderisce al bordo nella parte inferiore degli elenchi. Tuttavia, fai attenzione a non aggiungerlo come elemento all'interno di TransformingLazyColumn, ma a utilizzare lo slot edgeButton in ScreenScaffold.

L'utilizzo dello slot edgeButton garantisce che il pulsante sia posizionato correttamente in basso nello schermo e si comporti in modo appropriato quando si scorre l'elenco.

Il seguente snippet di codice mostra come implementare 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,
    ) {
        // ...
        // ...
    }
}

Scorrimento per rivelare negli elenchi

Il componente SwipeToReveal ti consente di accedere alle azioni per un elemento dell'elenco, ad esempio Card o Chip, scorrendo. Lo scorrimento in genere rivela uno o due pulsanti di azione (ad esempio "Elimina" o "Altro") dal lato.

Quando utilizzi SwipeToReveal all'interno di un TransformingLazyColumn, segui queste linee guida:

  • Reimposta allo scorrimento: quando l'utente scorre l'elenco, reimposta gli elementi aperti al loro stato coperto.
  • Altezze coerenti: imposta le altezze dei pulsanti di azione in modo che corrispondano a quelle dell'elemento interno scorrevole (Button o Card) per garantire un aspetto coerente.
  • Trasforma il contenitore: applica il modificatore transformedHeight e transformationSpec al componente SwipeToReveal stesso.
  • Non eseguire una doppia trasformazione: non applicare i modificatori transformedHeight o transformation all'elemento interno scorrevole (la scheda o il pulsante all'interno del contenitore SwipeToReveal).

Componenti componibili personalizzati negli elenchi

Quando crei componenti di superficie personalizzati per un TransformingLazyColumn, segui queste best practice in modo che il componente componibile venga scalato, sfumi e si trasformi senza problemi vicino ai bordi del display:

  • Espone SurfaceTransformation: accetta un parametro SurfaceTransformation facoltativo (il valore predefinito è null), corrispondente ai componenti standard di Wear Compose Material 3 come Card e Button. In questo modo i chiamanti possono TransformingLazyColumn passare SurfaceTransformation(transformationSpec) mentre il componente funziona normalmente al di fuori di un elenco.
  • Applica Modifier.transformedHeight per primo nel codice di chiamata: quando inserisci il tuo componente personalizzato in un TransformingLazyColumn, passa Modifier.transformedHeight(this, transformationSpec) come primo modificatore nella catena di modificatori del codice di chiamata. Mentre SurfaceTransformation applica gli effetti di ridimensionamento e dissolvenza visivi, transformedHeight è fondamentale per indicare al layout dell'elenco di ricalcolare l'altezza dell'elemento man mano che si riduce.
  • Applica i livelli di trasformazione, il chiamante modifier e il pittore in ordine:
    1. Livello di trasformazione del contenitore: avvia la catena di modificatori del contenitore principale con Modifier.graphicsLayer e applyContainerTransformation(), in modo che sia lo sfondo che i contenuti vengano disegnati nello spazio delle coordinate scalato e inclinato.
    2. Chiamante modifier: applica il parametro modifier passato dal chiamante (che include Modifier.transformedHeight) successivamente, prima di qualsiasi ridimensionamento o spaziatura interna.
    3. Clip della forma quando non è presente alcuna trasformazione: se transformation è null, applica Modifier.clip(shape) prima di disegnare lo sfondo. Il pittore restituito da createContainerPainter() si ritaglia sulla forma, ma un pittore semplice no, quindi senza questo lo sfondo viene disegnato con angoli quadrati al di fuori di un elenco.
    4. Morphing background painter: disegna lo sfondo all'interno del livello del contenitore utilizzando Modifier.drawBehind e un pittore creato da createContainerPainter().
    5. Livello di trasformazione dei contenuti: applica un secondo Modifier.graphicsLayer con applyContentTransformation() e ritaglia la forma del contenitore in modo che i contenuti interni sbiadiscano prima quando si avvicinano alla cornice.

Il seguente snippet mostra come implementare un composable BoardingPassCard personalizzato che applica queste trasformazioni in ordine:

@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 composable di carta d'imbarco personalizzata che si trasforma mentre scorre un elenco.

Puoi quindi utilizzare BoardingPassCard all'interno di un TransformingLazyColumn passando Modifier.transformedHeight come primo modificatore, insieme a 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)
                )
            }
        }
    }
}