Navigazione per UI adattabili

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

Le interfacce utente adattabili forniscono destinazioni dei contenuti adattabili e spesso includono diversi tipi di elementi di navigazione in risposta ai cambiamenti delle dimensioni del display, ad esempio una barra di navigazione in basso sui display di piccole dimensioni, una barra di navigazione sui display di dimensioni medie o un riquadro di navigazione a scomparsa permanente sui display di grandi dimensioni. Tuttavia, le UI adattabili devono comunque rispettare i principi della navigazione.

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

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

Navigazione nell'interfaccia utente adattabile

Le dimensioni della finestra di visualizzazione occupata da un'app influiscono 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 persistente sul bordo anteriore del display e possono essere spostati fino al 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 guida di navigazione riquadro di navigazione a scomparsa (bordo superiore)
larghezza espansa guida di navigazione riquadro a scomparsa di navigazione permanente (bordo superiore)

Nei layout basati sulle visualizzazioni, i file di risorse di layout possono essere qualificati da 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 della classe dimensioni finestra 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 dei contenuti deve adattarsi alle modifiche delle dimensioni della finestra. La tua app può regolare la spaziatura del layout, riposizionare gli elementi, aggiungere o rimuovere contenuti oppure modificare gli elementi dell'interfaccia utente, inclusi gli elementi di navigazione. Vedi Eseguire la migrazione dell'interfaccia utente ai layout adattabili e Creare layout adattivi.

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

La navigazione non deve essere considerata un effetto collaterale delle modifiche alle dimensioni delle finestre. Non creare destinazioni dei contenuti solo per dimensioni di finestre diverse. Ad esempio, non creare destinazioni dei contenuti diverse per i vari 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 della navigazione verso la nuova destinazione
  • Per mantenere la reversibilità (ad esempio, quando un dispositivo è piegato e aperto), è necessaria la navigazione per ogni dimensione della finestra
  • Mantenere lo stato dell'applicazione tra le destinazioni può essere difficile, poiché la navigazione può distruggere lo stato quando si apre il backstack.

Inoltre, l'app potrebbe non essere nemmeno in primo piano mentre sono in corso le 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 di un 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 elenco-dettagli. Per le finestre di dimensioni ridotte, l'app mostra un layout dei contenuti per l'elenco e uno per i dettagli. Se si passa alla destinazione della visualizzazione dei dettagli elenco, all'inizio viene mostrato solo il layout elenco. Quando viene selezionato un elemento dell'elenco, la tua app mostra il layout dei dettagli, che sostituisce l'elenco. Quando viene selezionato il controllo Indietro, viene visualizzato il layout elenco, che sostituisce i dettagli. Tuttavia, per le dimensioni delle finestre espanse , i layout elenco e dettagliato vengono visualizzati affiancati.

Visualizzazioni

SlidingPaneLayout consente di creare un'unica destinazione di navigazione che mostra due riquadri di contenuti affiancati su schermi di grandi dimensioni, ma un solo riquadro alla volta sui dispositivi con schermi piccoli, ad esempio 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 Creare un layout a due riquadri.

Scrivi

In Compose, è possibile implementare una visualizzazione dei dettagli dell'elenco combinando componenti componibili alternativi in un'unica route che utilizza le classi di dimensione 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 componibili alternativi. La logica di business determina quale dei componenti componibili alternativi viene visualizzato. L'oggetto componibile riempie la finestra dell'app indipendentemente dall'alternativa visualizzata.

La visualizzazione elenco-dettaglio è 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 elenco-dettaglio:

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

La route è inclusa 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 elemento ViewModel che mantiene lo stato in tutte le dimensioni della finestra. Quando l'utente seleziona un elemento 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,
        /*...*/
      )
    }
  }
}

La route include anche una BackHandler personalizzata quando i dettagli dell'elemento componibili occupano 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 ViewModel con le informazioni sulla classe delle dimensioni della finestra rende la scelta della componibile appropriata 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 di finestra, utilizza un singolo grafico di navigazione in cui il layout di ogni destinazione di contenuti è adattabile.

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 nei grafici.

Host di navigazione nidificato

La tua app potrebbe includere una destinazione di contenuti con destinazioni dei contenuti proprie. Ad esempio, in una visualizzazione dei dettagli elenco, il riquadro dei dettagli elemento potrebbe includere elementi dell'interfaccia utente che consentono di accedere a contenuti che sostituiscono i dettagli dell'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(...) }
    }
}

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

Per ulteriori informazioni, vedi Grafici di navigazione nidificati e Navigazione con Scrivi.

Stato conservato

Per fornire destinazioni dei contenuti adattabili, la tua app deve conservare il proprio stato quando il dispositivo viene ruotato o piegato oppure la finestra dell'app viene ridimensionata. Per impostazione predefinita, le modifiche alla 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 sopravvivono a tutte 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 il dispositivo e poi lo ruota di nuovo.

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 spazio per mostrare un widget a scorrimento aggiuntivo solo con finestre di larghezza maggiore. Se a causa di un evento di ridimensionamento la larghezza della finestra diventa troppo ridotta, il widget viene nascosto. Quando l'app viene ridimensionata alle dimensioni precedenti, il widget di scorrimento diventa di nuovo visibile e la posizione di scorrimento originale dovrebbe essere ripristinata.

Ambiti ViewModel

La guida per gli sviluppatori del componente Migrazione al componente Navigazione consiglia un'architettura a attività singola in cui le destinazioni vengono implementate come frammenti e i relativi modelli dei 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 a cui è limitato l'ambito ViewModel, e quindi la portata della condivisione di ViewModel, dipende dal delegato della proprietà utilizzato per ottenere ViewModel.

Nel caso più semplice, ogni destinazione di navigazione è un singolo frammento con uno stato dell'interfaccia utente completamente isolato, quindi ogni frammento può utilizzare il delegato della proprietà viewModels() per ottenere un ambito ViewModel su 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 tutti i frammenti collegati possono condividere l'istanza ViewModel. Tuttavia, in un'architettura ad attività singola, questo ambito ViewModel dura in modo efficace quanto l'app, quindi ViewModel rimane in memoria anche se non viene utilizzato da frammenti.

Supponiamo che il tuo grafico di navigazione abbia una sequenza di destinazioni dei frammenti che rappresentano un flusso di pagamento e che lo stato attuale dell'intera esperienza di pagamento si trovi in un valore ViewModel condiviso tra i frammenti. L'ambito dell'attività ViewModel non solo è troppo ampio, ma presenta anche un altro problema: se l'utente segue il flusso di pagamento per un ordine e poi lo ripete per un secondo ordine, entrambi gli ordini utilizzano la stessa istanza del ViewModel di pagamento. Prima del pagamento del secondo ordine, dovrai cancellare manualmente i dati del primo ordine e qualsiasi errore potrebbe essere costoso per l'utente.

Limita invece l'ambito di ViewModel a un grafico di navigazione nell'attuale NavController. Crea un grafico di navigazione nidificato per includere 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 dell'ambito, l'istanza corrispondente di ViewModel viene ignorata e non verrà utilizzata per il pagamento successivo.

Ambito Delegato per la proprietà Puoi 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 al di fuori 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'installazione di stato. Sollevando lo stato dei componibili a una posizione più in alto nell'albero di composizione, è possibile mantenere lo stato anche quando i componibili non sono più visibili.

Nella sezione Scrivi di Destinazioni dei contenuti con layout alternativi in alto, abbiamo aumentato lo stato dei componibili della visualizzazione elenco-dettaglio a ListDetailRoute, in modo che lo stato venga conservato indipendentemente dalla composizione componibile visualizzata:

@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