Créer des mises en page personnalisées à l'aide de scènes

Navigation 3 introduit un système puissant et flexible pour gérer le flux de l'interface utilisateur de votre application via des scènes. Les scènes vous permettent de créer des mises en page hautement personnalisées, de vous adapter à différentes tailles d'écran et de gérer de manière transparente des expériences complexes à plusieurs volets.

Comprendre les scènes

Dans Navigation 3, un Scene est l'unité fondamentale qui affiche une ou plusieurs instances NavEntry. Considérez une Scene comme un état visuel ou une section distincte de votre interface utilisateur qui peut contenir et gérer l'affichage du contenu de votre pile de retour.

Chaque Scene instance est identifiée de manière unique par son key et la classe de la Scene elle-même. Cet identifiant unique est essentiel, car il pilote l' animation de premier niveau lorsque le Scene change.

L'interface Scene comporte les propriétés suivantes :

  • key: Any : identifiant unique de cette instance Scene spécifique. Cette clé, combinée à la classe de la Scene, garantit la distinction, principalement à des fins d'animation.
  • entries: List<NavEntry<T>> : liste des objets NavEntry que le Scene est chargée d'afficher. Important : si le même NavEntry est affiché dans plusieurs Scenes lors d'une transition (par exemple, dans une transition d'élément partagé), son contenu ne sera affiché que par la Scene cible la plus récente.
  • previousEntries: List<NavEntry<T>> : cette propriété définit les NavEntry qui résulteraient d'une action « Retour » à partir du Scene actuel. Elle est essentielle pour calculer l'état de retour prédictif approprié, ce qui permet à NavDisplay d'anticiper et de passer à l'état précédent correct, qui peut être une scène avec une classe, une clé ou les deux différentes.
  • content: @Composable () -> Unit : fonction composable dans laquelle vous définissez comment la Scene affiche ses entries et tous les éléments d'interface utilisateur environnants spécifiques à cette Scene.
  • metadata: Map<String, Any> : fournit des informations spécifiques à la scène à d'autres composants de la bibliothèque, tels que NavDisplay. Par défaut, renvoie les metadata du dernier NavEntry dans entries.

Implémenter equals et hashCode dans des scènes personnalisées

Les implémentations Scene personnalisées doivent implémenter correctement equals et hashCode pour prendre en charge les éléments suivants :

  • Transitions d'état de la scène : chaque fois qu'une scène est calculée par la liste des stratégies de scène, NavDisplay vérifie l'égalité de la nouvelle scène et de la précédente. S'il détecte un changement, il met à jour le SeekableTransition utilisé pour passer d'une scène à l'autre, ce qui affecte à son tour l'état du cycle de vie des scènes.
  • Gestion des superpositions : pour les OverlayScene (comme les boîtes de dialogue), NavDisplay utilise l'objet de scène lui-même comme key pour suivre les cycles de vie des superpositions et les animations de sortie.

Vérifiez que toutes les propriétés qui définissent le contenu de la scène, telles que key, entries et previousEntries, sont incluses dans les implémentations equals et hashCode. En règle générale, évitez d'inclure des rappels (comme onBack) dans l'implémentation de ces méthodes, car elles peuvent modifier des instances sans modifier l'état logique de la scène.

Comprendre les stratégies de scène

Un SceneStrategy est le mécanisme qui détermine comment une liste donnée de NavEntry de la pile de retour doit être organisée et transformée en Scene. En substance, lorsqu'une SceneStrategy est présentée avec les entrées de la pile de retour actuelle, elle se pose deux questions clés :

  1. Puis-je créer un Scene à partir de ces entrées ? Si la SceneStrategy détermine qu'elle peut gérer les NavEntrys données et former une Scene (par exemple, une boîte de dialogue ou une mise en page à plusieurs volets), elle continue. Sinon, elle renvoie null, ce qui permet à d'autres stratégies de créer une Scene.
  2. Si oui, comment organiser ces entrées dans la Scene? Une fois qu'une SceneStrategy s'engage à gérer les entrées, elle est chargée de la responsabilité de construire une Scene et de définir comment les NavEntry spécifiées seront affichées dans cette Scene.

Le cœur d'un SceneStrategy est sa calculateScene méthode :

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

Cette méthode est une fonction d'extension sur un SceneStrategyScope qui prend la actuelle List<NavEntry<T>> de la pile de retour. Elle doit renvoyer un Scene<T> si elle peut en former un à partir des entrées fournies, ou null si elle ne le peut pas.

Le SceneStrategyScope est chargé de conserver tous les arguments facultatifs dont le SceneStrategy peut avoir besoin, tels qu'un rappel onBack.

Fonctionnement combiné des scènes et des stratégies de scène

Le NavDisplay est le composable central qui observe votre pile de retour et utilise une ou plusieurs SceneStrategy pour déterminer et afficher le Scene approprié.

Le paramètre sceneStrategies de NavDisplay attend une liste d'instances SceneStrategy chargées de calculer la Scene à afficher. Si aucune Scene n'est calculée par les stratégies fournies, NavDisplay revient automatiquement à l'utilisation d'une SinglePaneSceneStrategy par défaut.

Voici une présentation de l'interaction :

  • Lorsque vous ajoutez ou supprimez des clés de votre pile de retour (par exemple, à l'aide de backStack.add() ou backStack.removeLastOrNull()), le NavDisplay observe ces modifications.
  • Le NavDisplay transmet la liste actuelle des NavEntry (dérivée des clés de la pile de retour) aux sceneStrategies configurées dans l'ordre, en appelant calculateScene sur chacune jusqu'à ce qu'une Scene soit renvoyée.
  • Lorsqu'un SceneStrategy renvoie un Scene, le NavDisplay affiche ensuite le content de ce Scene. Le NavDisplay gère également les animations et le retour prédictif en fonction des propriétés de la Scene.

Exemple : Mise en page à un seul volet (comportement par défaut)

La mise en page personnalisée la plus simple que vous puissiez avoir est un affichage à un seul volet, qui est le comportement par défaut si aucune autre SceneStrategy n'est prioritaire.

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

Exemple : Mise en page de base de type liste-détails (scène et stratégie personnalisées)

Cet exemple montre comment créer une mise en page de type liste-détails qui est activée en fonction de deux conditions :

  1. La largeur de la fenêtre est suffisamment importante pour prendre en charge deux volets (c'est-à-dire au moins WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. La pile de retour contient des entrées qui ont déclaré leur prise en charge pour être affichées dans une mise en page de type liste-détails à l'aide de métadonnées spécifiques.

L'extrait de code suivant correspond au code source de ListDetailScene.kt et il contient à la fois ListDetailScene et ListDetailSceneStrategy :

// --- ListDetailScene ---
/**
 * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
 *
 */
data 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)
        }
    }
}

Pour utiliser cette ListDetailSceneStrategy dans votre NavDisplay, modifiez vos appels entryProvider pour inclure les métadonnées ListDetailScene.listPane() pour l'entrée que vous souhaitez afficher sous forme de mise en page de liste et ListDetailScene.detailPane() pour l'entrée que vous souhaitez afficher sous forme de mise en page de détails. Ensuite, fournissez ListDetailSceneStrategy() comme sceneStrategy, en vous appuyant sur le remplacement par défaut pour les scénarios à un seul volet :

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

Si vous ne souhaitez pas créer votre propre scène de type liste-détails, vous pouvez utiliser la scène de type liste-détails Material, qui fournit des détails pertinents et la prise en charge des espaces réservés, comme illustré dans la section suivante.

Afficher le contenu de type liste-détails dans une scène adaptative Material

Pour le cas d'utilisation de type liste-détails, l' androidx.compose.material3.adaptive:adaptive-navigation3 artefact fournit une ListDetailSceneStrategy qui crée une Scene de type liste-détails. Cette Scene gère automatiquement les mises en page complexes à plusieurs volets (liste, détails et volets supplémentaires) et les adapte en fonction de la taille de la fenêtre et de l'état de l'appareil.

Pour créer une Scene de type liste-détails Material, procédez comme suit :

  1. Ajoutez la dépendance : incluez androidx.compose.material3.adaptive:adaptive-navigation3 dans le fichier build.gradle.kts de votre projet.
  2. Définissez vos entrées avec les métadonnées ListDetailSceneStrategy : utilisez listPane(), detailPane(), et extraPane() pour marquer vos NavEntrys pour un affichage approprié des volets. L'assistant listPane() vous permet également de spécifier un detailPlaceholder lorsqu'aucun élément n'est sélectionné.
  3. Utilisez rememberListDetailSceneStrategy() : cette fonction composable fournit une ListDetailSceneStrategy préconfigurée qui peut être utilisée par un NavDisplay.

L'extrait de code suivant est un exemple d'Activity illustrant l'utilisation de 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")
                        }
                    }
                )
            }
        }
    }
}

Figure 1 : Exemple de contenu exécuté dans une scène de type liste-détails Material.