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