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

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

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

Для варианта использования «список-детализация» артефакт 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. Пример содержимого, работающего в сцене «Список материалов-подробности».