Navigation pour les interfaces utilisateur responsives

La navigation est le processus qui consiste à interagir avec l'interface utilisateur d'une application pour accéder à ses destinations. Les principes de navigation d'Android fournissent des consignes pour vous aider à créer une navigation cohérente et intuitive dans l'application.

Les interfaces utilisateur responsives fournissent des destinations de contenu responsives et incluent souvent différents types d'éléments de navigation en fonction de la taille de l'écran. Il peut s'agit d'une barre de navigation inférieure sur les petits écrans, d'un rail de navigation sur les écrans de taille moyenne ou d'un panneau de navigation persistant sur les grands écrans. Quoi qu'il en soit, les interfaces utilisateur responsives doivent toujours respecter les principes de navigation.

Le composant Navigation de Jetpack applique ces principes de navigation et contribue à faciliter le développement d'applications avec des interfaces utilisateur responsives.

Figure 1 : Petit, moyen et grand écrans avec un panneau, une colonne et une barre inférieure de navigation

Navigation responsive dans l'interface utilisateur

La taille de la fenêtre d'affichage occupée par une application a un effet sur l'ergonomie et la facilité d'utilisation. Les classes de taille de fenêtre vous permettent de déterminer les éléments de navigation appropriés (tels que les barres de navigation, les rails ou les panneaux) et de les placer là où ils sont les plus accessibles pour l'utilisateur. Dans les consignes de mise en page de Material Design, les éléments de navigation occupent un espace persistant sur le bord gauche de l'écran et peuvent se déplacer vers le bord inférieur lorsque la largeur de l'application est compacte. Le choix des éléments de navigation dépend en grande partie de la taille de la fenêtre de l'application et du nombre d'éléments qu'elle doit contenir.

Classe de taille de fenêtre Peu d'éléments Beaucoup d'éléments
Largeur compacte Barre de navigation inférieure Panneau de navigation (bord gauche ou bord inférieur)
Largeur moyenne Rail de navigation Panneau de navigation (bord gauche)
Largeur étendue Rail de navigation Panneau de navigation persistant (bord gauche)

Dans les mises en page basées sur les vues, les fichiers de ressources de mise en page peuvent être qualifiés par des points d'arrêt de classe de taille de fenêtre afin d'utiliser différents éléments de navigation pour différentes dimensions d'affichage. Jetpack Compose peut utiliser les points d'arrêt fournis par l'API de classe de taille de fenêtre pour déterminer par programmation l'élément de navigation le mieux adapté à la fenêtre de l'application.

Vues

<!-- res/layout/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w600dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigationrail.NavigationRailView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w1240dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigation.NavigationView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

Compose

// This method should be run inside a Composable function.
val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
// You can get the height of the current window by invoking heightSizeClass instead.

@Composable
fun MyApp(widthSizeClass: WindowWidthSizeClass) {
    // Select a navigation element based on window size.
    when (widthSizeClass) {
        WindowWidthSizeClass.Compact -> { CompactScreen() }
        WindowWidthSizeClass.Medium -> { MediumScreen() }
        WindowWidthSizeClass.Expanded -> { ExpandedScreen() }
    }
}

@Composable
fun CompactScreen() {
    Scaffold(bottomBar = {
                NavigationBar {
                    icons.forEach { item ->
                        NavigationBarItem(
                            selected = isSelected,
                            onClick = { ... },
                            icon = { ... })
                    }
                }
            }
        ) {
        // Other content
    }
}

@Composable
fun MediumScreen() {
    Row(modifier = Modifier.fillMaxSize()) {
        NavigationRail {
            icons.forEach { item ->
                NavigationRailItem(
                    selected = isSelected,
                    onClick = { ... },
                    icon = { ... })
            }
        }
        // Other content
    }
}

@Composable
fun ExpandedScreen() {
    PermanentNavigationDrawer(
        drawerContent = {
            icons.forEach { item ->
                NavigationDrawerItem(
                    icon = { ... },
                    label = { ... },
                    selected = isSelected,
                    onClick = { ... }
                )
            }
        },
        content = {
            // Other content
        }
    )
}

Destinations de contenu responsives

Dans une UI responsive, la mise en page de chaque destination de contenu doit s'adapter aux changements de taille de la fenêtre. Votre application peut ajuster l'espacement de la mise en page, repositionner des éléments, ajouter ou supprimer du contenu, ou modifier les éléments d'interface utilisateur, y compris les éléments de navigation. Pour en savoir plus, consultez Migrer votre UI vers les mises en page responsives et Créer des mises en page adaptatives.

Lorsque chaque destination gère correctement les événements de redimensionnement, les modifications sont isolées dans l'interface utilisateur. Le reste de l'état de l'application, y compris la navigation, n'est pas affecté.

La navigation ne doit pas être un effet secondaire d'un changement de taille de la fenêtre. Ne créez pas de destinations de contenu juste pour vous adapter à différentes tailles de fenêtres. Par exemple, ne créez pas de destinations de contenu distinctes pour les différents écrans d'un appareil pliable.

Lorsque la navigation dépend des changements de taille de la fenêtre, les problèmes suivants peuvent se présenter :

  • L'ancienne destination (correspondant à la taille de fenêtre précédente) peut être visible momentanément avant d'accéder à la nouvelle destination.
  • Pour garantir la réversibilité (par exemple, lorsqu'un appareil est plié et déplié), la navigation est requise pour chaque taille de fenêtre.
  • Il peut être difficile de préserver l'état de l'application entre les destinations, car la navigation peut détruire l'état lors de l'affichage de la pile "Retour".

Par ailleurs, il est possible que votre application ne soit même pas au premier plan lorsque la taille de la fenêtre change. Si la mise en page de votre application nécessite plus d'espace que l'application au premier plan, l'orientation et la taille de la fenêtre pourraient avoir changé lorsque l'utilisateur retourne dans votre application.

Si votre application nécessite des destinations de contenu uniques en fonction de la taille de la fenêtre, envisagez de regrouper les destinations correspondantes dans une seule destination incluant d'autres mises en page.

Destinations de contenu avec plusieurs mises en page

Dans le cadre du responsive design, une seule destination de navigation peut comporter d'autres mises en page en fonction de la taille de la fenêtre de l'application. La mise en page occupe la totalité de la fenêtre, mais elle varie selon la taille de cette dernière.

La vue liste/détails est un bon exemple. Dans les fenêtres de petite taille, votre application affiche une mise en page pour la liste et une autre mise en page pour le détail. Au départ, l'accès à la destination de la vue liste/détails affiche uniquement la mise en page de la liste. Lorsqu'un élément de la liste est sélectionné, l'application affiche la mise en page du détail en lieu et place de la liste. Lorsque la commande "Retour" est sélectionnée, la liste s'affiche à nouveau et remplace les détails. Toutefois, pour les grandes tailles de fenêtre, les mises en page de la liste et des détails sont affichées côte à côte.

Vues

SlidingPaneLayout vous permet de créer une seule destination de navigation qui affiche deux volets de contenu côte à côte sur un grand écran, mais un volet à la fois sur les appareils à petit écran tels que les téléphones.

<!-- Single destination for list and detail. -->

<navigation ...>

    <!-- Fragment that implements SlidingPaneLayout. -->
    <fragment
        android:id="@+id/article_two_pane"
        android:name="com.example.app.ListDetailTwoPaneFragment" />

    <!-- Other destinations... -->
</navigation>

Pour découvrir comment implémenter une mise en page liste/détails à l'aide de SlidingPaneLayout, consultez Créer une mise en page à deux volets.

Compose

Dans Compose, vous pouvez implémenter une vue liste/détails en combinant plusieurs choix de composables dans une même route. Cette route utilise des classes de taille de fenêtre pour émettre le composable approprié pour chaque classe de taille.

Une route est le chemin de navigation vers une destination de contenu, qui est généralement un composable unique, mais qui peut également combiner plusieurs choix de composables. La logique métier détermine lequel des autres composables s'affichera. Quel que soit le composable choisi, il remplit la fenêtre de l'application.

La vue liste/détails est constituée de trois composables. Exemple :

/* Displays a list of items. */
@Composable
fun ListOfItems(
    onItemSelected: (String) -> Unit,
) { /*...*/ }

/* Displays the detail for an item. */
@Composable
fun ItemDetail(
    selectedItemId: String? = null,
) { /*...*/ }

/* Displays a list and the detail for an item side by side. */
@Composable
fun ListAndDetail(
    selectedItemId: String? = null,
    onItemSelected: (String) -> Unit,
) {
  Row {
    ListOfItems(onItemSelected = onItemSelected)
    ItemDetail(selectedItemId = selectedItemId)
  }
}

Une même route de navigation permet d'accéder à la vue liste/détails :

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      /*...*/
    )
  } else {
    // If the display size cannot accommodate both the list and the item detail,
    // show one of them based on the user's focus.
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(/*...*/)
    }
  }
}

ListDetailRoute (destination de navigation) détermine lequel des trois composables doit être émis : ListAndDetail pour une grande taille de fenêtre, ListOfItems ou ItemDetail pour les formats compacts, selon qu'un élément a été sélectionné dans la liste ou non.

La route est incluse dans un élément NavHost, par exemple :

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  /*...*/
}

Vous pouvez renseigner l'argument isExpandedWindowSize en examinant les paramètres WindowMetrics de votre application.

L'argument selectedItemId peut être fourni par un objet ViewModel qui gère l'état dans toutes les tailles de fenêtre. Lorsque l'utilisateur sélectionne un élément dans la liste, la variable d'état selectedItemId est mise à jour :

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

La route inclut également un BackHandler personnalisé lorsque le composable des détails de l'élément occupe la totalité de la fenêtre de l'application :

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }

  fun onItemBackPress() {
    viewModelState.update {
      it.copy(selectedItemId = null)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
    onItemBackPress: () -> Unit = { listDetailViewModel.onItemBackPress() },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
      BackHandler {
        onItemBackPress()
      }
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

La combinaison de l'état de l'application à partir d'un ViewModel avec les informations de classe de taille de fenêtre contribue au choix logique du composable. En conservant un flux de données unidirectionnel, votre application peut utiliser pleinement l'espace d'affichage disponible, tout en préservant l'état de l'application.

Pour accéder à l'implémentation complète d'une vue liste/détails dans Compose, consultez l'exemple JetNews sur GitHub.

Utiliser un seul graphe de navigation

Pour offrir une expérience utilisateur cohérente sur tous les appareils ou dans toutes les fenêtres, utilisez un seul graphe de navigation dans lequel la mise en page de chaque destination de contenu est responsive.

Si vous utilisez un graphe de navigation différent pour chaque classe de taille de fenêtre, chaque fois que l'application passe d'une classe de taille à une autre, vous devez déterminer la destination actuelle de l'utilisateur dans les autres graphes, construire une pile "Retour" et rapprocher les informations d'état qui diffèrent entre les graphes.

Hôte de navigation imbriqué

Votre application peut inclure une destination de contenu qui possède ses propres destinations de contenu. Par exemple, dans une vue liste/détails, le volet des détails d'un élément peut inclure des éléments d'interface utilisateur qui permettent d'accéder à du contenu qui le remplace.

Pour implémenter ce type de sous-navigation, le volet Détails peut être un hôte de navigation imbriqué doté de son propre graphe de navigation, qui spécifie les destinations accessibles à partir de ce volet :

Vues

<!-- layout/two_pane_fragment.xml -->

<androidx.slidingpanelayout.widget.SlidingPaneLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/sliding_pane_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_pane"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_gravity="start"/>

    <!-- Detail pane is a nested navigation host. Its graph is not connected
         to the main graph that contains the two_pane_fragment destination. -->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/detail_pane"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/detail_pane_nav_graph" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

Compose

@Composable
fun ItemDetail(selectedItemId: String? = null) {
    val navController = rememberNavController()
    NavHost(navController, "itemSubdetail1") {
        composable("itemSubdetail1") { ItemSubdetail1(...) }
        composable("itemSubdetail2") { ItemSubdetail2(...) }
        composable("itemSubdetail3") { ItemSubdetail3(...) }
    }
}

Cet exemple diffère d'un graphe de navigation imbriqué, car le graphe de navigation du NavHost imbriqué n'est pas connecté au graphe de navigation principal. Autrement dit, vous ne pouvez pas passer directement d'une destination à l'autre dans un graphe.

Pour en savoir plus, consultez Graphes de navigation imbriqués et Naviguer avec Compose.

État préservé

Pour fournir des destinations de contenu responsives, votre application doit conserver son état lorsque l'appareil est pivoté ou plié, ou lorsque la fenêtre de l'application est redimensionnée. Par défaut, ces changements de configuration recréent les activités, les fragments, la hiérarchie de vues et les composables de l'application. Pour enregistrer l'état de l'interface utilisateur, nous vous recommandons d'utiliser un élément ViewModel ou rememberSaveable, qui persistent en cas de changement de la configuration. Pour en savoir plus, consultez Enregistrer les états de l'interface utilisateur et États et Jetpack Compose.

Les changements de taille devraient être réversibles, notamment lorsque l'utilisateur fait pivoter l'appareil dans un sens, puis dans l'autre.

Les mises en page responsives peuvent afficher différents contenus en fonction des différentes tailles de fenêtre. Elles ont donc souvent besoin d'enregistrer un état supplémentaire lié au contenu, même si cet état n'est pas applicable à la taille de la fenêtre actuelle. Par exemple, une mise en page peut avoir de l'espace pour afficher un widget de défilement supplémentaire uniquement pour des largeurs de fenêtre plus importantes. Si un événement de redimensionnement réduit trop la largeur de la fenêtre, le widget est masqué. Lorsque l'application applique à nouveau les dimensions précédentes, le widget de défilement redevient visible, et la position de défilement d'origine doit être restaurée.

Champs d'application ViewModel

Le guide du développeur Effectuer une migration vers le composant Navigation recommande une architecture à activité unique dans laquelle les destinations sont implémentées en tant que fragments et leurs modèles de données sont implémentés à l'aide de ViewModel.

Un ViewModel est toujours limité à un cycle de vie. Une fois ce cycle de vie terminé définitivement, ViewModel est effacé et peut être supprimé. Le cycle de vie auquel le ViewModel est limité (ce qui détermine dans quelle mesure le ViewModel peut être partagée) dépend du délégué de propriété utilisé pour obtenir le ViewModel.

Dans le scénario le plus simple, chaque destination de navigation est un fragment unique avec un état d'interface utilisateur complètement isolé. Chaque fragment peut ainsi utiliser le délégué de propriété viewModels() pour obtenir un ViewModel limité à ce fragment.

Pour partager l'état de l'interface utilisateur entre des fragments, appliquez le champ d'application du ViewModel à l'activité en appelant activityViewModels() dans les fragments (l'équivalent pour l'activité est juste viewModels()). Cette action permet à l'activité et aux fragments qui lui sont associés de partager l'instance ViewModel. Toutefois, dans une architecture à activité unique, ce champ d'application du ViewModel reste efficace aussi longtemps que l'application. Par conséquent, le ViewModel reste en mémoire même si aucun fragment ne l'utilise.

Supposons que votre graphe de navigation présente une séquence de destinations de fragment représentant un flux de paiement et que l'état actuel de l'ensemble du processus de paiement se trouve dans un ViewModel partagé entre les fragments. L'application du champ d'application du ViewModel à l'activité est non seulement trop large, mais présente également un autre problème : si l'utilisateur passe par le processus de paiement pour une commande, puis réitère cette opération pour une deuxième commande, les deux commandes utilisent la même instance du ViewModel de paiement. Avant de procéder au règlement de la deuxième commande, vous devez effacer manuellement les données de la première commande. Toute erreur pourrait coûter cher à l'utilisateur.

Définissez plutôt la portée de l'élément ViewModel sur un graphe de navigation dans le NavController actuel. Créez un graphe de navigation imbriqué pour encapsuler les destinations incluses dans le processus de paiement. Ensuite, dans chacune de ces destinations de fragment, utilisez le délégué de propriété navGraphViewModels() et transmettez l'ID du graphe de navigation pour obtenir le ViewModel partagé. Cette approche garantit que lorsque l'utilisateur quitte le processus de paiement et que le graphe de navigation imbriqué n'est pas inclus dans le champ d'application, l'instance correspondante de ViewModel est supprimée et n'est pas utilisée pour le paiement suivant.

Champ d'application Délégué de propriété Peut partager le ViewModel avec…
Fragment Fragment.viewModels() Fragment actuel uniquement
Activité Activity.viewModels()

Fragment.activityViewModels()

Activité et tous les fragments associés
Graphe de navigation Fragment.navGraphViewModels() Tous les fragments du même graphe de navigation

Notez que si vous utilisez un hôte de navigation imbriqué (voir ci-dessus), les destinations de cet hôte ne peuvent pas partager de ViewModel avec des destinations extérieures à l'hôte lorsque vous utilisez navGraphViewModels(), car les graphes ne sont pas connectés. Dans ce cas, vous pouvez utiliser le champ d'application de l'activité à la place.

État hissé

Dans Compose, vous pouvez conserver l'état pendant les changements de taille de la fenêtre grâce au hissage d'état. En hissant l'état des composables à une position plus élevée dans l'arborescence de composition, il peut être préservé même si les composables ne sont plus visibles.

Dans la section Compose, sous Destinations de contenu avec plusieurs mises en page ci-dessus, nous avons hissé l'état des composables de la vue liste/détails sur ListDetailRoute afin que cet état soit conservé, quel que soit le composable affiché :

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) { /*...*/ }

Ressources supplémentaires