Навигация для адаптивных интерфейсов

Навигация — это процесс взаимодействия с пользовательским интерфейсом приложения для доступа к местам назначения контента приложения. Принципы навигации Android содержат рекомендации, которые помогут вам создать последовательную и интуитивно понятную навигацию по приложениям.

Адаптивные пользовательские интерфейсы обеспечивают адаптивные места назначения контента и часто включают в себя различные типы элементов навигации в ответ на изменения размера дисплея — например, нижнюю панель навигации на небольших дисплеях, направляющую навигации на дисплеях среднего размера или постоянный навигационный ящик на больших дисплеях. но адаптивные пользовательские интерфейсы по-прежнему должны соответствовать принципам навигации.

Компонент Jetpack Navigation реализует принципы навигации и может использоваться для облегчения разработки приложений с адаптивным пользовательским интерфейсом.

Рис. 1. Расширенный, средний и компактный дисплеи с навигационным ящиком, направляющей и нижней панелью.

Адаптивная навигация пользовательского интерфейса

Размер окна дисплея, занимаемого приложением, влияет на эргономику и удобство использования. Классы размеров окон позволяют определить подходящие элементы навигации (такие как панели навигации, направляющие или ящики) и разместить их там, где они наиболее доступны для пользователя. В рекомендациях по макетированию Material Design элементы навигации занимают постоянное место на передней кромке дисплея и могут перемещаться к нижнему краю, когда ширина приложения небольшая. Выбор элементов навигации во многом зависит от размера окна приложения и количества элементов, которые должен содержать элемент.

Класс размера окна Мало предметов Много предметов
компактная ширина нижняя панель навигации панель навигации (передний край или низ)
средняя ширина навигационный рельс навигационный ящик (передний край)
расширенная ширина навигационный рельс постоянный ящик навигации (передний край)

В макетах на основе представлений файлы ресурсов макета могут быть уточнены точками останова класса размера окна, чтобы использовать разные элементы навигации для разных размеров отображения. Jetpack Compose может использовать точки останова, предоставляемые API класса размера окна, для программного определения элемента навигации, наиболее подходящего для окна приложения.

Просмотры

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

Сочинить

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

Адаптивные места назначения контента

В адаптивном пользовательском интерфейсе макет каждого места назначения контента должен адаптироваться к изменениям размера окна. Ваше приложение может регулировать расстояние между макетами, перемещать элементы, добавлять или удалять контент или изменять элементы пользовательского интерфейса, включая элементы навигации. (См. разделы «Миграция пользовательского интерфейса на адаптивные макеты» и «Поддержка экранов разных размеров ».)

Когда каждый отдельный пункт назначения корректно обрабатывает события изменения размера, изменения изолируются от пользовательского интерфейса. Остальная часть состояния приложения, включая навигацию, не затрагивается.

Навигация не должна возникать как побочный эффект изменения размера окна. Не создавайте места назначения контента только для того, чтобы разместить окна разных размеров. Например, не создавайте разные места назначения контента для разных экранов складного устройства.

Навигация как побочный эффект изменения размера окна имеет следующие проблемы:

  • Старый пункт назначения (для предыдущего размера окна) может быть на мгновение виден перед переходом к новому пункту назначения.
  • Чтобы сохранить обратимость (например, когда устройство сложено и разложено), навигация необходима для каждого размера окна.
  • Поддержание состояния приложения между пунктами назначения может быть затруднено, поскольку навигация может разрушить состояние при извлечении обратного стека.

Кроме того, ваше приложение может даже не находиться на переднем плане, пока происходят изменения размера окна. Макет вашего приложения может потребовать больше места, чем приложение переднего плана, и когда пользователь возвращается в ваше приложение, ориентация и размер окна могут измениться.

Если вашему приложению требуются уникальные места назначения контента в зависимости от размера окна, рассмотрите возможность объединения соответствующих мест назначения в одно место назначения, включающее альтернативные макеты.

Места назначения контента с альтернативными макетами

В рамках адаптивного дизайна один пункт назначения навигации может иметь альтернативные макеты в зависимости от размера окна приложения. Каждый макет занимает все окно, но для разных размеров окон представлены разные макеты.

Каноническим примером является подробное представление списка . При небольших размерах окон ваше приложение отображает один макет контента для списка и один для подробностей. При переходе к месту назначения просмотра подробностей списка сначала отображается только макет списка. Когда элемент списка выбран, ваше приложение отображает подробный макет, заменяя список. При выборе заднего элемента управления отображается макет списка, заменяющий детали. Однако для расширенных размеров окна список и подробные макеты отображаются рядом.

Просмотры

SlidingPaneLayout позволяет создать единый пункт назначения навигации, который отображает две панели содержимого рядом на больших экранах, но только одну панель за раз на устройствах с маленькими экранами, таких как телефоны.

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

Дополнительные сведения о реализации макета со списком сведений с помощью SlidingPaneLayout см. в разделе Создание макета с двумя панелями .

Сочинить

В Compose представление списка подробностей может быть реализовано путем объединения альтернативных компонуемых объектов в один маршрут , который использует классы размеров окон для создания соответствующего компонуемого объекта для каждого класса размера.

Маршрут — это путь навигации к месту назначения контента, который обычно представляет собой один составной объект, но также может быть альтернативным составным объектом. Бизнес-логика определяет, какой из альтернативных составных элементов отображается. Составной объект заполняет окно приложения независимо от того, какая альтернатива отображается.

Подробное представление списка состоит из трех составных элементов, например:

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

Один маршрут навигации обеспечивает доступ к подробному представлению списка:

@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 (пункт назначения навигации) определяет, какой из трех компонуемых объектов выдавать: ListAndDetail для расширенного размера окна; ListOfItems или ItemDetail для компактности, в зависимости от того, выбран ли элемент списка.

Маршрут включен в NavHost , например:

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

Вы можете предоставить аргумент isExpandedWindowSize , изучив WindowMetrics вашего приложения.

Аргумент selectedItemId может быть предоставлен ViewModel , который поддерживает состояние для всех размеров окон. Когда пользователь выбирает элемент из списка, переменная состояния selectedItemId обновляется:

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

Маршрут также включает в себя пользовательский BackHandler , когда компонуемая деталь элемента занимает все окно приложения:

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

Объединение состояния приложения из ViewModel с информацией о классе размера окна делает выбор подходящего компонуемого объекта вопросом простой логики. Поддерживая однонаправленный поток данных , ваше приложение может полностью использовать доступное пространство дисплея, сохраняя при этом состояние приложения.

Полную реализацию представления подробностей списка в Compose см. в примере JetNews на GitHub.

Один навигационный график

Чтобы обеспечить единообразие взаимодействия с пользователем на любом устройстве и в любом размере окна, используйте единый граф навигации, в котором макет каждого места назначения контента будет адаптивным.

Если вы используете разные графы навигации для каждого класса размера окна, то всякий раз, когда приложение переходит из одного класса размера в другой, вам необходимо определить текущий пункт назначения пользователя в других графах, построить обратный стек и согласовать информацию о состоянии, которая различается между графики.

Вложенный навигационный узел

Ваше приложение может включать в себя пункт назначения контента, у которого есть собственные пункты назначения контента. Например, в представлении сведений о списке панель сведений об элементе может включать элементы пользовательского интерфейса, которые переходят к содержимому, которое заменяет сведения об элементе.

Чтобы реализовать этот вид дополнительной навигации, панель подробностей может быть вложенным навигационным узлом с собственным графом навигации, который определяет пункты назначения, к которым осуществляется доступ из панели подробностей:

Просмотры

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

Сочинить

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

Это отличается от вложенного графа навигации, поскольку граф навигации вложенного NavHost не связан с основным графом навигации; то есть вы не можете напрямую переходить от пунктов назначения на одном графике к пунктам назначения на другом.

Дополнительные сведения см. в разделах «Вложенные графики навигации» и «Навигация с помощью Compose» .

Сохраненное состояние

Чтобы обеспечить адаптивное расположение контента, ваше приложение должно сохранять свое состояние при повороте или складывании устройства или изменении размера окна приложения. По умолчанию такие изменения конфигурации воссоздают действия приложения, фрагменты, иерархию представлений и компонуемые объекты. Рекомендуемый способ сохранить состояние пользовательского интерфейса — использовать ViewModel или rememberSaveable , которые сохраняются при изменениях конфигурации. (См. Сохранение состояний пользовательского интерфейса и Создание состояния и Jetpack .)

Изменения размера должны быть обратимыми — например, когда пользователь поворачивает устройство, а затем поворачивает его обратно.

Адаптивные макеты могут отображать разные части контента в окнах разных размеров; поэтому адаптивным макетам часто необходимо сохранять дополнительное состояние, связанное с содержимым, даже если оно неприменимо к текущему размеру окна. Например, в макете может быть место для отображения дополнительного виджета прокрутки только при большей ширине окна. Если событие изменения размера приводит к тому, что ширина окна становится слишком маленькой, виджет скрывается. Когда приложение изменяет размер до прежних размеров, виджет прокрутки снова становится видимым, и исходное положение прокрутки должно быть восстановлено.

Области ViewModel

В руководстве разработчика компонента «Миграция на навигацию» рекомендуется использовать архитектуру с одним действием, в которой пункты назначения реализуются как фрагменты, а их модели данных реализуются с помощью ViewModel .

ViewModel всегда привязана к жизненному циклу, и как только этот жизненный цикл завершается окончательно, ViewModel очищается и может быть удалена. Жизненный цикл, на который распространяется ViewModel , и, следовательно, насколько широко может использоваться ViewModel , зависит от того, какой делегат свойства используется для получения ViewModel .

В простейшем случае каждый пункт назначения навигации представляет собой отдельный фрагмент с полностью изолированным состоянием пользовательского интерфейса; и поэтому каждый фрагмент может использовать делегат свойства viewModels() для получения ViewModel , ограниченной этим фрагментом.

Чтобы разделить состояние пользовательского интерфейса между фрагментами, примените ViewModel к действию, вызвав activityViewModels() во фрагментах (эквивалентом действия является просто viewModels() ). Это позволяет активности и любым присоединенным к ней фрагментам совместно использовать экземпляр ViewModel . Однако в архитектуре с одним действием эта область ViewModel эффективно действует до тех пор, пока приложение, поэтому ViewModel остается в памяти, даже если никакие фрагменты ее не используют.

Предположим, что ваш навигационный граф имеет последовательность пунктов назначения фрагментов, представляющих поток оформления заказа, а текущее состояние всего процесса оформления заказа находится в ViewModel , которая является общей для всех фрагментов. Привязка ViewModel к действию не только слишком широка, но и фактически создает еще одну проблему: если пользователь проходит через процесс оформления заказа для одного заказа, а затем снова проходит через него для второго заказа, оба заказа используют один и тот же экземпляр оформления заказа. ViewModel . Перед оформлением второго заказа вам придется вручную очищать данные из первого заказа, и любые ошибки могут дорого стоить пользователю.

Вместо этого примените ViewModel к графу навигации в текущем NavController . Создайте вложенный граф навигации, чтобы инкапсулировать пункты назначения, являющиеся частью процесса оформления заказа. Затем в каждом из этих мест назначения фрагментов используйте делегат свойства navGraphViewModels() и передайте идентификатор графа навигации, чтобы получить общую ViewModel . Это гарантирует, что как только пользователь выйдет из процесса оформления заказа и вложенный граф навигации выйдет за пределы области действия, соответствующий экземпляр ViewModel будет удален и не будет использоваться для следующего оформления заказа.

Объем Делегат свойства Можно поделиться ViewModel с
Фрагмент Fragment.viewModels() Только текущий фрагмент
Активность Activity.viewModels()

Fragment.activityViewModels()

Активность и все прикрепленные к ней фрагменты
График навигации Fragment.navGraphViewModels() Все фрагменты в одном навигационном графе

Обратите внимание: если вы используете вложенный хост навигации (см. выше), пункты назначения на этом хосте не могут совместно использовать ViewModel с пунктами назначения за пределами хоста при использовании navGraphViewModels() поскольку графики не связаны. В этом случае вместо этого вы можете использовать область действия.

Поднятое состояние

В Compose вы можете сохранять состояние при изменении размера окна с помощью подъема состояния . Подняв состояние составных элементов на более высокую позицию в дереве композиции, можно сохранить состояние, даже если составные элементы больше не видны.

В разделе «Создание» в разделе «Назначения контента с альтернативными макетами», приведенном выше, мы подняли состояние компонуемых элементов представления списка до ListDetailRoute , чтобы состояние сохранялось независимо от того, какой компонуемый объект отображается:

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

Дополнительные ресурсы

{% дословно %} {% дословно %} {% дословно %} {% дословно %}