Nawigacja w elastycznych interfejsach użytkownika

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

Elastyczne interfejsy użytkownika zapewniają elastyczne miejsca docelowe treści i często zawierają różne typy elementów nawigacyjnych w odpowiedzi na zmianę rozmiaru interfejsu. Mogą to być np. dolny pasek nawigacyjny na małych wyświetlaczach, kolejka na średnich ekranach lub stały panel nawigacji na dużych ekranach. Elastyczne interfejsy powinny być jednak zgodne z zasadami nawigacji.

Komponent Nawigacja Jetpack implementuje zasady nawigacji i umożliwia tworzenie aplikacji z elastycznym interfejsem użytkownika.

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

Elastyczne poruszanie się po interfejsie

Rozmiar okna wyświetlacza zajmowanego przez aplikację wpływa na ergonomię i łatwość obsługi. Klasy rozmiaru okna umożliwiają określenie odpowiednich elementów nawigacyjnych (np. paski nawigacyjne, prowadnice czy szuflady) i umieszczanie ich w miejscach, w których są one najbardziej dostępne dla użytkownika. Zgodnie ze wskazówkami dotyczącymi układu Material Design elementy nawigacyjne zajmują stałą przestrzeń na wiodącej krawędzi wyświetlacza i gdy aplikacja jest niewielka, mogą się przenieść do dolnej krawędzi. Wybór elementów nawigacyjnych zależy w dużej mierze od rozmiaru okna aplikacji i liczby elementów, które muszą się w nim znajdować.

Klasa rozmiaru okna Kilka produktów Wiele elementów
kompaktowa szerokość dolny pasek nawigacyjny panel nawigacji (na początku lub na dole)
średnia szerokość kolumna nawigacji panel nawigacji (najwyższa krawędź)
szerokość po rozwinięciu kolumna nawigacji szuflada trwała nawigacji (od początku)

W układach opartych na widokach pliki zasobów układu mogą być kwalifikowane według punktów przerwania klasy rozmiaru okna, co pozwala używać różnych elementów nawigacyjnych dla różnych wymiarów wyświetlania. Jetpack Compose może korzystać z punktów przerwania udostępnianych przez interfejs API Window size, by automatycznie określić element nawigacji najlepiej pasujący do okna aplikacji.

wyświetleń

<!-- 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 elastycznych interfejsach układ każdego miejsca docelowego treści musi dostosowywać się do zmian rozmiaru okna. Aplikacja może dostosowywać odstępy w układzie, przesuwać elementy, dodawać i usuwać treści oraz zmieniać elementy interfejsu, w tym elementy nawigacyjne. (Przeczytaj artykuły Migracja do układów elastycznych i Obsługa różnych rozmiarów ekranu).

Gdy każde miejsce docelowe płynnie obsługuje zdarzenia zmiany rozmiaru, zmiany są izolowane w interfejsie. Nie ma to wpływu na pozostały stan aplikacji, w tym na nawigację.

Nawigacja nie powinna być efektem ubocznym zmian rozmiaru okna. Nie twórz miejsc docelowych treści tylko po to, by dopasować je do różnych rozmiarów okien. Nie twórz na przykład różnych miejsc docelowych treści na różne ekrany na urządzeniu składanym.

Korzystanie z nawigacji jako efekt uboczny zmian rozmiaru okna wiąże się z następującymi problemami:

  • Stary cel podróży (dla poprzedniego rozmiaru okna) może być widoczny na chwilę, zanim przejdziesz do nowego miejsca docelowego
  • Aby można było odwracać uwagę (na przykład po złożeniu i rozłożeniu urządzenia), w przypadku każdego rozmiaru okna wymagana jest nawigacja
  • Utrzymywanie stanu aplikacji między miejscami docelowymi może być trudne, ponieważ nawigacja może zniszczyć stan aplikacji po kliknięciu stosu backendowego

Poza tym aplikacja może nie znajdować się na pierwszym planie, gdy trwa zmiana rozmiaru okna. Układ aplikacji może wymagać więcej miejsca niż aplikacja na pierwszym planie, a gdy użytkownik wróci do aplikacji, orientacja i rozmiar okna mogą ulec zmianie.

Jeśli Twoja aplikacja wymaga unikalnych miejsc docelowych treści zależnie od rozmiaru okna, rozważ połączenie ich w jednym miejscu docelowym z alternatywnym układem.

Miejsca docelowe treści z układami alternatywnymi

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

Przykładem kanonicznym jest widok szczegółów listy. W małych oknach aplikacja wyświetla jeden układ treści dla listy i jeden dla szczegółów. Po przejściu do miejsca docelowego w widoku szczegółów listy początkowo wyświetlany jest tylko układ listy. Po wybraniu elementu listy aplikacja wyświetla układ szczegółów zamiast listy. Po wybraniu elementu sterującego Wstecz zostanie wyświetlony układ listy, który zastąpi szczegół. Jednak w przypadku rozszerzonych rozmiarów okien układy listy i szczegółów są wyświetlane obok siebie.

wyświetleń

SlidingPaneLayout pozwala utworzyć jedno miejsce docelowe nawigacji, które na dużych ekranach, np. na telefonach, wyświetla 2 panele treści obok siebie, ale tylko jeden panel naraz na urządzeniach z małym ekranem, np. na telefonach.

<!-- 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 znajdziesz w artykule Tworzenie układu z dwoma panelami, aby dowiedzieć się więcej o implementowaniu układu z informacjami na liście za pomocą funkcji SlidingPaneLayout.

Utwórz

W funkcji Compose można wdrożyć widok szczegółów listy, łącząc alternatywne elementy kompozycyjne w jedną trasę, która używa klas rozmiaru okna do wysyłania odpowiedniej funkcji kompozycyjnej 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 elementem kompozycyjnym. Logika biznesowa określa, który z alternatywnych elementów kompozycyjnych jest wyświetlany. 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 elementów kompozycyjnych, 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 nawigacji zapewnia dostęp do widoku szczegółów 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óry z 3 komponentów kompozycyjnych ma być emitowany: ListAndDetail oznacza rozmiar rozwiniętego okna lub ListOfItems lub ItemDetail w przypadku kompaktowego (w zależności od tego, czy został wybrany element listy).

Trasa jest uwzględniona w elemencie NavHost, na przykład:

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

Argument isExpandedWindowSize możesz podać, korzystając z parametru WindowMetrics swojej aplikacji.

Argument selectedItemId może być dostarczony przez funkcję ViewModel, która utrzymuje stan wszystkich rozmiarów 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 kompozycyjna 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,
        /*...*/
      )
    }
  }
}

Łączenie stanu aplikacji z ViewModel z informacjami o klasie rozmiaru okna sprawia, że wybór odpowiedniej funkcji kompozycyjnej jest skomplikowaną logiką. Dzięki utrzymaniu jednokierunkowego przepływu danych aplikacja może w pełni wykorzystywać dostępną przestrzeń wyświetlania przy zachowaniu stanu aplikacji.

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

1 wykres nawigacji

Aby zapewnić spójne wrażenia użytkownika na każdym urządzeniu i w każdej wielkości okna, użyj pojedynczego wykresu nawigacyjnego, w którym układ poszczególnych miejsc docelowych z treściami jest elastyczny.

Jeśli dla każdej klasy rozmiaru okna używasz innego wykresu nawigacyjnego, 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ć tylny stos i uzgodnić informacje o stanie różniące się między wykresami.

Zagnieżdżony host nawigacji

Twoja 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 okienko szczegółów elementu może zawierać elementy interfejsu, które prowadzą do treści zastępującej szczegóły elementu.

Aby wdrożyć tego rodzaju nawigację podrzędną, panel szczegółów może być hostem zagnieżdżonej nawigacji z własnym wykresem nawigacji, który określa miejsca docelowe dostępne z poziomu panelu szczegółów:

wyświetleń

<!-- 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ę to od zagnieżdżonego wykresu nawigacyjnego, ponieważ wykres nawigacji w zagnieżdżonych elementach NavHost nie jest połączony z głównym wykresem nawigacyjnym. Oznacza to, że nie można nawigować bezpośrednio z miejsc docelowych na jednym wykresie do miejsc docelowych na drugim.

Więcej informacji znajdziesz w artykułach Zagnieżdżone wykresy nawigacyjne i Nawigacja podczas tworzenia wiadomości.

Zachowany stan

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

Zmiany rozmiaru powinny być odwracalne – na przykład gdy użytkownik obróci urządzenie, a potem ponownie je obróci.

Układy elastyczne mogą wyświetlać różne fragmenty treści w oknach o różnych rozmiarach. Dlatego układy elastyczne często muszą zapisywać dodatkowy stan związany z treścią, nawet jeśli nie odnoszą się one do bieżącego rozmiaru okna. Na przykład w układzie może być miejsce na dodatkowy widżet przewijania tylko przy większych szerokościach okna. Jeśli zdarzenie zmiany rozmiaru spowoduje zbyt małą szerokość okna, widżet zostanie ukryty. Gdy aplikacja zmieni rozmiar do poprzednich wymiarów, widżet przewijania staje się ponownie widoczny i powinna zostać przywrócona pierwotna pozycja przewijania.

Zakresy ViewModel

W przewodniku dla programistów dotyczącym komponentu Migracja do nawigacji zaleca się architekturę z pojedynczą aktywnością, w której miejsca docelowe są zaimplementowane jako fragmenty, a ich modele danych są zaimplementowane za pomocą ViewModel.

Pole ViewModel jest zawsze ograniczone do cyklu życia. Gdy cykl życia zakończy się na stałe, obiekt ViewModel zostanie wyczyszczony i można go odrzucić. Cykl życia, do którego jest ograniczony zakres ViewModel, a tym samym, w jakim zakresie można udostępnić obiekt ViewModel, zależy od tego, do której usługi został użyty delegat usługi, aby uzyskać ViewModel.

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

Aby udostępniać stan interfejsu między fragmentami, określ zakres ViewModel w działaniu, wywołując we fragmentach activityViewModels() (odpowiednik aktywności to po prostu viewModels()). Dzięki temu aktywność i dołączone do niej fragmenty mogą wspólnie korzystać z instancji ViewModel. Jednak w architekturze z pojedynczym działaniem zakres ViewModel działa tak długo, jak długo ma zastosowanie aplikacja, więc obiekt ViewModel pozostaje w pamięci, nawet jeśli żadne fragmenty go nie używają.

Załóżmy, że wykres nawigacyjny zawiera sekwencję miejsc docelowych zawierających fragmenty reprezentujące 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 tych fragmentów. Zakres pola ViewModel w aktywności jest nie tylko zbyt szeroki, ale w rzeczywistości naraża też inny problem: jeśli użytkownik zrealizuje proces płatności w przypadku jednego zamówienia, a potem powtórzy ten proces w przypadku drugiego zamówienia, w obu z nich zostanie użyte to samo wystąpienie elementu ViewModel. Przed dokonaniem drugiego zamówienia musisz ręcznie usunąć dane z pierwszego zamówienia, a wszelkie błędy mogą być bardzo kosztowne dla użytkownika.

Zamiast tego ogranicz zakres ViewModel do wykresu nawigacyjnego w bieżącym narzędziu NavController. Utwórz zagnieżdżony wykres nawigacyjny, aby uwzględnić miejsca docelowe, które są częścią procesu płatności. Następnie w każdym z tych miejsc docelowych fragmentów użyj delegacji właściwości navGraphViewModels() i przekaż identyfikator wykresu nawigacyjnego, aby uzyskać wspólną wartość ViewModel. Dzięki temu gdy użytkownik zamknie proces płatności, a zagnieżdżony wykres nawigacyjny przestanie działać, odpowiednie wystąpienie ViewModel zostanie odrzucone i nie zostanie użyte przy kolejnych płatnościach.

Zakres Osoba, której przekazano dostęp do usługi ViewModel może udostępnić użytkownikowi
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ć ViewModel miejscom docelowym poza hostem, gdy korzystasz z navGraphViewModels(), ponieważ wykresy nie są połączone. W takim przypadku możesz zamiast tego użyć zakresu aktywności.

Stan podniesiony

Za pomocą przenoszenia stanów w funkcji tworzenia wiadomości możesz zachować stan podczas zmiany rozmiaru okien. Dzięki przeniesieniu stanu funkcji kompozycyjnych na pozycję wyżej w drzewie kompozycji stan może zostać zachowany nawet wtedy, gdy elementy kompozycyjne nie są już widoczne.

W sekcji Tworzenie w miejscach docelowych treści z alternatywnymi układami powyżej przenieśliśmy stan elementów kompozycyjnych widoku szczegółów listy do ListDetailRoute, dzięki czemu jest on zachowywany niezależnie od tego, która funkcja kompozycyjna jest wyświetlana:

@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