Creare layout personalizzati utilizzando le scene

Navigation 3 introduce un sistema potente e flessibile per gestire il flusso dell'interfaccia utente della tua app tramite le Scene. Le scene ti consentono di creare layout altamente personalizzati, di adattarti a diverse dimensioni dello schermo e di gestire senza problemi esperienze complesse con più riquadri.

Informazioni sulle scene

In Navigation 3, un Scene è l'unità fondamentale che esegue il rendering di una o più istanze di NavEntry. Pensa a un Scene come a uno stato visivo o a una sezione distinta della tua UI che può contenere e gestire la visualizzazione dei contenuti del back stack.

Ogni istanza di Scene è identificata in modo univoco dal relativo key e dalla classe di Scene stessa. Questo identificatore univoco è fondamentale perché determina l'animazione di primo livello quando cambia Scene.

L'interfaccia Scene ha le seguenti proprietà:

  • key: Any: un identificatore univoco per questa istanza specifica di Scene. Questa chiave, combinata con la classe di Scene, garantisce la distinzione, principalmente per scopi di animazione.
  • entries: List<NavEntry<T>>: si tratta di un elenco di oggetti NavEntry che il Scene è responsabile della visualizzazione. È importante sottolineare che se lo stesso NavEntry viene visualizzato in più Scenes durante una transizione (ad es. in una transizione di elementi condivisi), il suo contenuto verrà visualizzato solo dall'Scene di destinazione più recente.
  • previousEntries: List<NavEntry<T>>: questa proprietà definisce gli NavEntry che si otterrebbero se si eseguisse un'azione "Indietro" dall'attuale Scene. È essenziale per calcolare lo stato precedente predittivo corretto, consentendo all'NavDisplay di prevedere e passare allo stato precedente corretto, che potrebbe essere una scena con una classe e/o una chiave diversa.
  • content: @Composable () -> Unit: questa è la funzione componibile in cui definisci il rendering di Scene del relativo entries e di eventuali elementi UI circostanti specifici per Scene.
  • metadata: Map<String, Any>: fornisce informazioni specifiche per la scena ad altri componenti della libreria, ad esempio NavDisplay. Per impostazione predefinita, restituisce il metadata dell'ultimo NavEntry in entries.

Informazioni sulle strategie di scena

Un SceneStrategy è il meccanismo che determina come deve essere disposta e trasferita una determinata lista di NavEntry dal back stack in un Scene. In sostanza, quando vengono presentate le voci correnti del back stack, un SceneStrategy si pone due domande chiave:

  1. Posso creare un Scene da queste voci? Se SceneStrategy determina di poter gestire i NavEntry forniti e formare un Scene significativo (ad es. una finestra di dialogo o un layout a più riquadri), procede. In caso contrario, restituisce null, dando ad altre strategie la possibilità di creare un Scene.
  2. In caso affermativo, come devo disporre queste voci nel Scene? Una volta che un SceneStrategy si impegna a gestire le voci, si assume la responsabilità di costruire un Scene e definire come verranno visualizzati i NavEntry specificati all'interno di questo Scene.

Il fulcro di un SceneStrategy è il suo metodo calculateScene:

@Composable
public fun calculateScene(
    entries: List<NavEntry<T>>,
    onBack: (count: Int) -> Unit,
): Scene<T>?

Questo metodo è una funzione di estensione su un SceneStrategyScope che prende l'List<NavEntry<T>> corrente dal back stack. Dovrebbe restituire un Scene<T> se riesce a formarne uno dalle voci fornite o null se non riesce.

SceneStrategyScope è responsabile della gestione di eventuali argomenti facoltativi di cui SceneStrategy potrebbe aver bisogno, ad esempio un callback onBack.

Come funzionano insieme le scene e le strategie di scena

NavDisplay è il componente componibile centrale che osserva il back stack e utilizza uno o più SceneStrategy per determinare ed eseguire il rendering dell'Scene appropriato.

Il parametro sceneStrategies di NavDisplay prevede un elenco di istanze SceneStrategy responsabili del calcolo di Scene da visualizzare. Se non viene calcolato alcun Scene dalle strategie fornite, NavDisplay torna automaticamente a utilizzare un SinglePaneSceneStrategy per impostazione predefinita.

Ecco una suddivisione dell'interazione:

  • Quando aggiungi o rimuovi chiavi dal tuo back stack (ad es. utilizzando backStack.add() o backStack.removeLastOrNull()), NavDisplay osserva queste modifiche.
  • NavDisplay passa l'elenco corrente di NavEntry (derivato dalle chiavi dello stack) al sceneStrategies configurato in ordine, chiamando calculateScene su ciascuno finché non viene restituito un Scene.
  • Quando un SceneStrategy restituisce correttamente un Scene, NavDisplay esegue il rendering di content di Scene. NavDisplay gestisce anche le animazioni e l'indietro predittivo in base alle proprietà di Scene.

Esempio: layout a un solo riquadro (comportamento predefinito)

Il layout personalizzato più semplice è la visualizzazione a un solo riquadro, che è il comportamento predefinito se nessun altro SceneStrategy ha la precedenza.

data class SinglePaneScene<T : Any>(
    override val key: Any,
    val entry: NavEntry<T>,
    override val previousEntries: List<NavEntry<T>>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(entry)
    override val content: @Composable () -> Unit = { entry.Content() }
}

/**
 * A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the
 * list.
 */
public class SinglePaneSceneStrategy<T : Any> : SceneStrategy<T> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? =
        SinglePaneScene(
            key = entries.last().contentKey,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

Esempio: layout list-detail di base (scena e strategia personalizzate)

Questo esempio mostra come creare un semplice layout list-detail che viene attivato in base a due condizioni:

  1. La larghezza della finestra è sufficiente per supportare due riquadri (ovvero almeno WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. Il back stack contiene voci che hanno dichiarato il supporto per la visualizzazione in un layout list-detail utilizzando metadati specifici.

Lo snippet seguente è il codice sorgente per ListDetailScene.kt e contiene sia ListDetailScene sia ListDetailSceneStrategy:

// --- ListDetailScene ---
/**
 * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
 *
 */
class ListDetailScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val listEntry: NavEntry<T>,
    val detailEntry: NavEntry<T>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(listEntry, detailEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.4f)) {
                listEntry.Content()
            }
            Column(modifier = Modifier.weight(0.6f)) {
                detailEntry.Content()
            }
        }
    }
}

@Composable
fun <T : Any> rememberListDetailSceneStrategy(): ListDetailSceneStrategy<T> {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    return remember(windowSizeClass) {
        ListDetailSceneStrategy(windowSizeClass)
    }
}

// --- ListDetailSceneStrategy ---
/**
 * A [SceneStrategy] that returns a [ListDetailScene] if the window is wide enough, the last item
 * is the backstack is a detail, and before it, at any point in the backstack is a list.
 */
class ListDetailSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {

    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {

        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val detailEntry =
            entries.lastOrNull()?.takeIf { it.metadata.contains(DetailKey) } ?: return null
        val listEntry = entries.findLast { it.metadata.contains(ListKey) } ?: return null

        // We use the list's contentKey to uniquely identify the scene.
        // This allows the detail panes to be displayed instantly through recomposition, rather than
        // having NavDisplay animate the whole scene out when the selected detail item changes.
        val sceneKey = listEntry.contentKey

        return ListDetailScene(
            key = sceneKey,
            previousEntries = entries.dropLast(1),
            listEntry = listEntry,
            detailEntry = detailEntry
        )
    }

    object ListKey : NavMetadataKey<Boolean>
    object DetailKey : NavMetadataKey<Boolean>
    companion object {

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun listPane() = metadata {
            put(ListKey, true)
        }

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun detailPane() = metadata {
            put(DetailKey, true)
        }
    }
}

Per utilizzare questo ListDetailSceneStrategy in NavDisplay, modifica le chiamate entryProvider in modo da includere i metadati ListDetailScene.listPane() per la voce che intendi mostrare come layout elenco e ListDetailScene.detailPane() per la voce che vuoi mostrare come layout dettaglio. Dopodiché, fornisci ListDetailSceneStrategy() come sceneStrategy, affidandoti al fallback predefinito per gli scenari a un solo riquadro:

// Define your navigation keys
@Serializable
data object ConversationList : NavKey

@Serializable
data class ConversationDetail(val id: String) : NavKey

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ConversationList)
    val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        sceneStrategies = listOf(listDetailStrategy),
        entryProvider = entryProvider {
            entry<ConversationList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
                Column(modifier = Modifier.fillMaxSize()) {
                    Text(text = "I'm a Conversation List")
                    Button(onClick = { backStack.addDetail(ConversationDetail("123")) }) {
                        Text(text = "Open detail")
                    }
                }
            }
            entry<ConversationDetail>(
                metadata = ListDetailSceneStrategy.detailPane()
            ) {
                Text(text = "I'm a Conversation Detail")
            }
        }
    )
}

private fun NavBackStack<NavKey>.addDetail(detailRoute: ConversationDetail) {

    // Remove any existing detail routes, then add the new detail route
    removeIf { it is ConversationDetail }
    add(detailRoute)
}

Se non vuoi creare la tua scena elenco-dettagli, puoi utilizzare la scena elenco-dettagli Material, che include dettagli sensati e il supporto dei segnaposto, come mostrato nella sezione successiva.

Visualizzare i contenuti list-detail in una scena adattiva di Material

Per lo scenario di utilizzo elenco-dettagli, l'artefatto androidx.compose.material3.adaptive:adaptive-navigation3 fornisce un ListDetailSceneStrategy che crea un Scene elenco-dettagli. Scene gestisce automaticamente le complesse disposizioni a più riquadri (elenco, dettagli e riquadri aggiuntivi) e le adatta in base alle dimensioni della finestra e allo stato del dispositivo.

Per creare un elenco dettagliato dei materiali Scene, segui questi passaggi:

  1. Aggiungi la dipendenza: includi androidx.compose.material3.adaptive:adaptive-navigation3 nel file build.gradle.kts del tuo progetto.
  2. Definisci le voci con i metadati ListDetailSceneStrategy: utilizza listPane(), detailPane() e extraPane() per contrassegnare NavEntrys per la visualizzazione corretta del riquadro. L'helper listPane() ti consente anche di specificare un detailPlaceholder quando non è selezionato alcun elemento.
  3. Utilizza rememberListDetailSceneStrategy(): questa funzione componibile fornisce un ListDetailSceneStrategy preconfigurato che può essere utilizzato da un NavDisplay.

Il seguente snippet è un esempio di Activity che mostra l'utilizzo di ListDetailSceneStrategy:

@Serializable
object ProductList : NavKey

@Serializable
data class ProductDetail(val id: String) : NavKey

@Serializable
data object Profile : NavKey

class MaterialListDetailActivity : ComponentActivity() {

    @OptIn(ExperimentalMaterial3AdaptiveApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Scaffold { paddingValues ->
                val backStack = rememberNavBackStack(ProductList)
                val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { backStack.removeLastOrNull() },
                    sceneStrategies = listOf(listDetailStrategy),
                    entryProvider = entryProvider {
                        entry<ProductList>(
                            metadata = ListDetailSceneStrategy.listPane(
                                detailPlaceholder = {
                                    ContentYellow("Choose a product from the list")
                                }
                            )
                        ) {
                            ContentRed("Welcome to Nav3") {
                                Button(onClick = {
                                    backStack.add(ProductDetail("ABC"))
                                }) {
                                    Text("View product")
                                }
                            }
                        }
                        entry<ProductDetail>(
                            metadata = ListDetailSceneStrategy.detailPane()
                        ) { product ->
                            ContentBlue("Product ${product.id} ", Modifier.background(PastelBlue)) {
                                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                                    Button(onClick = {
                                        backStack.add(Profile)
                                    }) {
                                        Text("View profile")
                                    }
                                }
                            }
                        }
                        entry<Profile>(
                            metadata = ListDetailSceneStrategy.extraPane()
                        ) {
                            ContentGreen("Profile")
                        }
                    }
                )
            }
        }
    }
}

Figura 1. Esempio di contenuti in esecuzione nella scena list-detail di Material.