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

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. Две верхние записи в стеке явно заявляют о своей поддержке отображения в двухпанельном макете с использованием определенных метаданных.

Следующий фрагмент представляет собой объединенный исходный код для 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()
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.Content()
            }
        }
    }

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

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

    return remember(windowSizeClass) {
        TwoPaneSceneStrategy(windowSizeClass)
    }
}

// --- 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>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
        // 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.contentKey, secondEntry.contentKey)

            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 = rememberTwoPaneSceneStrategy(),
        onBack = {
            if (backStack.isNotEmpty()) {
                backStack.removeLastOrNull()
            }
        }
    )
}

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

Для варианта использования «список-детализация» артефакт androidx.compose.material3.adaptive:adaptive-navigation3 предоставляет ListDetailSceneStrategy , который создаёт Scene «список-детализация». Эта 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<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. Пример содержимого, работающего в сцене «Список материалов-подробности».