Naviguer avec Compose

Le composant Navigation prend en charge les applications Jetpack Compose. Vous pouvez naviguer entre les composables tout en tirant parti de l'infrastructure et des fonctionnalités du composant Navigation.

Configuration

Pour assurer la prise en charge de Compose, utilisez la dépendance suivante dans le fichier build.gradle de votre module d'application :

Groovy

dependencies {
    def nav_version = "2.5.3"

    implementation "androidx.navigation:navigation-compose:$nav_version"
}

Kotlin

dependencies {
    val nav_version = "2.5.3"

    implementation("androidx.navigation:navigation-compose:$nav_version")
}

Premiers pas

L'API NavController est au cœur du composant Navigation. Il suit l'état et la pile "Retour" des composables qui composent les écrans de votre application ainsi que l'état de chaque écran.

Vous pouvez créer un NavController à l'aide de la méthode rememberNavController() dans votre composable :

val navController = rememberNavController()

Vous devez créer NavController à l'emplacement de votre hiérarchie de composables, là où tous les composables qui doivent le référencer peuvent y avoir accès. Ce processus suit les principes du hissage d'état et vous permet d'utiliser le NavController et l'état qu'il fournit via currentBackStackEntryAsState() comme source d'informations pour mettre à jour des composables en dehors de vos écrans. Consultez la section Intégration avec la barre de navigation inférieure pour découvrir un exemple de cette fonctionnalité.

Créer un composable NavHost

Chaque NavController doit être associé à un seul composable NavHost. NavHost associe NavController à un graphique de navigation qui spécifie les destinations des composables que vous devriez pouvoir parcourir. Lorsque vous naviguez entre les composables, le contenu de NavHost est automatiquement recomposé. Chaque destination composable de votre graphique de navigation est associée à un itinéraire.

Pour créer la NavHost, vous devez utiliser le NavController précédemment créé via rememberNavController() et l'itinéraire de destination de départ de votre graphe. La création du NavHost utilise la syntaxe lambda du DSL de navigation Kotlin pour créer votre graphique de navigation. Vous pouvez l'ajouter à votre structure de navigation en utilisant la méthode composable(). Cette méthode nécessite de fournir un itinéraire et le composable qui doit être associé à la destination :

NavHost(navController = navController, startDestination = "profile") {
    composable("profile") { Profile(/*...*/) }
    composable("friendslist") { FriendsList(/*...*/) }
    /*...*/
}

Pour accéder à une destination de composable dans le graphique de navigation, vous devez utiliser la méthode navigate. navigate utilise un seul paramètre String qui représente l'itinéraire de la destination. Pour naviguer à partir d'un composable dans le graphique de navigation, appelez navigate :

navController.navigate("friendslist")

Par défaut, navigate ajoute votre nouvelle destination à la pile "Retour". Vous pouvez modifier le comportement de navigate en associant des options de navigation supplémentaires à notre appel navigate() :

// Pop everything up to the "home" destination off the back stack before
// navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home")
}

// Pop everything up to and including the "home" destination off
// the back stack before navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home") { inclusive = true }
}

// Navigate to the "search” destination only if we’re not already on
// the "search" destination, avoiding multiple copies on the top of the
// back stack
navController.navigate("search") {
    launchSingleTop = true
}

Consultez le guide popUpTo pour découvrir d'autres cas d'utilisation.

La fonction navigate de NavController modifie l'état interne de NavController. Pour respecter autant que possible le principe de la référence unique, seule la fonction modulable ou seul le conteneur d'état qui hisse la fonction NavController et les fonctions modulables qui utilisent NavController comme paramètre doivent effectuer des appels de navigation. Les événements de navigation déclenchés à partir d'autres fonctions modulables dans la hiérarchie de l'UI doivent exposer ces événements à l'appelant de manière appropriée à l'aide de fonctions.

L'exemple suivant montre la fonction modulable MyAppNavHost comme référence unique pour l'instance NavController. ProfileScreen expose un événement sous la forme d'une fonction appelée lorsque l'utilisateur appuie sur un bouton. MyAppNavHost, qui permet de parcourir les différents écrans de l'application, effectue l'appel de navigation vers la bonne destination lorsque vous appelez ProfileScreen.

@Composable
fun MyAppNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController(),
    startDestination: String = "profile"
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = startDestination
    ) {
        composable("profile") {
            ProfileScreen(
                onNavigateToFriends = { navController.navigate("friendsList") },
                /*...*/
            )
        }
        composable("friendslist") { FriendsListScreen(/*...*/) }
    }
}

@Composable
fun ProfileScreen(
    onNavigateToFriends: () -> Unit,
    /*...*/
) {
    /*...*/
    Button(onClick = onNavigateToFriends) {
        Text(text = "See friends list")
    }
}

Vous ne devez appeler navigate() que dans le cadre d'un rappel, et non dans le cadre de votre composable lui-même. Vous éviterez ainsi d'appeler navigate() à chaque recomposition.

L'exposition d'événements à partir de fonctions modulables à des appelants qui savent gérer une logique particulière dans l'application est une bonne pratique dans Compose lors du hissage d'état.

Bien qu'exposer des événements en tant que paramètres lambda individuels puisse surcharger la signature de la fonction, cela permet d'optimiser la visibilité des responsabilités de la fonction modulable. Vous pouvez voir un aperçu de son fonctionnement.

D'autres solutions susceptibles visant à réduire le nombre de paramètres dans la déclaration de la fonction peuvent être plus pratiques dans un premier temps, mais présentent quelques inconvénients à long terme. Par exemple, vous pouvez créer une classe wrapper comme ProfileScreenEvents pour centraliser tous les événements au même endroit. Vous réduirez la visibilité de ce que fait le composable lors de la définition de sa fonction, cela ajoutera une autre classe et des méthodes au décompte de votre projet, et dans tous les cas, vous devrez créer et mémoriser des instances de cette classe à chaque fois que vous appellerez cette fonction modulable. De plus, pour réutiliser cette classe wrapper autant que possible, ce modèle incite à transmettre une instance de cette classe vers le bas de la hiérarchie d'UI au lieu de suivre les recommandations, c'est-à-dire de transmettre aux composables uniquement ce dont ils ont besoin.

Navigation Compose permet également de transmettre des arguments entre des destinations de composables. Pour ce faire, vous devez ajouter des espaces réservés aux arguments sur votre itinéraire, comme lorsque vous ajoutez des arguments à un lien profond en utilisant la bibliothèque de navigation de base :

NavHost(startDestination = "profile/{userId}") {
    ...
    composable("profile/{userId}") {...}
}

Par défaut, tous les arguments sont analysés sous forme de chaînes. Le paramètre arguments de composable() accepte une liste de NamedNavArgument. Vous pouvez créer rapidement un NamedNavArgument à l'aide de la méthode navArgument, puis spécifier son type exact :

NavHost(startDestination = "profile/{userId}") {
    ...
    composable(
        "profile/{userId}",
        arguments = listOf(navArgument("userId") { type = NavType.StringType })
    ) {...}
}

Vous devez extraire les arguments du NavBackStackEntry disponible dans le lambda de la fonction composable().

composable("profile/{userId}") { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

Pour transmettre l'argument à la destination, vous devez l'ajouter à l'itinéraire lorsque vous effectuez l'appel navigate :

navController.navigate("profile/user1234")

Pour obtenir la liste des types compatibles, consultez la section Transmettre des données entre les destinations.

Récupération de données complexes lors de la navigation

Lorsque vous effectuez des actions de navigation, nous vous recommandons vivement de ne pas transmettre d'objets de données complexes, mais plutôt de transmettre le strict minimum (comme un identifiant unique ou une autre forme d'ID) sous la forme d'arguments.

// Pass only the user ID when navigating to a new destination as argument
navController.navigate("profile/user1234")

Les objets complexes doivent être stockés sous forme de données dans une référence unique, telle que la couche de données. Une fois arrivé à destination après votre navigation, vous pouvez charger les informations requises à partir de la référence unique en utilisant l'ID transmis. Pour récupérer les arguments de votre ViewModel responsables de l'accès à la couche de données, vous pouvez utiliser la méthode ViewModel’sSavedStateHandle :

class UserViewModel(
    savedStateHandle: SavedStateHandle,
    private val userInfoRepository: UserInfoRepository
) : ViewModel() {

    private val userId: String = checkNotNull(savedStateHandle["userId"])

    // Fetch the relevant user information from the data layer,
    // ie. userInfoRepository, based on the passed userId argument
    private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(userId)

// …

}

Cette approche permet de conserver les données lors des modifications de configuration et d'éviter les incohérences lors de la mise à jour ou de la mutation d'un objet.

Pour découvrir pourquoi vous devez éviter de transmettre des données complexes en tant qu'arguments, et pour obtenir la liste des types d'arguments compatibles, consultez la page Transmettre des données entre les destinations.

Ajouter des arguments facultatifs

Navigation Compose accepte également les arguments de navigation facultatifs. Les arguments facultatifs présentent deux différences par rapport aux arguments obligatoires :

  • Ils doivent être inclus à l'aide de la syntaxe des paramètres de requête ("?argName={argName}").
  • Ils doivent avoir une valeur defaultValue ou nullability = true (qui définit implicitement la valeur par défaut sur null).

Cela signifie que tous les arguments facultatifs doivent être explicitement ajoutés à la fonction composable() sous forme de liste :

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

Désormais, même si aucun argument n'est transmis à la destination, le champ defaultValue ("user1234") est utilisé à la place.

Avec la structure de traitement des arguments via les itinéraires, vos composables restent entièrement indépendants de la navigation. Vous pouvez donc les tester plus facilement.

Navigation Compose accepte également les liens profonds implicites qui peuvent également être définis dans la fonction composable(). Son paramètre deepLinks accepte une liste d'éléments NavDeepLink, qui peuvent être créés facilement à l'aide de la méthode navDeepLink :

val uri = "https://www.example.com"

composable(
    "profile?id={id}",
    deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("id"))
}

Ces liens profonds vous permettent d'associer une URL, une action ou un type MIME spécifiques à un composable. Par défaut, ces liens profonds ne sont pas exposés à des applications externes. Pour rendre ces liens profonds disponibles en externe, vous devez ajouter les éléments <intent-filter> appropriés au fichier manifest.xml de votre application. Pour activer le lien profond ci-dessus, vous devez ajouter ce qui suit dans l'élément <activity> du fichier manifeste :

<activity …>
  <intent-filter>
    ...
    <data android:scheme="https" android:host="www.example.com" />
  </intent-filter>
</activity>

La navigation crée automatiquement un lien profond dans ce composable lorsque le lien profond est déclenché par une autre application.

Ces mêmes liens profonds peuvent également être utilisés pour créer un PendingIntent avec le lien profond approprié à partir d'un composable :

val id = "exampleId"
val context = LocalContext.current
val deepLinkIntent = Intent(
    Intent.ACTION_VIEW,
    "https://www.example.com/$id".toUri(),
    context,
    MyActivity::class.java
)

val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
    addNextIntentWithParentStack(deepLinkIntent)
    getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
}

Vous pouvez ensuite utiliser ce deepLinkPendingIntent comme n'importe quel autre PendingIntent afin d'ouvrir votre application à l'emplacement de destination du lien profond.

Navigation imbriquée

Les destinations peuvent être regroupées dans un graphique imbriqué pour modulariser un flux particulier dans l'interface utilisateur de votre application. Il peut s'agir, par exemple, d'un flux de connexion autonome.

Le graphique imbriqué encapsule ses destinations. Comme pour un graphique racine, un graphique imbriqué doit avoir une destination identifiée comme destination de départ dans son itinéraire. Il s'agit de la destination visée lorsque vous accédez à l'itinéraire associé au graphe imbriqué.

Pour ajouter un graphique imbriqué à votre NavHost, vous pouvez utiliser la fonction d'extension navigation :

NavHost(navController, startDestination = "home") {
    ...
    // Navigating to the graph via its route ('login') automatically
    // navigates to the graph's start destination - 'username'
    // therefore encapsulating the graph's internal routing logic
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
    ...
}

Nous vous recommandons vivement de diviser votre graphique de navigation en plusieurs méthodes à mesure que sa taille augmente. Vous permettrez ainsi à plusieurs modules de fournir leurs propres graphiques de navigation.

fun NavGraphBuilder.loginGraph(navController: NavController) {
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
}

Si vous définissez la méthode comme une méthode d'extension sur NavGraphBuilder, vous pouvez l'utiliser avec les méthodes d'extension navigation, composable et dialog prédéfinies :

NavHost(navController, startDestination = "home") {
    ...
    loginGraph(navController)
    ...
}

Intégration avec la barre de navigation inférieure

En définissant NavController à un niveau supérieur dans votre hiérarchie de composables, vous pouvez connecter Navigation à d'autres composants, comme le composant de navigation en bas de l'écran. Vous pouvez alors naviguer en sélectionnant les icônes dans la barre inférieure.

Pour utiliser les composants BottomNavigation et BottomNavigationItem, ajoutez la dépendance androidx.compose.material à votre application Android.

Groovy

dependencies {
    implementation "androidx.compose.material:material:1.3.1"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.4.3"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

dependencies {
    implementation("androidx.compose.material:material:1.3.1")
}

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.4.3"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Pour associer les éléments d'une barre de navigation inférieure aux itinéraires de votre graphique de navigation, nous vous recommandons de définir une classe scellée, comme Screen ici. Elle contient l'itinéraire et l'ID de ressource de chaîne des destinations.

sealed class Screen(val route: String, @StringRes val resourceId: Int) {
    object Profile : Screen("profile", R.string.profile)
    object FriendsList : Screen("friendslist", R.string.friends_list)
}

Placez ensuite ces éléments dans une liste pouvant être utilisée par BottomNavigationItem :

val items = listOf(
   Screen.Profile,
   Screen.FriendsList,
)

Dans votre composable BottomNavigation, obtenez le NavBackStackEntry actuel en utilisant la fonction currentBackStackEntryAsState(). Cette entrée vous donne accès au NavDestination actuel. L'état sélectionné de chaque BottomNavigationItem peut ensuite être déterminé en comparant l'itinéraire de l'élément avec l'itinéraire de la destination actuelle et ses destinations parentes (pour gérer les cas où vous utilisez la navigation imbriquée) via la hiérarchie NavDestination.

L'itinéraire de l'élément est également utilisé pour connecter le lambda onClick à un appel navigate afin que l'utilisateur puisse appuyer sur l'élément pour y accéder. Grâce aux options saveState et restoreState, l'état et la pile "Retour" de cet élément sont correctement enregistrés et restaurés lorsque vous passez d'un élément de navigation à l'autre en bas de l'écran.

val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      items.forEach { screen ->
        BottomNavigationItem(
          icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
          label = { Text(stringResource(screen.resourceId)) },
          selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
          onClick = {
            navController.navigate(screen.route) {
              // Pop up to the start destination of the graph to
              // avoid building up a large stack of destinations
              // on the back stack as users select items
              popUpTo(navController.graph.findStartDestination().id) {
                saveState = true
              }
              // Avoid multiple copies of the same destination when
              // reselecting the same item
              launchSingleTop = true
              // Restore state when reselecting a previously selected item
              restoreState = true
            }
          }
        )
      }
    }
  }
) { innerPadding ->
  NavHost(navController, startDestination = Screen.Profile.route, Modifier.padding(innerPadding)) {
    composable(Screen.Profile.route) { Profile(navController) }
    composable(Screen.FriendsList.route) { FriendsList(navController) }
  }
}

Ici, vous allez exploiter la méthode NavController.currentBackStackEntryAsState() pour hisser l'état navController de la fonction NavHost, puis le partager avec le composant BottomNavigation. Cela signifie que BottomNavigation possède automatiquement l'état le plus récemment mis à jour.

Sûreté du typage dans Navigation Compose

Le code de cette page n'utilise pas la sûreté du typage. Vous pouvez appeler la fonction navigate() avec des routes inexistantes ou des arguments incorrects. Cependant, vous pouvez structurer votre code de navigation pour qu'il soit sûr lors de l'exécution. Cela vous permet d'éviter les plantages et de vous assurer que :

  • les arguments que vous fournissez lorsque vous accédez à une destination ou un graphique de navigation sont de types appropriés, et que tous les arguments requis sont présents ;f
  • les arguments que vous récupérez à partir de SavedStateHandle sont de types appropriés.

Pour en savoir plus à ce sujet, consultez la documentation sur la sûreté du typage dans Navigation.

Interopérabilité

Si vous souhaitez utiliser le composant Navigation avec Compose, deux options s'offrent à vous :

  • Définissez un graphique de navigation avec le composant Navigation pour les fragments.
  • Définissez un graphique de navigation avec un NavHost dans Compose en utilisant les destinations Compose. Cela n'est possible que si tous les écrans du graphique de navigation sont des composables.

Pour les applications qui utilisent à la fois les vues et Compose, nous vous recommandons donc d'utiliser le composant Navigation basé sur des fragments. Les fragments contiendront ainsi les écrans basés sur les vues, les écrans Compose et les écrans qui utilisent à la fois les vues et Compose. Une fois que le contenu de chaque fragment se trouve dans Compose, l'étape suivante consiste à lier tous ces écrans avec Navigation Compose et à supprimer tous les fragments.

Pour modifier des destinations dans le code Compose, vous exposez des événements pouvant être transmis à n'importe quel composable de la hiérarchie et déclenchés par ceux-ci :

@Composable
fun MyScreen(onNavigate: (Int) -> ()) {
    Button(onClick = { onNavigate(R.id.nav_profile) } { /* ... */ }
}

Dans votre fragment, vous créez le pont entre Compose et le composant de navigation basé sur des fragments en recherchant NavController et en accédant à la destination :

override fun onCreateView( /* ... */ ) {
    setContent {
        MyScreen(onNavigate = { dest -> findNavController().navigate(dest) })
    }
}

Vous pouvez également transmettre l'élément NavController à votre hiérarchie Compose. Toutefois, l'exposition de fonctions simples est beaucoup plus réutilisable et testable.

Tests

Nous vous recommandons vivement de dissocier le code de navigation de vos destinations de composables afin de pouvoir tester chaque composable séparément, indépendamment du composable NavHost.

Cela signifie que vous ne devez pas transmettre navController directement dans un composable, mais transmettre des rappels de navigation sous la forme de paramètres. Tous vos composables peuvent ainsi être testés individuellement, car ils ne nécessitent pas d'instance de navController lors des tests.

C'est le niveau d'indirection fourni par le lambda composable qui vous permet de séparer votre code de navigation du composable lui-même. Cela fonctionne dans deux sens :

  • transmettre des arguments analysés dans votre composable uniquement ;
  • transmettre des lambdas qui doivent être déclenchés par le composable pour naviguer, plutôt que le NavController lui-même.

Par exemple, un composable Profile qui accepte une entrée userId et permet aux utilisateurs d'accéder à la page de profil d'un ami peut avoir la signature suivante :

@Composable
fun Profile(
    userId: String,
    navigateToFriendProfile: (friendUserId: String) -> Unit
) {
 …
}

De cette façon, le composable Profile fonctionne indépendamment de la navigation, ce qui lui permet d'être testé séparément. Le lambda composable encapsule la logique minimale requise pour combler l'écart entre les API de navigation et votre composable :

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(backStackEntry.arguments?.getString("userId")) { friendUserId ->
        navController.navigate("profile?userId=$friendUserId")
    }
}

Nous vous recommandons d'écrire des tests qui couvrent les besoins de votre application en matière de navigation. Pour ce faire, testez le NavHost, les actions de navigation transmises à vos composables ainsi qu'à vos composables d'écran individuels.

Tester le NavHost

Pour commencer à tester votre NavHost, ajoutez la dépendance navigation-testing suivante :

dependencies {
// ...
  androidTestImplementation "androidx.navigation:navigation-testing:$navigationVersion"
  // ...
}

Vous pouvez configurer l'objet de test NavHost et lui transmettre une instance de l'instance navController. Pour ce faire, l'artefact de test de navigation fournit un TestNavHostController. Voici à quoi ressemble votre test d'UI qui vérifie la destination de départ de votre application et le NavHost :

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    lateinit var navController: TestNavHostController

    @Before
    fun setupAppNavHost() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavHost(navController = navController)
        }
    }

    // Unit test
    @Test
    fun appNavHost_verifyStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Start Screen")
            .assertIsDisplayed()
    }
}

Tester les actions de navigation

Vous pouvez tester l'implémentation de la navigation de différentes manières : en cliquant sur les éléments de l'UI, puis en vérifiant la destination affichée ou en comparant l'itinéraire attendu par rapport à l'itinéraire actuel.

Pour tester l'implémentation concrète de votre application, il est préférable de tester les clics sur les éléments de l'UI. Pour découvrir comment procéder de manière isolée avec des fonctions modulables individuelles, consultez l'atelier de programmation Tests dans Jetpack Compose.

Vous pouvez également utiliser navController pour vérifier vos assertions en comparant l'itinéraire de la chaîne actuelle à celui attendu, à l'aide du currentBackStackEntry de navController :

@Test
fun appNavHost_clickAllProfiles_navigateToProfiles() {
    composeTestRule.onNodeWithContentDescription("All Profiles")
        .performScrollTo()
        .performClick()

    val route = navController.currentBackStackEntry?.destination?.route
    assertEquals(route, "profiles")
}

Pour en savoir plus sur les principes de base des tests Compose, consultez la documentation sur les tests Compose et l'atelier de programmation Tester dans Jetpack Compose. Pour en savoir plus sur les tests avancés du code de navigation, consultez le guide intitulé Tester la navigation.

En savoir plus

Pour en savoir plus sur le composant Navigation de Jetpack, consultez Premiers pas avec le composant Navigation ou suivez l'atelier de programmation sur la navigation Jetpack Compose.

Pour découvrir comment concevoir la navigation de votre application pour l'adapter à différentes tailles d'écran, orientations et facteurs de forme, consultez la page Navigation pour les interfaces utilisateur responsives.

Pour en savoir plus sur la mise en œuvre plus avancée de Navigation Compose dans une application modularisée, y compris des concepts tels que les graphiques imbriqués et l'intégration de la barre de navigation inférieure, consultez le dépôt GitHub Now in Android.

Exemples