Создавайте собственные макеты с помощью сцен

В Navigation 3 представлена ​​мощная и гибкая система управления потоком пользовательского интерфейса вашего приложения с помощью сцен . Сцены позволяют создавать высоко настраиваемые макеты, адаптироваться к различным размерам экрана и беспрепятственно управлять сложными многопанельными интерфейсами.

Понимание сцен

В Navigation 3 Scene — это основная единица, которая отображает один или несколько экземпляров NavEntry . Представьте себе Scene как отдельное визуальное состояние или раздел вашего пользовательского интерфейса, который может содержать и управлять отображением контента из стека возврата.

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

Интерфейс Scene обладает следующими свойствами:

  • key: Any : Уникальный идентификатор для данного экземпляра Scene . Этот ключ в сочетании с классом Scene обеспечивает уникальность, в первую очередь, для целей анимации.
  • entries: List<NavEntry<T>> : Это список объектов NavEntry , за отображение которых отвечает Scene . Важно отметить, что если один и тот же NavEntry отображается в нескольких Scenes во время перехода (например, при переходе с использованием общего элемента), его содержимое будет отображаться только в самой последней целевой Scene , которая его отображает.
  • previousEntries: List<NavEntry<T>> : Это свойство определяет элементы NavEntry , которые будут отображаться при нажатии кнопки «назад» в текущей Scene . Оно необходимо для расчета правильного прогнозируемого состояния «назад», позволяя NavDisplay предугадывать и переходить к правильному предыдущему состоянию, которое может представлять собой сцену с другим классом и/или ключом.
  • content: @Composable () -> Unit : Это составная функция, в которой вы определяете, как Scene отображает свои entries и любые окружающие элементы пользовательского интерфейса, специфичные для этой Scene .

Понимание стратегий построения сцены

SceneStrategy — это механизм, определяющий, как заданный список элементов NavEntry из стека возврата должен быть упорядочен и преобразован в Scene . По сути, при наличии текущих элементов стека возврата SceneStrategy задает себе два ключевых вопроса:

  1. Могу ли я создать Scene из этих элементов? Если SceneStrategy определяет, что может обработать заданные NavEntry и сформировать осмысленную Scene (например, диалоговое окно или многопанельный макет), она продолжает работу. В противном случае она возвращает null , предоставляя другим стратегиям возможность создать Scene .
  2. Если да, то как мне следует расположить эти элементы в Scene? Как только SceneStrategy принимает на себя задачу обработки элементов, она берет на себя ответственность за построение Scene и определение того, как указанные элементы NavEntry будут отображаться в этой Scene .

Суть SceneStrategy заключается в методе calculateScene :

@Composable
public fun calculateScene(
    entries: List<NavEntry<T>>,
    onBack: (count: Int) -> Unit,
): Scene<T>?

Этот метод является функцией расширения объекта SceneStrategyScope , которая принимает текущий List<NavEntry<T>> из стека возврата. Он должен возвращать Scene<T> если ему удается успешно сформировать объект из предоставленных элементов, или null , если это невозможно.

Объект SceneStrategyScope отвечает за хранение любых необязательных аргументов, которые могут потребоваться SceneStrategy , например, для вызова функции обратного вызова onBack .

SceneStrategy также предоставляет удобную функцию then infix, позволяющую объединять несколько стратегий в цепочку. Это создает гибкий конвейер принятия решений, в котором каждая стратегия может попытаться рассчитать Scene , и если ей это не удается, она передает задачу следующей в цепочке.

Как сцены и стратегии сцен работают вместе

NavDisplay — это центральный компонент, который отслеживает ваш стек возврата и использует SceneStrategy для определения и отображения соответствующей Scene .

Параметр NavDisplay's sceneStrategy ожидает указать SceneStrategy , отвечающую за вычисление отображаемой Scene . Если Scene не вычисляется с помощью предоставленной стратегии (или цепочки стратегий), NavDisplay автоматически переключается на использование SinglePaneSceneStrategy по умолчанию.

Вот подробное описание взаимодействия:

  • Когда вы добавляете или удаляете клавиши из стека возврата (например, с помощью backStack.add() или backStack.removeLastOrNull() ), NavDisplay отслеживает эти изменения.
  • Объект NavDisplay передает текущий список элементов NavEntrys (полученный из клавиш стека "назад") в метод SceneStrategy's calculateScene .
  • Если SceneStrategy успешно возвращает Scene , NavDisplay отображает content этой Scene . NavDisplay также управляет анимацией и предиктивным возвратом на основе свойств Scene .

Пример: Однопанельная компоновка (поведение по умолчанию)

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

data class SinglePaneScene<T : Any>(
    override val key: Any,
    val entry: NavEntry<T>,
    override val previousEntries: List<NavEntry<T>>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(entry)
    override val content: @Composable () -> Unit = { entry.Content() }
}

/**
 * A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the
 * list.
 */
public class SinglePaneSceneStrategy<T : Any> : SceneStrategy<T> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? =
        SinglePaneScene(
            key = entries.last().contentKey,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

Пример: Базовая структура «список-детали» (с пользовательскими настройками сцены и стратегии)

В этом примере показано, как создать простой макет «список-подробности», который активируется при выполнении двух условий:

  1. Ширина окна достаточна для размещения двух панелей (т.е., как минимум, WIDTH_DP_MEDIUM_LOWER_BOUND ).
  2. В стеке возврата находятся записи, которые заявили о поддержке отображения в формате список-подробности с использованием определенных метаданных.

Ниже приведён исходный код файла ListDetailScene.kt , содержащий функции ListDetailScene и ListDetailSceneStrategy :

// --- ListDetailScene ---
/**
 * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
 *
 */
class ListDetailScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val listEntry: NavEntry<T>,
    val detailEntry: NavEntry<T>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(listEntry, detailEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.4f)) {
                listEntry.Content()
            }
            Column(modifier = Modifier.weight(0.6f)) {
                detailEntry.Content()
            }
        }
    }
}

@Composable
fun <T : Any> rememberListDetailSceneStrategy(): ListDetailSceneStrategy<T> {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    return remember(windowSizeClass) {
        ListDetailSceneStrategy(windowSizeClass)
    }
}

// --- ListDetailSceneStrategy ---
/**
 * A [SceneStrategy] that returns a [ListDetailScene] if the window is wide enough, the last item
 * is the backstack is a detail, and before it, at any point in the backstack is a list.
 */
class ListDetailSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {

    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {

        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val detailEntry =
            entries.lastOrNull()?.takeIf { it.metadata.containsKey(DETAIL_KEY) } ?: return null
        val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) } ?: return null

        // We use the list's contentKey to uniquely identify the scene.
        // This allows the detail panes to be displayed instantly through recomposition, rather than
        // having NavDisplay animate the whole scene out when the selected detail item changes.
        val sceneKey = listEntry.contentKey

        return ListDetailScene(
            key = sceneKey,
            previousEntries = entries.dropLast(1),
            listEntry = listEntry,
            detailEntry = detailEntry
        )
    }

    companion object {
        internal const val LIST_KEY = "ListDetailScene-List"
        internal const val DETAIL_KEY = "ListDetailScene-Detail"

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun listPane() = mapOf(LIST_KEY to true)

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun detailPane() = mapOf(DETAIL_KEY to true)
    }
}

Чтобы использовать ListDetailSceneStrategy в вашем NavDisplay , измените вызовы entryProvider , добавив метаданные ListDetailScene.listPane() для элемента, который вы хотите отобразить в виде списка , и ListDetailScene.detailPane() для элемента, который вы хотите отобразить в виде подробного макета. Затем укажите ListDetailSceneStrategy() в качестве вашей sceneStrategy , используя резервный вариант по умолчанию для сценариев с одной панелью:

// Define your navigation keys
@Serializable
data object ConversationList : NavKey

@Serializable
data class ConversationDetail(val id: String) : NavKey

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ConversationList)
    val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        sceneStrategy = listDetailStrategy,
        entryProvider = entryProvider {
            entry<ConversationList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
                Column(modifier = Modifier.fillMaxSize()) {
                    Text(text = "I'm a Conversation List")
                    Button(onClick = { backStack.addDetail(ConversationDetail("123")) }) {
                        Text(text = "Open detail")
                    }
                }
            }
            entry<ConversationDetail>(
                metadata = ListDetailSceneStrategy.detailPane()
            ) {
                Text(text = "I'm a Conversation Detail")
            }
        }
    )
}

private fun NavBackStack<NavKey>.addDetail(detailRoute: ConversationDetail) {

    // Remove any existing detail routes, then add the new detail route
    removeIf { it is ConversationDetail }
    add(detailRoute)
}

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

Отображение содержимого списка и сведений в адаптивной сцене Material Design

Для сценария использования списка и подробностей артефакт androidx.compose.material3.adaptive:adaptive-navigation3 предоставляет ListDetailSceneStrategy , который создает Scene для списка и подробностей. Эта Scene автоматически обрабатывает сложные многопанельные конфигурации (список, подробности и дополнительные панели) и адаптирует их в зависимости от размера окна и состояния устройства.

Для создания Scene типа "список-детали" в формате Material Design выполните следующие шаги:

  1. Добавьте зависимость : добавьте androidx.compose.material3.adaptive:adaptive-navigation3 в файл ` build.gradle.kts вашего проекта.
  2. Определите свои элементы с помощью метаданных ListDetailSceneStrategy : используйте listPane(), detailPane() и extraPane() для пометки элементов NavEntrys для соответствующего отображения в панели. Вспомогательная функция listPane() также позволяет указать detailPlaceholder если ни один элемент не выбран.
  3. Используйте rememberListDetailSceneStrategy (): Эта составная функция предоставляет предварительно настроенную ListDetailSceneStrategy , которую может использовать NavDisplay .

Приведённый ниже фрагмент кода представляет собой пример Activity , демонстрирующий использование ListDetailSceneStrategy :

@Serializable
object ProductList : NavKey

@Serializable
data class ProductDetail(val id: String) : NavKey

@Serializable
data object Profile : NavKey

class MaterialListDetailActivity : ComponentActivity() {

    @OptIn(ExperimentalMaterial3AdaptiveApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Scaffold { paddingValues ->
                val backStack = rememberNavBackStack(ProductList)
                val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { backStack.removeLastOrNull() },
                    sceneStrategy = listDetailStrategy,
                    entryProvider = entryProvider {
                        entry<ProductList>(
                            metadata = ListDetailSceneStrategy.listPane(
                                detailPlaceholder = {
                                    ContentYellow("Choose a product from the list")
                                }
                            )
                        ) {
                            ContentRed("Welcome to Nav3") {
                                Button(onClick = {
                                    backStack.add(ProductDetail("ABC"))
                                }) {
                                    Text("View product")
                                }
                            }
                        }
                        entry<ProductDetail>(
                            metadata = ListDetailSceneStrategy.detailPane()
                        ) { product ->
                            ContentBlue("Product ${product.id} ", Modifier.background(PastelBlue)) {
                                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                                    Button(onClick = {
                                        backStack.add(Profile)
                                    }) {
                                        Text("View profile")
                                    }
                                }
                            }
                        }
                        entry<Profile>(
                            metadata = ListDetailSceneStrategy.extraPane()
                        ) {
                            ContentGreen("Profile")
                        }
                    }
                )
            }
        }
    }
}

Рисунок 1. Пример содержимого, работающего в сцене «Список материалов — Детали».