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 d'UI de votre application à l'aide des scènes. Les scènes vous permettent de créer des mises en page très 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 UI qui peut contenir et gérer l'affichage du contenu de votre pile de retour.

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

L'interface Scene présente les propriétés suivantes :

  • key: Any : identifiant unique de cette instance Scene spécifique. Cette clé, combinée à la classe de Scene, garantit la distinction, principalement à des fins d'animation.
  • entries: List<NavEntry<T>> : liste des objets NavEntry que le Scene est chargé d'afficher. Il est important de noter que 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 rendu que par le Scene cible le plus récent qui l'affiche.
  • previousEntries: List<NavEntry<T>> : cette propriété définit les NavEntry qui résulteraient d'une action "Retour" à partir du Scene actuel. Il est essentiel 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 et/ou une clé différentes.
  • content: @Composable () -> Unit : il s'agit de la fonction composable dans laquelle vous définissez la façon dont Scene affiche son entries et tous les éléments d'UI environnants spécifiques à ce Scene.

Comprendre les stratégies de scène

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

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

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

@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 le List<NavEntry<T>> actuel 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 responsable de la gestion des arguments facultatifs dont le SceneStrategy peut avoir besoin, comme un rappel onBack.

SceneStrategy fournit également une fonction infixe then pratique, qui vous permet d'enchaîner plusieurs stratégies. Cela crée un pipeline de prise de décision flexible dans lequel chaque stratégie peut tenter de calculer un Scene. Si elle n'y parvient pas, elle délègue la tâche à la stratégie suivante de la chaîne.

Comment les scènes et les stratégies de scène fonctionnent-elles ensemble ?

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

Le paramètre NavDisplay's sceneStrategy attend un SceneStrategy chargé de calculer le Scene à afficher. Si aucune Scene n'est calculée par la stratégie (ou la chaîne de stratégies) fournie, NavDisplay revient automatiquement à l'utilisation d'une SinglePaneSceneStrategy par défaut.

Voici le détail 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()), NavDisplay observe ces modifications.
  • NavDisplay transmet la liste actuelle des NavEntrys (dérivée des clés de la pile de retour) à la méthode SceneStrategy's calculateScene configurée.
  • Si SceneStrategy renvoie un Scene, NavDisplay affiche le content de ce Scene. NavDisplay gère également les animations et la prévisualisation du retour en arrière en fonction des propriétés de Scene.

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

La mise en page personnalisée la plus simple 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 Liste/Détail de base (Scène et stratégie personnalisées)

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

  1. La largeur de la fenêtre est suffisamment grande 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 compatibilité avec l'affichage dans une mise en page liste/détails à l'aide de métadonnées spécifiques.

L'extrait suivant est le code source de ListDetailScene.kt. 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.
 *
 */
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.containsKey(DETAIL_KEY) } ?: return null
        val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) } ?: 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
        )
    }

    companion object {
        internal const val LIST_KEY = "ListDetailScene-List"
        internal const val DETAIL_KEY = "ListDetailScene-Detail"

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

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

Pour utiliser ce ListDetailSceneStrategy dans votre NavDisplay, modifiez vos appels entryProvider pour inclure les métadonnées ListDetailScene.listPane() pour l'entrée que vous souhaitez afficher en tant que mise en page list et le ListDetailScene.detailPane() pour l'entrée que vous souhaitez afficher en tant que mise en page detail. Ensuite, indiquez ListDetailSceneStrategy() comme sceneStrategy, en vous appuyant sur la solution de secours 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() },
        sceneStrategy = 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 liste/détails, vous pouvez utiliser la scène de liste/détails Material, qui inclut des détails pertinents et la prise en charge des espaces réservés, comme indiqué dans la section suivante.

Afficher le contenu de la liste et des détails dans une scène adaptative Material

Pour le cas d'utilisation Liste/Détails, l'artefact androidx.compose.material3.adaptive:adaptive-navigation3 fournit un ListDetailSceneStrategy qui crée un Scene Liste/Détails. Ce Scene gère automatiquement les agencements complexes à plusieurs volets (listes, 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 un Scene Material list-detail :

  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 des métadonnées ListDetailSceneStrategy : utilisez listPane(), detailPane() et extraPane() pour marquer votre NavEntrys afin qu'il s'affiche dans le volet approprié. L'assistant listPane() vous permet également de spécifier un detailPlaceholder lorsqu'aucun élément n'est sélectionné.
  3. Utiliser rememberListDetailSceneStrategy(): cette fonction composable fournit un ListDetailSceneStrategy préconfiguré qui peut être utilisé par un NavDisplay.

L'extrait suivant est un exemple de Activity qui montre comment utiliser 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() },
                    sceneStrategy = 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 s'exécutant dans la scène de liste/détails Material.