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

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

  1. Can I create a Scene from these entries? Если 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.

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

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() для записи, которую вы хотите показать в макете list, и ListDetailScene.detailPane() для записи, которую вы хотите показать в макете detail. Затем укажите 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 Design, которая поставляется с разумными деталями и поддержкой плейсхолдеров, как показано в следующем разделе.

Показ контента в виде списка и подробных сведений в адаптивной сцене 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.