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

В 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 – это composable-функция, в которой вы определяете, как Scene отрисовывает entries и любые окружающие элементы интерфейса, относящиеся к Scene.
  • metadata: Map<String, Any>: предоставляет информацию о сцене другим компонентам библиотеки, например NavDisplay. По умолчанию возвращает metadata последнего значения NavEntry в entries.

Как реализовать методы equals и hashCode в пользовательских сценах

В пользовательских реализациях Scene должны быть правильно реализованы equals и hashCode, чтобы поддерживать следующие функции:

  • Переходы между состояниями сцен. Каждый раз, когда сцена рассчитывается на основе списка стратегий, NavDisplay проверяет, совпадает ли новая сцена с предыдущей. Если он обнаруживает изменение, то обновляет SeekableTransition, используемый для перехода между сценами, что, в свою очередь, влияет на состояние жизненного цикла сцен.
  • Управление наложениями. Для OverlayScene (например, диалоговых окон) NavDisplay использует сам объект сцены в качестве key, чтобы отслеживать жизненные циклы наложений и анимацию выхода.

Убедитесь, что все свойства, определяющие контент сцены, например key, entries и previousEntries, включены в реализации equals и hashCode. Как правило, не следует включать обратные вызовы (например, onBack) в реализацию этих методов, поскольку они могут изменять экземпляры, не меняя логическое состояние сцены.

Стратегии для сцен

SceneStrategy – это механизм, который определяет, как должен быть организован и передан в Scene список объектов NavEntry из стека возврата. По сути, когда 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>> из стека возврата. Если из предоставленных записей можно сформировать URL, функция должна вернуть значение Scene<T>, в противном случае – null.

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

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

NavDisplay – это центральная composable-функция, которая отслеживает обратный стек и использует один или несколько элементов SceneStrategy, чтобы определить и отрисовать подходящий элемент Scene.

Параметр sceneStrategies тега NavDisplay ожидает список экземпляров SceneStrategy, которые отвечают за расчет значения Scene для показа. Если ни одна из стратегий не позволяет рассчитать Scene, то по умолчанию NavDisplay автоматически переходит к использованию SinglePaneSceneStrategy.

Вот как это происходит:

  • Когда вы добавляете или удаляете ключи из стека возврата (например, с помощью backStack.add() или backStack.removeLastOrNull()), NavDisplay отслеживает эти изменения.
  • Функция NavDisplay передает текущий список NavEntry (полученный из ключей стека вызовов) настроенной функции sceneStrategies по порядку, вызывая функцию calculateScene для каждого элемента, пока не будет возвращено значение Scene.
  • Когда 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.
 *
 */
data 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.contains(DetailKey) } ?: return null
        val listEntry = entries.findLast { it.metadata.contains(ListKey) } ?: 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
        )
    }

    object ListKey : NavMetadataKey<Boolean>
    object DetailKey : NavMetadataKey<Boolean>
    companion object {

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

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun detailPane() = metadata {
            put(DetailKey, 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() },
        sceneStrategies = listOf(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 list-detail, в которой есть подходящие детали и поддержка плейсхолдеров, как показано в следующем разделе.

Как показывать контент в формате "список и подробные сведения" в адаптивной сцене Material

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

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

  1. Добавьте зависимость. Включите androidx.compose.material3.adaptive:adaptive-navigation3 в файл build.gradle.kts проекта.
  2. Определите записи с помощью метаданных ListDetailSceneStrategy. Используйте теги listPane(), detailPane() и extraPane(), чтобы отметить NavEntrys для показа в нужном окне. Помощник listPane() также позволяет указать detailPlaceholder, когда не выбран ни один элемент.
  3. Используйте rememberListDetailSceneStrategy(). Эта composable-функция предоставляет предварительно настроенный 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() },
                    sceneStrategies = listOf(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 Design список и подробные сведения.