Navigazione per UI adattabili

La navigazione è il processo di interazione con l'interfaccia utente di un'applicazione per accedere alle destinazioni dei contenuti dell'app. I principi di navigazione di Android forniscono linee guida che ti aiutano a creare una navigazione nelle app coerente e intuitiva.

Le UI adattabili forniscono destinazioni dei contenuti adattabili e spesso includono diversi tipi di elementi di navigazione in risposta ai cambiamenti delle dimensioni dello schermo, ad esempio una barra di navigazione in basso sui display piccoli, una barra di navigazione sui display di medie dimensioni o un riquadro di navigazione a scomparsa permanente sui display di grandi dimensioni, ma le UI adattabili devono comunque essere conformi ai principi di navigazione.

Il componente di navigazione Jetpack implementa i principi di navigazione e può essere utilizzato per facilitare lo sviluppo di app con UI adattabili.

Figura 1. Display espansi, medi e compatti con riquadro di navigazione a scomparsa, barra laterale e barra inferiore.

Navigazione nell'interfaccia utente adattabile

Le dimensioni della finestra di visualizzazione occupata da un'app incidono sull'ergonomia e sull'usabilità. Le classi di dimensioni delle finestre consentono di determinare gli elementi di navigazione appropriati (come barre di navigazione, binari o cassetti) e di posizionarli dove sono più accessibili all'utente. Nelle linee guida per il layout di Material Design, gli elementi di navigazione occupano uno spazio permanente sul bordo anteriore del display e possono spostarsi verso il bordo inferiore quando la larghezza dell'app è compatta. La scelta degli elementi di navigazione dipende in gran parte dalle dimensioni della finestra dell'app e dal numero di elementi che l'elemento deve contenere.

Classe dimensioni finestra Pochi elementi Molti elementi
larghezza compatta barra di navigazione in basso riquadro di navigazione a scomparsa (bordo superiore o inferiore)
larghezza media rotaia di navigazione riquadro di navigazione a scomparsa (bordo superiore)
larghezza espansa rotaia di navigazione riquadro di navigazione a scomparsa permanente (bordo superiore)

Nei layout basati sulle visualizzazioni, i file di risorse di layout possono essere qualificati in base ai punti di interruzione della classe delle dimensioni delle finestre in modo da utilizzare elementi di navigazione diversi per dimensioni di visualizzazione diverse. Jetpack Compose può utilizzare i punti di interruzione forniti dall'API window size class per determinare in modo programmatico l'elemento di navigazione più adatto per la finestra dell'app.

visualizzazioni

<!-- 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>

Scrivi

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

Destinazioni dei contenuti adattabili

In un'interfaccia utente adattabile, il layout di ogni destinazione di contenuti deve adattarsi alle modifiche delle dimensioni delle finestre. La tua app può regolare la spaziatura del layout, riposizionare gli elementi, aggiungere o rimuovere contenuti oppure modificare gli elementi dell'interfaccia utente, inclusi quelli di navigazione. (Vedi Eseguire la migrazione dell'interfaccia utente ai layout adattabili e Supportare schermi di dimensioni diverse.)

Quando ogni singola destinazione gestisce correttamente gli eventi di ridimensionamento, le modifiche vengono isolate nell'interfaccia utente. Il resto dello stato dell'app, inclusa la navigazione, non è interessato.

La navigazione non deve essere un effetto collaterale di modifiche delle dimensioni della finestra. Non creare destinazioni di contenuti solo per adattarsi a finestre di dimensioni diverse. Ad esempio, non creare destinazioni di contenuti diverse per i diversi schermi di un dispositivo pieghevole.

La navigazione come effetto collaterale delle modifiche alle dimensioni delle finestre presenta i seguenti problemi:

  • La destinazione precedente (per le dimensioni della finestra precedente) potrebbe essere temporaneamente visibile prima di passare alla nuova destinazione
  • Per mantenere la reversibilità (ad esempio quando un dispositivo è piegato e aperto), la navigazione è necessaria per ogni dimensione della finestra
  • Mantenere lo stato dell'applicazione tra le destinazioni può essere difficile, poiché l'esplorazione può causare l'eliminazione dello stato all'apertura del backstack

Inoltre, la tua app potrebbe non essere nemmeno in primo piano mentre vengono apportate modifiche alle dimensioni della finestra. Il layout dell'app potrebbe richiedere più spazio rispetto all'app in primo piano e, quando l'utente torna nell'app, l'orientamento e le dimensioni della finestra potrebbero essere cambiati.

Se la tua app richiede destinazioni dei contenuti uniche in base alle dimensioni delle finestre, valuta la possibilità di combinare le destinazioni pertinenti in un'unica destinazione che includa layout alternativi.

Destinazioni dei contenuti con layout alternativi

Nell'ambito del design adattabile, una singola destinazione di navigazione può avere layout alternativi a seconda delle dimensioni della finestra dell'app. Ogni layout occupa l'intera finestra, ma vengono presentati layout diversi per finestre di dimensioni diverse.

Un esempio canonico è la visualizzazione dei dettagli elenco. Per le finestre di dimensioni ridotte, l'app mostra un layout di contenuti per l'elenco e uno per i dettagli. Se si accede alla destinazione della visualizzazione dei dettagli elenco, inizialmente viene visualizzato solo il layout dell'elenco. Quando viene selezionato un elemento dell'elenco, l'app visualizza il layout dei dettagli, sostituendo l'elenco. Quando viene selezionato il controllo posteriore, viene visualizzato il layout dell'elenco, che sostituisce i dettagli. Tuttavia, per le dimensioni delle finestre espanse , i layout dell'elenco e dei dettagli vengono visualizzati affiancati.

visualizzazioni

SlidingPaneLayout consente di creare un'unica destinazione di navigazione che mostra due riquadri dei contenuti affiancati su schermi di grandi dimensioni, ma un solo riquadro alla volta sui dispositivi con schermi di piccole dimensioni, come i telefoni.

<!-- 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>

Per maggiori dettagli sull'implementazione di un layout con dettagli elenco utilizzando SlidingPaneLayout, consulta la sezione Creare un layout a due riquadri.

Scrivi

In Compose, è possibile implementare una visualizzazione dei dettagli dell'elenco combinando elementi componibili alternativi in un'unica route che utilizza le classi di dimensioni delle finestre per emettere il componibile appropriato per ogni classe di dimensione.

Un percorso è il percorso di navigazione verso una destinazione di contenuto, che in genere è un singolo componibile, ma può anche essere componibile alternativi. La logica di business determina quale dei componenti componibili alternativi viene visualizzato. Il componibile riempie la finestra dell'app indipendentemente dall'alternativa visualizzata.

La visualizzazione dei dettagli elenco è costituita da tre elementi componibili, ad esempio:

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

Un singolo percorso di navigazione consente di accedere alla visualizzazione dei dettagli dell'elenco:

@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 (la destinazione della navigazione) determina quale dei tre componibili emettere: ListAndDetail per la dimensione della finestra espansa; ListOfItems o ItemDetail per compatto, a seconda che sia stato selezionato un elemento dell'elenco.

Il percorso è incluso in una NavHost, ad esempio:

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

Puoi fornire l'argomento isExpandedWindowSize esaminando WindowMetrics dell'app.

L'argomento selectedItemId può essere fornito da un ViewModel che mantiene lo stato in tutte le dimensioni delle finestre. Quando l'utente seleziona un articolo dall'elenco, la variabile di stato selectedItemId viene aggiornata:

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,
        /*...*/
      )
    }
  }
}

Il percorso include anche una BackHandler personalizzata quando il dettaglio dell'elemento componibile occupa l'intera finestra dell'app:

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 combinazione dello stato dell'app da un oggetto ViewModel con le informazioni sulla classe delle dimensioni della finestra rende la scelta dell'elemento componibile appropriato una questione di logica semplice. Se mantieni un flusso di dati unidirezionale, la tua app è in grado di utilizzare completamente lo spazio di visualizzazione disponibile, preservando lo stato dell'applicazione.

Per un'implementazione completa della visualizzazione dei dettagli dell'elenco in Compose, vedi l'esempio di JetNews su GitHub.

Un grafico di navigazione

Per offrire un'esperienza utente coerente su qualsiasi dispositivo o dimensione della finestra, utilizza un singolo grafico di navigazione in cui il layout di ogni destinazione di contenuti è reattivo.

Se utilizzi un grafico di navigazione diverso per ogni classe di dimensioni della finestra, ogni volta che l'app passa da una classe di dimensioni a un'altra, devi determinare la destinazione corrente dell'utente negli altri grafici, creare una pila posteriore e riconciliare le informazioni sullo stato diverse tra i grafici.

Host di navigazione nidificato

La tua app potrebbe includere una destinazione per i contenuti con destinazioni proprie. Ad esempio, in una visualizzazione dei dettagli elenco, il riquadro dei dettagli elemento potrebbe includere elementi UI che consentono di accedere ai contenuti in sostituzione dei dettagli elemento.

Per implementare questo tipo di navigazione secondaria, il riquadro dei dettagli può essere un host di navigazione nidificato con un proprio grafico di navigazione che specifica le destinazioni accessibili dal riquadro dei dettagli:

visualizzazioni

<!-- 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>

Scrivi

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

Questo è diverso da un grafico di navigazione nidificato, perché il grafico di navigazione del NavHost nidificato non è collegato al grafico di navigazione principale, ovvero non puoi passare direttamente dalle destinazioni di un grafico a quelle nell'altro.

Per maggiori informazioni, vedi Grafici di navigazione nidificati e Navigare con Compose.

Stato conservato

Per fornire destinazioni di contenuti adattabili, la tua app deve mantenere il suo stato quando il dispositivo viene ruotato o piegato o la finestra dell'app viene ridimensionata. Per impostazione predefinita, modifiche di configurazione come queste ricreano le attività, i frammenti, la gerarchia delle visualizzazioni e i componibili dell'app. Il modo consigliato per salvare lo stato dell'interfaccia utente è ViewModel o rememberSaveable, che rimangono invariati dopo le modifiche alla configurazione. (Vedi Salvare gli stati dell'interfaccia utente e State e Jetpack Compose.)

Le modifiche alle dimensioni devono essere reversibili, ad esempio quando l'utente ruota e poi ruota il dispositivo all'indietro.

I layout adattabili possono mostrare contenuti diversi con finestre di dimensioni diverse; pertanto, i layout adattabili spesso devono salvare uno stato aggiuntivo relativo ai contenuti, anche se lo stato non è applicabile alle dimensioni correnti della finestra. Ad esempio, un layout potrebbe avere lo spazio per mostrare un widget a scorrimento aggiuntivo solo in caso di finestre di larghezza superiore. Se a causa di un evento di ridimensionamento la larghezza della finestra diventa troppo ridotta, il widget è nascosto. Quando l'app viene ridimensionata alle dimensioni precedenti, il widget di scorrimento diventa di nuovo visibile e la posizione di scorrimento originale deve essere ripristinata.

Ambiti ViewModel

La guida per gli sviluppatori Migrazione al componente di navigazione consiglia un'architettura a attività singola in cui le destinazioni vengono implementate come frammenti e i relativi modelli di dati vengono implementati utilizzando ViewModel.

Un ViewModel ha sempre come ambito un ciclo di vita e, una volta terminato definitivamente, ViewModel viene cancellato e può essere eliminato. Il ciclo di vita di ViewModel, e quindi la portata della condivisione di ViewModel, dipende dal delegato della proprietà per ottenere ViewModel.

Nel caso più semplice, ogni destinazione di navigazione è un singolo frammento con uno stato di UI completamente isolato, quindi ogni frammento può utilizzare il delegato della proprietà viewModels() per ottenere un ambito ViewModel per quel frammento.

Per condividere lo stato dell'interfaccia utente tra i frammenti, limita l'ambito di ViewModel all'attività chiamando activityViewModels() nei frammenti (l'equivalente per l'attività è solo viewModels()). In questo modo l'attività e i frammenti collegati possono condividere l'istanza ViewModel. Tuttavia, in un'architettura a singola attività, questo ambito ViewModel dura in modo efficace quanto l'app, quindi ViewModel rimane in memoria anche se non è utilizzato da frammenti.

Supponiamo che il tuo grafico di navigazione abbia una sequenza di destinazioni di frammenti che rappresenta un flusso di pagamento e che lo stato attuale dell'intera esperienza di pagamento si trovi in un elemento ViewModel condiviso tra i frammenti. L'ambito dell'attività ViewModel non solo è troppo ampio, ma mette in pratica un altro problema: se l'utente segue il flusso di pagamento per un ordine e poi lo esegue di nuovo per un secondo ordine, entrambi gli ordini utilizzano la stessa istanza dell'elemento di pagamento ViewModel. Prima del pagamento del secondo ordine, dovrai cancellare manualmente i dati del primo ordine e eventuali errori potrebbero essere costosi per l'utente.

Limita ViewModel a un grafico di navigazione nell'attuale NavController. Crea un grafico di navigazione nidificato per incapsulare le destinazioni che fanno parte del flusso di pagamento. Quindi, in ciascuna di queste destinazioni dei frammenti, utilizza il delegato della proprietà navGraphViewModels() e trasmetti l'ID del grafico di navigazione per ottenere il valore ViewModel condiviso. In questo modo, quando l'utente esce dal flusso di pagamento e il grafico di navigazione nidificato non rientra nell'ambito, l'istanza corrispondente di ViewModel viene ignorata e non verrà utilizzata per il pagamento successivo.

Ambito Delegato alla proprietà È possibile condividere ViewModel con
Frammento Fragment.viewModels() Solo frammento attuale
Attività Activity.viewModels()

Fragment.activityViewModels()

L'attività e tutti i frammenti associati
Grafico di navigazione Fragment.navGraphViewModels() Tutti i frammenti nello stesso grafico di navigazione

Tieni presente che se utilizzi un host di navigazione nidificato (vedi sopra), le destinazioni in quell'host non possono condividere ViewModel con destinazioni all'esterno dell'host quando utilizzi navGraphViewModels() perché i grafici non sono collegati. In questo caso, puoi utilizzare l'ambito dell'attività.

Stato sollevato

In Scrivi, puoi mantenere lo stato durante le modifiche alle dimensioni delle finestre con l'alzata dello stato. Estendendo lo stato dei componibili in una posizione più in alto nell'albero delle composizioni, è possibile conservarlo anche quando i componibili non sono più visibili.

Nella sezione Scrivi di Destinazioni contenuti con layout alternativi in alto, abbiamo aumentato lo stato dei componibili della visualizzazione dei dettagli dell'elenco a ListDetailRoute, in modo che lo stato venga mantenuto a prescindere dal componibile visualizzato:

@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?,
) { /*...*/ }

Risorse aggiuntive