Nawigacja w elastycznych interfejsach użytkownika

Nawigacja to proces interakcji z interfejsem aplikacji w celu uzyskania dostępu do docelowych treści w aplikacji. Zasady nawigacji na Androidzie zawierają wskazówki, które pomogą Ci utworzyć spójną i intuicyjną nawigację po aplikacji.

Elastyczne interfejsy UI zapewniają elastyczne miejsca docelowe treści i często zawierają różne typy elementów nawigacyjnych w odpowiedzi na zmiany rozmiaru wyświetlacza – np. dolny pasek nawigacyjny na małych ekranach, pasek nawigacyjny na średnich ekranach czy stały panel nawigacji na dużych ekranach – ale responsywny interfejs użytkownika powinien być jednak zgodny z zasadami nawigacji.

Komponent Nawigacja Jetpack obsługuje zasady nawigacji i może ułatwiać tworzenie aplikacji z elastycznym interfejsem użytkownika.

Rysunek 1. Rozwinięte, średnie i kompaktowe ekrany z szufladą nawigacji, suwakiem i dolnym paskiem.

Nawigacja w elastycznym interfejsie

Rozmiar okna wyświetlacza zajmowanego przez aplikację wpływa na ergonomię i łatwość obsługi. Klasy rozmiaru okna pozwalają określić odpowiednie elementy nawigacyjne (takie jak paski nawigacyjne, balony czy szuflady) i umieszczać je w miejscach, w których są najbardziej dostępne dla użytkownika. Zgodnie z wytycznymi dotyczącymi układu Material Design elementy nawigacyjne zajmują stałą przestrzeń przy górnej krawędzi ekranu i mogą przenieść się do dolnej krawędzi, gdy aplikacja jest mała. Wybór elementów nawigacyjnych zależy w dużej mierze od rozmiaru okna aplikacji i liczby elementów, które musi zawierać element.

Klasa rozmiaru okna Mało elementów Wiele elementów
mała szerokość dolny pasek nawigacyjny panel nawigacji (na początku lub na dole)
średnia szerokość linia nawigacji panel nawigacji (na początku)
szerokość po rozwinięciu linia nawigacji panel trwałej nawigacji (najnowsza krawędź)

W układach opartych na widokach pliki zasobów układu można zakwalifikować według punktów przerwania klasy rozmiaru okna, aby używać różnych elementów nawigacyjnych dla różnych wymiarów wyświetlania. Jetpack Compose może używać punktów przerwania dostarczanych przez interfejs API klasy rozmiaru okna do programowego określania elementu nawigacji, który najlepiej pasuje do okna aplikacji.

Wyświetlenia

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

Utwórz

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

Miejsca docelowe treści elastycznych

W elastycznym interfejsie układ każdego miejsca docelowego treści musi się dostosowywać do zmian rozmiaru okna. Aplikacja może dostosowywać odstępy w układzie, zmieniać położenie elementów, dodawać i usuwać treści oraz zmieniać elementy interfejsu, w tym elementy nawigacyjne. (zobacz Przenoszenie interfejsu użytkownika do układów elastycznych i Tworzenie układów adaptacyjnych).

Gdy każde miejsce docelowe płynnie obsługuje zdarzenia zmiany rozmiaru, zmiany są wyodrębniane w interfejsie. Nie ma to wpływu na pozostałą część stanu aplikacji, w tym nawigację.

Nawigacja nie powinna być efektem ubocznym zmiany rozmiaru okna. Nie twórz miejsc docelowych treści tylko na potrzeby różnych rozmiarów okien. Nie twórz na przykład różnych miejsc docelowych treści dla różnych ekranów urządzenia składanego.

Nawigacja jako efekt uboczny zmiany rozmiaru okna wykazuje te problemy:

  • Stare miejsce docelowe (dla poprzedniego rozmiaru okna) może być przez chwilę widoczne przed przejściem do nowego miejsca docelowego.
  • Aby można było je łatwo wrócić (np. gdy urządzenie jest złożone i rozłożone), nawigacja jest wymagana dla każdego rozmiaru okna
  • Utrzymywanie stanu aplikacji między miejscami docelowymi może być trudne, ponieważ nawigacja może zniszczyć stan po wystrzeliwaniu warstwy tylnej

W trakcie zmiany rozmiaru okna aplikacja może nawet nie znajdować się na pierwszym planie. Układ aplikacji może wymagać więcej miejsca niż aplikacja na pierwszym planie, a gdy użytkownik wraca do aplikacji, orientacja i rozmiar okna mogą ulec zmianie.

Jeśli aplikacja wymaga unikalnych miejsc docelowych treści na podstawie rozmiaru okna, rozważ połączenie odpowiednich miejsc docelowych w jedno miejsce docelowe obejmujące alternatywne układy.

Miejsca docelowe treści z alternatywnymi układami

W ramach elastycznego projektowania stron pojedyncze miejsce docelowe nawigacji może mieć alternatywne układy zależnie od rozmiaru okna aplikacji. Każdy układ zajmuje całe okno, ale różne układy są wyświetlane dla różnych rozmiarów okna.

Przykładem strony kanonicznej jest widok szczegółów listy. W przypadku małych okien aplikacja wyświetla 1 układ treści dla listy i 1 dla szczegółów. Przejście do miejsca docelowego widoku szczegółów listy początkowo wyświetla tylko układ listy. Po wybraniu elementu listy aplikacja wyświetla układ szczegółowy, który zastępuje listę. Po wybraniu opcji cofania pojawi się układ listy, zastępując szczegóły. Jednak w przypadku rozmiaru rozwiniętego okna układy list i szczegółów są wyświetlane obok siebie.

Wyświetlenia

SlidingPaneLayout umożliwia utworzenie jednego obszaru nawigacji, w którym na dużych ekranach wyświetlają się obok siebie 2 panele treści, ale na urządzeniach z małym ekranem, takich jak telefony, tylko jeden panel naraz.

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

Szczegółowe informacje o implementowaniu układu ze szczegółami listy za pomocą SlidingPaneLayout znajdziesz w artykule Tworzenie układu z 2 panelami.

Utwórz

W widoku szczegółów listy można wdrożyć widok szczegółów listy, łącząc alternatywne elementy kompozycyjne w jednej trasie, która używa klas rozmiaru okna, aby emitować odpowiedni element kompozycyjny dla każdej klasy rozmiaru.

Trasa to ścieżka nawigacyjna do miejsca docelowego treści, które zwykle jest pojedynczym elementem kompozycyjnym, ale może też być alternatywnym obiektem kompozycyjnym. Logika biznesowa określa, które z alternatywnych funkcji kompozycyjnych są wyświetlane. Funkcja kompozycyjna wypełnia okno aplikacji niezależnie od tego, która opcja jest wyświetlana.

Widok szczegółów listy składa się z 3 komponentów, na przykład:

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

Pojedyncza trasa nawigacyjna zapewnia dostęp do widoku szczegółowego listy:

@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 (miejsce docelowe nawigacji) określa, które z trzech elementów kompozycyjnych mają być emitowane: ListAndDetail oznacza rozmiar rozwiniętego okna; ListOfItems lub ItemDetail – kompaktowy, w zależności od tego, czy został wybrany element listy.

Trasa znajduje się w elemencie NavHost, na przykład:

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

Argument isExpandedWindowSize możesz podać, analizując parametr WindowMetrics aplikacji.

Argument selectedItemId może być dostarczany przez parametr ViewModel, który zachowuje stan we wszystkich rozmiarach okien. Gdy użytkownik wybierze element z listy, zmienna stanu selectedItemId zostanie zaktualizowana:

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

Trasa zawiera też niestandardowy element BackHandler, gdy funkcja szczegółów elementu zajmuje całe okno aplikacji:

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

Połączenie stanu aplikacji z ViewModel z informacjami o klasie rozmiaru okna sprawia, że wybór odpowiedniego elementu kompozycyjnego to kwestia prostej logiki. Dzięki utrzymywaniu jednokierunkowego przepływu danych aplikacja może w pełni wykorzystywać dostępną przestrzeń reklamową, zachowując przy tym jej stan.

Pełną implementację widoku listy w funkcji Compose znajdziesz w przykładzie JetNews na GitHubie.

1 wykres nawigacyjny

Aby zapewnić spójne wrażenia użytkowników niezależnie od rozmiaru okna i urządzenia, użyj pojedynczego wykresu nawigacyjnego, w którym układ każdego miejsca docelowego treści jest elastyczny.

Jeśli używasz innego wykresu nawigacyjnego dla każdej klasy rozmiaru okna, za każdym razem, gdy aplikacja przechodzi z jednej klasy rozmiaru do drugiej, musisz określić aktualne miejsce docelowe użytkownika na innych wykresach, utworzyć stos wsteczny i uzgodnić informacje o stanie różniące się na poszczególnych wykresach.

Host zagnieżdżonej nawigacji

Aplikacja może zawierać miejsce docelowe treści, które ma własne miejsca docelowe treści. Na przykład w widoku szczegółów listy panel szczegółów elementu może zawierać elementy interfejsu prowadzące do treści, która zastępuje szczegóły elementu.

Aby skorzystać z tego rodzaju nawigacji podrzędnej, panel szczegółów może być zagnieżdżonym hostem nawigacji z własnym wykresem nawigacyjnym, który określa miejsca docelowe, do których uzyskano dostęp z panelu szczegółów:

Wyświetlenia

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

Utwórz

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

Różni się on od zagnieżdżonego wykresu nawigacyjnego, ponieważ wykres nawigacyjny zagnieżdżonych obiektów NavHost nie jest połączony z głównym wykresem nawigacyjnym. Oznacza to, że nie można bezpośrednio przechodzić z miejsc docelowych na jednym wykresie do miejsc docelowych na drugim.

Więcej informacji znajdziesz w artykułach Wykresy nawigacyjne zagnieżdżone i Nawigacja przy użyciu tworzenia wiadomości.

Zachowany stan

Aby miejsca docelowe treści elastycznych były dostępne, aplikacja musi zachowywać swój stan po obróceniu lub złożeniu urządzenia albo zmianie rozmiaru okna aplikacji. Domyślnie zmiany w konfiguracji, takie jak te, odtwarzają działania, fragmenty, hierarchię widoków i elementy kompozycyjne aplikacji. Zalecanym sposobem zapisywania stanu interfejsu jest użycie atrybutów ViewModel lub rememberSaveable, które zachowują ważność nawet po wprowadzeniu zmian w konfiguracji. Zobacz Zapisywanie stanów interfejsu użytkownika oraz Stan i Jtpack Compose.

Zmiana rozmiaru powinna być odwracalna, np. gdy użytkownik obróci urządzenie, a potem obróci je z powrotem.

Układy elastyczne mogą wyświetlać różne fragmenty treści w różnych rozmiarach okna. Dlatego też układy elastyczne muszą zapisywać dodatkowy stan związany z treścią, nawet jeśli nie ma on zastosowania do bieżącego rozmiaru okna. Na przykład układ może zawierać miejsce na dodatkowy widżet przewijania tylko przy większej szerokości okna. Jeśli zdarzenie zmiany rozmiaru powoduje zbyt małą szerokość okna, widżet zostaje ukryty. Gdy aplikacja zmieni rozmiar do poprzednich wymiarów, widżet przewijania stanie się znów widoczny i powinna zostać przywrócona pierwotna pozycja przewijania.

Zakresy ViewModel

Przewodnik dla programistów Migracja do komponentu Nawigacja zaleca architekturę jednodziałania, w której miejsca docelowe są zaimplementowane jako fragmenty, a ich modele danych są zaimplementowane za pomocą ViewModel.

Element ViewModel jest zawsze ograniczony do cyklu życia, a gdy ten cykl życia zakończy się trwale, ViewModel jest wyczyszczony i można go odrzucić. Cykl życia, do którego jest ograniczony element ViewModel, a tym samym zakres udostępniania elementu ViewModel, zależy od tego, który delegat usługi posłuży do uzyskania ViewModel.

W najprostszym przypadku każde miejsce docelowe nawigacji jest pojedynczym fragmentem z całkowicie odizolowanym stanem UI, więc każdy fragment może użyć delegata właściwości viewModels(), aby uzyskać ViewModel ograniczony do tego fragmentu.

Aby udostępnić stan interfejsu między fragmentami, ustaw zakres ViewModel na aktywność, wywołując activityViewModels() we fragmentach (odpowiednik aktywności to tylko viewModels()). Dzięki temu aktywność i wszystkie dołączone do niej fragmenty mogą udostępniać instancję ViewModel. Jednak w architekturze z pojedynczą aktywnością ten zakres ViewModel działa skutecznie tak długo, jak długo działa aplikacja, więc ViewModel pozostaje w pamięci, nawet jeśli nie są z niego używane żadne fragmenty.

Załóżmy, że na wykresie nawigacyjnym znajduje się sekwencja miejsc docelowych fragmentów reprezentujących proces płatności, a bieżący stan całego procesu płatności znajduje się w elemencie ViewModel, który jest wspólny dla fragmentów. Zakres działania funkcji ViewModel jest nie tylko zbyt szeroki, ale w rzeczywistości stwarza kolejny problem: jeśli użytkownik przechodzi przez proces płatności w przypadku jednego zamówienia, a potem ponownie przez niego w przypadku drugiego zamówienia, oba zamówienia używają tego samego wystąpienia zdarzenia ViewModel płatności. Przed drugim zamówieniem musisz ręcznie wyczyścić dane pierwszego zamówienia, a wszelkie błędy mogą być kosztowne dla użytkownika.

Zamiast tego ustaw zakres ViewModel na wykres nawigacyjny w bieżącym NavController. Utwórz zagnieżdżony wykres nawigacyjny, aby uwzględnić miejsca docelowe, które wchodzą w skład procesu płatności. Następnie w każdym z tych miejsc docelowych fragmentów użyj delegata właściwości navGraphViewModels() i prześlij identyfikator wykresu nawigacyjnego, aby uzyskać udostępniony ViewModel. Dzięki temu, gdy użytkownik zamknie proces płatności, a zagnieżdżony wykres nawigacyjny znajdzie się poza zakresem, odpowiednie wystąpienie obiektu ViewModel zostanie odrzucone i nie zostanie użyte podczas kolejnej płatności.

Zakres Przekazywanie dostępu do usługi Można udostępnić ViewModel tym osobom
Fragment Fragment.viewModels() Tylko bieżący fragment
Aktywność Activity.viewModels()

Fragment.activityViewModels()

Aktywność i wszystkie dołączone do niej fragmenty
Wykres nawigacyjny Fragment.navGraphViewModels() Wszystkie fragmenty na tym samym wykresie nawigacyjnym

Pamiętaj, że jeśli używasz zagnieżdżonego hosta nawigacji (patrz wyżej), miejsca docelowe na tym hoście nie mogą udostępniać ViewModelmiejscom docelowym spoza hosta, gdy używasz navGraphViewModels(), ponieważ wykresy nie są połączone. W takim przypadku możesz użyć zakresu aktywności.

Stan podniesienia

W oknie tworzenia możesz zachować stan podczas zmiany rozmiaru okna za pomocą funkcji przenoszenia stanu. Przenosząc stan funkcji kompozycyjnych na wyższą pozycję w drzewie kompozycji, można zachować stan, nawet gdy elementy kompozycyjne nie są już widoczne.

W sekcji Utwórz w artykule Miejsca docelowe treści z alternatywnymi układami powyżej przenieśliśmy stan elementów kompozycyjnych widoku szczegółów listy do ListDetailRoute, aby stan był zachowywany niezależnie od tego, który element kompozycyjny jest wyświetlany:

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

Dodatkowe materiały