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

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 s, которые будут получены, если действие «назад» произойдет из текущей 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>?

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

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: T,
    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.invoke(entry.key) }
}

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

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

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

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

Следующий фрагмент представляет собой объединенный исходный код для TwoPaneScene.kt и TwoPaneSceneStrategy.kt :

// --- TwoPaneScene ---
/**
 * A custom [Scene] that displays two [NavEntry]s side-by-side in a 50/50 split.
 */
class TwoPaneScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val firstEntry: NavEntry<T>,
    val secondEntry: NavEntry<T>
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(firstEntry, secondEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.5f)) {
                firstEntry.content.invoke(firstEntry.key)
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.content.invoke(secondEntry.key)
            }
        }
    }

    companion object {
        internal const val TWO_PANE_KEY = "TwoPane"
        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * in a two-pane layout.
         */
        fun twoPane() = mapOf(TWO_PANE_KEY to true)
    }
}

// --- TwoPaneSceneStrategy ---
/**
 * A [SceneStrategy] that activates a [TwoPaneScene] if the window is wide enough
 * and the top two back stack entries declare support for two-pane display.
 */
class TwoPaneSceneStrategy<T : Any> : SceneStrategy<T> {
    @OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3WindowSizeClassApi::class)
    @Composable
    override fun calculateScene(
        entries: List<NavEntry<T>>,
        onBack: (Int) -> Unit
    ): Scene<T>? {

        val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

        // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes.
        // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp).
        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val lastTwoEntries = entries.takeLast(2)

        // Condition 2: Only return a Scene if there are two entries, and both have declared
        // they can be displayed in a two pane scene.
        return if (lastTwoEntries.size == 2 &&
            lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) }
        ) {
            val firstEntry = lastTwoEntries.first()
            val secondEntry = lastTwoEntries.last()

            // The scene key must uniquely represent the state of the scene.
            val sceneKey = Pair(firstEntry.key, secondEntry.key)

            TwoPaneScene(
                key = sceneKey,
                // Where we go back to is a UX decision. In this case, we only remove the top
                // entry from the back stack, despite displaying two entries in this scene.
                // This is because in this app we only ever add one entry to the
                // back stack at a time. It would therefore be confusing to the user to add one
                // when navigating forward, but remove two when navigating back.
                previousEntries = entries.dropLast(1),
                firstEntry = firstEntry,
                secondEntry = secondEntry
            )
        } else {
            null
        }
    }
}

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

// Define your navigation keys
@Serializable
data object ProductList : NavKey
@Serializable
data class ProductDetail(val id: String) : NavKey

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ProductList)

    NavDisplay(
        backStack = backStack,
        entryProvider = entryProvider {
            entry<ProductList>(
                // Mark this entry as eligible for two-pane display
                metadata = TwoPaneScene.twoPane()
            ) { key ->
                Column {
                    Text("Product List")
                    Button(onClick = { backStack.add(ProductDetail("ABC")) }) {
                        Text("View Details for ABC (Two-Pane Eligible)")
                    }
                }
            }

            entry<ProductDetail>(
                // Mark this entry as eligible for two-pane display
                metadata = TwoPaneScene.twoPane()
            ) { key ->
                Text("Product Detail: ${key.id} (Two-Pane Eligible)")
            }
            // ... other entries ...
        },
        // Simply provide your custom strategy. NavDisplay will fall back to SinglePaneSceneStrategy automatically.
        sceneStrategy = TwoPaneSceneStrategy<Any>(),
        onBack = { count ->
            repeat(count) {
                if (backStack.isNotEmpty()) {
                    backStack.removeLastOrNull()
                }
            }
        }
    )
}

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

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

Чтобы создать Scene со списком материалов, выполните следующие действия:

  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<Any>()

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { keysToRemove -> repeat(keysToRemove) { 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. Пример содержимого, работающего в сцене Material list-detail.