TL;DR
Что мы узнаем?
Как, используя compose, kotlin multiplatform, decompose, model-view-intent, реализовать навигацию по табам.
Какие функциональные возможности?
-
Выбор активной вкладки (остальные автоматически переходят в спящий режим).
-
Перемещение вкладки (с анимацией вытеснения).
-
Закрытие вкладки (с освобождением ресурсов).
-
Поддержка произвольных конфигураций компонентов (это то, из чего фабрика будет порождать компоненты; на гифке ниже это названия файлов
readme.md
,build.gradle.kts
) То есть можно задать свои дата-классы, по которым создаются компоненты. -
🔥 Поддержка произвольного внешнего вида вкладки.
-
🔥 Поддержка произвольного внешнего вида компонента.
-
🔥 Поддержка произвольной компоновки табов и контента.
Как это выглядит?
В примере ниже внутри компонента крутится простая джоба, которая постоянно увеличивает счетчик и меняет body
текст на title.repeat(counter)
. Это нужно для наглядности и следует заменить на свою логику.
Какие библиотеки используем?
Только база, только core
com.arkivanov.essenty:lifecycle:2.4.0 com.arkivanov.decompose:decompose:3.2.2 com.arkivanov.decompose:extensions-compose:3.2.2 org.jetbrains.kotlin.plugin.serialization:2.1.0 org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3
Давайте сразу обозначим
Чтобы не путаться, давайте считать:
1. Вкладка — это та самая штука с кнопкой «закрыть», ui-компонент навигационной панели.
2. Панель вкладок — это горизонтальный бар, где эти вкладки лежат. Та самая штука сверху, как в Google Chrome.
3. Слот — это decompose-компонент, в котором описывается логика видимого на экране.
В примере выше, слот содержит счетчик и текст, причем текст постоянно увеличивается. И таких слотов там два, один связан с readme.txt
, другой с build.gradle.kts
.
4. Контент слота — это ui-компонент, который визуализирует состояние слота.
В примере выше — это Card { Text(counter), Text(body) }
.
5. Контейнер — это decompose-компонент, который содержит в себе панель вкладок и слоты.
6. Контент контейнера — это ui-компонент, который содержит внутри себя ui панели вкладок и умеет рисовать выбранный слот (а может, и несколько слотов, если вы захотите сделать что-то типа карусели изображений).
Как это выглядит в коде?
У нас есть decompose-компонент, хранящий в себе слоты, и умеющий по ним навигироваться (выбирать активный, менять порядок слотов)
interface ContainerComponent<out SlotComponent : Any> { val children: Value<Children<*, TabComponent>> fun onSelect(index: Int) // В UI выбрали вкладку. fun onMove(fromIndex: Int, toIndex: Int) // В UI переместили вкладку. fun onTabCloseClicked(index: Int) // В UI нажали "закрыть". class Children<out C : Any, out T : Any>( // Инстансы контента вкладок - их decompose компоненты. val items: List<Child.Created<C, T>>, // А это индекс выбранного компонента. val selected: Int?, ) }
Чтобы породить слоты, используется конфигурация
import kotlinx.serialization.Serializable @Serializable data class Config(val filename: String)
И тогда для внедрения этой навигации в приложение, нужно всего лишь передать в реализацию нашего контейнера список с конфигурациями и лямбду slotFactory
, которая по конфигурации создает слот:
fun main() { val lifecycle = LifecycleRegistry() val stateKeeper = StateKeeperDispatcher( savedState = File(SAVED_STATE_FILE_NAME).readSerializableContainer() ) DefaultContainerComponent( componentContext = DefaultComponentContext( lifecycle = lifecycle, stateKeeper = stateKeeper ), configurations = listOf(Config("readme.txt"), Config("build.gradle.kts")), serializer = Config.serializer(), slotFactory = { componentContext, config -> DefaultSlotComponent(componentContext, config.filename) } ) }
Тут также нужно вспомнить, что decompose требует наличие ComponentContext
в каждом своем компоненте. Этот зверь нужен, чтобы компонент по-умолчанию имел:
-
Жизненный цикл (обеспечивается интерфейсом
LifecycleOwner
из библиотеки Essenty) -
Сохранение состояния компонента (особенно важно, если он тяжелый) — обеспечивается
StateKeeperOwner
из той же библиотеки. -
и так далее…
Решение получилось достаточно сложным и бегло объяснить его не получится, поэтому прошу под кат моего потока мыслей. Пройдемся от kotlin-интерфейса к каждому моменту реализации и разберемся, что же там такого понаписано.
Давным-давно, в далёкой-далёкой…
Вернемся к
ComponentContext
для небольшой ремарки. По своей сути,ComponentContext
делает decompose-компоненты «живыми», и избавляет нас от грязнейших последствий callback-hell, если мы решим жизненный цикл в них добавить.
А работает он на удивление просто: в самом начале программы мы создаем глобальный реестрLifecycleRegistry()
, а потом отпочковываем дочерние реестры, которые подписаны на вышестоящих и образуют дерево. В целом, похоже на порождение дочерних процессов.
С тем, что мы хотим получить, можно ознакомиться выше (см. «Какие функциональные возможности?»). А теперь давайте подробно разберем интерфейс нашего компонента с навигацией:
interface ContainerComponent<out SlotComponent : Any> { val children: Value<Children<*, SlotComponent>> fun onSelect(index: Int) fun onMove(fromIndex: Int, toIndex: Int) fun onTabCloseClicked(index: Int) class Children<out C : Any, out T : Any>( val items: List<Child.Created<C, T>>, val selected: Int?, ) }
Здесь интересны следующие моменты:
-
Value
— это аналогStateFlow
— «потока состояний». Мы осуществляем на него подписку и можем слушать все изменения. Так как конфигурации слотов будут меняться (будет меняться порядок слотов, индекс выбранного, а также сам набор слотов), нужно предусмотреть подписку на эти обновления. Чтобы упростить осознание, предлагаю посмотреть интерфейс Value:package com.arkivanov.decompose.value abstract class Value<out T : Any> { // Актуальное значение T. abstract val value: T /* Подписаться на обновления T. Возвращает токен отмены подписки */ abstract fun subscribe(observer: (T) -> Unit): Cancellation }
-
Children
— это состояние нашего компонента навигации. Состояние будет иметь decompose-компоненты слотов и информацию о выбранном. Вы также можете добавить сюда любую информацию. Допустим, режим отображения: горизонтальные вкладки или вертикальные -
SlotComponent
— это дженерик, который позволит нам переиспользовать этот навигационный компонент для разных типов слотов. В примере выше слоты — это странный счетчик, но вы можете захотеть сделать полноценный редактор файлов или просмотр изображений — и тогда нужно предусмотреть независимость от типа компонента, что и позволяет дженерик.
СоответствуетDefaultSlotComponent
в примере из «Как это выглядит в коде?». -
C
— дженерик в состоянии слотаChildren
. Он отвечает за конфигурацию, из которой мы будем создавать наши дочерние компоненты. Опять же, это может бытьimageURL
илиfilePath
, а, может быть,dialogId
— все зависит от того, из чего должен создаваться ваш дочерний компонент. Стоит также подчеркнуть, что используя механизмsealed
— классов для создания конфигурации, можно сделать управление совершенно произвольными компонентами, не имеющими ничего общего:@Serializable sealed interface SealedConfig { @Serializable data class Image(val uri: String): SealedConfig @Serializable data class File(val path: Path): SealedConfig } fun main() { val configurations = listOf( SealedConfig.Image("http://www.example.com"), SealedConfig.File(Path.of("/home/users/example/.zprofile")), ) val containerComponent = runOnUiThread { DefaultContainerComponent( componentContext = DefaultComponentContext(LifecycleRegistry()), configurations = configurations, // <--- тут мы передаем конфигурации serializer = SealedConfig.serializer(), slotFactory = { componentContext, config -> when(config) { // <---- тут мы говорим, какие компоненты вернуть is SealedConfig.File -> DefaultFileComponent(componentContext, config.path) is SealedConfig.Image -> DefaultImageComponent(componentContext, config.uri) } } ) }
Здесь можно заметить, как внезапно появился
runOnUiThread
— это небольшая необходимость, позаимствованная отсюда. -
Child.Created
— это просто класс, хранящий созданный вslotFactory
инстанс дочернего компонента с его конфигурацией.
Осознав, с чем мы собираемся иметь дело (контейнер, показывающий контент слота для выбранной вкладки), давайте воплотим этот интерфейс в жизнь.
Выше уже были примеры использования, в которых фигурирует некоторый slotFactory
— фабрика, отвечающая за создание компонентов из конфигураций. Предположим, что мы не хотим управлять конфигурацией за пределами компонента (поэтому не будем создавать callback-ов вроде onTabClose
), однако мы все еще хотим вынести логику построения наружу, чтобы было проще переиспользовать компонент.
Исходя из задачи, давайте напишем реализацию интерфейса ContainerComponent:
class DefaultContainerComponent<Configuration : Any, SlotComponent : Any>( componentContext: ComponentContext, private val configurations: List<Configuration>, serializer: KSerializer<Configuration>, private val slotFactory: (componentContext: ComponentContext, config: Configuration) -> SlotComponent ) : ComponentContext by componentContext, ContainerComponent<SlotComponent> { override val children: Value<Children<*, SlotComponent>> get() = TODO("Not yet implemented") override fun onSelect(index: Int) { TODO("Not yet implemented") } override fun onMove(fromIndex: Int, toIndex: Int) { TODO("Not yet implemented") } override fun onTabCloseClicked(index: Int) { TODO("Not yet implemented") } }
Разберем, что у нас есть. В компонент на вход подаем componentContext
и тут же делегируем ему реализацию интерфейса.
Если эта концепция кажется вам непонятной, вот объяснение
interface Ping { fun ping() } class Pinger : Ping { override fun ping() { println("ping") } } class Controller(pinger: Pinger) : Ping by pinger fun main() { Controller(Pinger()).ping() }
kotlin позволяет создавать объекты-хабы, которые через агрегацию реализуют множество интерфейсов. Если класс должен поддерживать много специфичной логики, которую хочется разбить по классам поменьше, допустим, чтобы инкапсулировать состояние, то можно использовать делегаты.
Код выше компилируется в что-то похожее на это:
class Controller(private val pinger: Pinger) : Ping { override fun ping() { pinger.ping() } }
Также у нас есть serializer
— он нужен для того, чтобы сохранять состояние панели вкладок. И, конечно, уже всем известный slotFactory
.
Здесь очень важно не попасть в ловушку от Implement Members, которая любезно делает
get()
на состояниеchildren
. Почему это плохо? А потому, что decompose хранит дочерние компоненты по ключу, и если мы сделаем геттер, то при каждом обращении в попытке получить текущее состояние (допустим, чтобы отрисовать UI), мы будем создавать новый набор детей, используяslotFactory
, добавлять этих детей в жизненный цикл, и, соответственно, порождать коллизию по ключам — об этом в runtime нам скажет фатальный Exception. Поэтому сразу переделываем вoverride val children: Value<Children<*, SlotComponent>> = TODO("Not yet implemented")
и наслаждаемся.
Учимся хранить информацию о состояниях слотов
Перед тем, как создавать детей (слоты), нам сначала нужно определить навигацию — ведь кто-то должен отвечать за их жизненный цикл. Поэтому создадим хранилище состояний NavigationState
:
class DefaultContainerComponent<Configuration : Any, SlotComponent : Any>( componentContext: ComponentContext, private val configurations: List<Configuration>, serializer: KSerializer<Configuration>, private val slotFactory: (componentContext: ComponentContext, config: Configuration) -> SlotComponent ) : ComponentContext by componentContext, ContainerComponent<SlotComponent> { private val navigation = SimpleNavigation<(NavigationState<Configuration>) -> NavigationState<Configuration>>() @Serializable private data class NavigationState<Configuration : Any>( val configurations: List<Configuration>, val index: Int?, ) : NavState<Configuration> { override val children: List<SimpleChildNavState<Configuration>> by lazy { configurations.mapIndexed { index, config -> val status = if (index == this.index) ChildNavState.Status.RESUMED else ChildNavState.Status.CREATED SimpleChildNavState( // <--- это аналог StateFlow и Value. configuration = config, status = status, ) } } } // это нам пока не нужно override val children: Value<Children<*, SlotComponent>> = TODO("Not yet implemented") override fun onSelect(index: Int) { TODO("Not yet implemented") } override fun onMove(fromIndex: Int, toIndex: Int) { TODO("Not yet implemented") } override fun onTabCloseClicked(index: Int) { TODO("Not yet implemented") } }
Он реализует простой интерфейс:
package com.arkivanov.decompose.router.children // Представляет собой полное состояние навигации. interface NavState<out Configuration : Any> { // Список дочерних состояний навигации. // Каждое [ChildNavState.configuration] должно быть уникальным по equals. val children: List<ChildNavState<Configuration>> }
И, как говорилось ранее, хранит в себе информацию о детях и их состояниях.
А что такое ChildNavState?
package com.arkivanov.decompose.router.children /* Представляет дочернее состояние навигации */ interface ChildNavState<out Configuration : Any> { /* Конфигурация дочернего элемента. Должна быть уникальной в [NavState] */ val configuration: Configuration /* Состояние жизненного цикла для компонента с соответствующей конфигурацией [configuration] */ val status: Status /* Все возможные состояния жизненного цикла */ enum class Status { /* Дочерний компонент уничтожен, но по-прежнему управляется, например, его состояние может быть сохранено и восстановлено позже */ DESTROYED, /* Состояние после создания объекта (еще не запущен) или если его поставили на паузу (выбрали другую вкладку) */ CREATED, /* Компонент активен (выбрана вкладка, соответствующая ему) */ STARTED, /* Компонент активен (выбрана вкладка, соответствующая ему), но ранее был приостановлен */ RESUMED, } }
Да, буквально хранит в себе информацию о детях и их состояниях =)
Примечательно также, что в 17
строчке мы определили 2 состояния для наших вкладок:
val status = if (index == this.index) { ChildNavState.Status.RESUMED } else { ChildNavState.Status.CREATED }
Эта логика может быть расширена, но в текущей реализации ее хватает, чтобы корректно обрабатывать фокус: когда наша вкладка открыта, RESUMED
, когда открыта другая, то наша CREATED
. И, соответственно, при изменении состояний, внутри компонента будет дергаться соответствующий callback — так что мы всегда будем знать об изменениях состояния.
Учимся обновлять состояние вкладок
После того, как мы создали держатель состояния, нужно понять, как это состояние изменять, и сделать так, чтобы другие эти изменения видели.
Для начала я предлагаю научиться менять состояние навигации, так как это проще всего. Реализуем оставшиеся методы (закомментировано то, что не особо имеет значение сейчас):
class DefaultContainerComponent<Configuration : Any, SlotComponent : Any>( componentContext: ComponentContext, private val configurations: List<Configuration>, serializer: KSerializer<Configuration>, private val slotFactory: (componentContext: ComponentContext, config: Configuration) -> SlotComponent ) : ComponentContext by componentContext, ContainerComponent<SlotComponent> { private val navigation = SimpleNavigation<(NavigationState<Configuration>) -> NavigationState<Configuration>>() override fun onSelect(index: Int) { navigation.navigate { state -> state.copy(index = index) } } @Serializable private data class NavigationState<Configuration : Any>( val configurations: List<Configuration>, val index: Int?, ) : NavState<Configuration> {...} // override val children: Value<Children<*, SlotComponent>> = TODO("Not yet implemented") // override fun onMove(fromIndex: Int, toIndex: Int) { // if (fromIndex !in configurations.indices || toIndex !in configurations.indices) return // navigation.navigate { state -> // val updatedConfigurations = state.configurations.toMutableList() // val movedItem = updatedConfigurations.removeAt(fromIndex) // updatedConfigurations.add(toIndex, movedItem) // state.copy(configurations = updatedConfigurations, index = toIndex) // } // } // override fun onTabCloseClicked(index: Int) { // if (index !in configurations.indices) return // navigation.navigate { state -> // val updatedConfigurations = state.configurations.toMutableList() // updatedConfigurations.removeAt(index) // val newIndex = if (updatedConfigurations.isEmpty()) { // null // } else { // index.coerceAtLeast(0).coerceAtMost(updatedConfigurations.size - 1) // } // state.copy(configurations = updatedConfigurations, index = newIndex) // } // } }
Описывать логику танцев с индексами не будем, но что важно — это следующий момент:
navigation.navigate { state -> state.copy(index = index) }
Так как мы создали SimpleNavigation
, который по своей природе то же самое, что и Value
(о нем было ранее), а метод navigate
осуществляет вызов всех подписчиков на обновления, то для обновления состояния навигации нам всего лишь достаточно вызвать navigation.navigate(event)
и передать в него наше новое состояние. В методе onSelect
мы выбираем новый индекс, а в onMove
меняем порядок конфигураций.
Окей, мы научились хранить и обновлять состояние. Теперь нужно научить decompose слушать состояние слотов и менять их жизненный цикл (да, именно он будет отвечать за вызов методов onPause
, onCreate
и тд, так как это задача ComponentContext
и библиотеки Essenty
).
Учим decompose слушать обновления состояний
И тут на удивление все нереально просто. Так как наш компонент реализует ComponentContext:
package com.arkivanov.decompose interface ComponentContext : GenericComponentContext<ComponentContext>
А у GenericComponentContext
есть методы расширения, которые умеют порождать дочерние компоненты, то мы просто вызываем этот метод расширения и передаем в него нашу SimpleNavigation
. Decompose умеет слушать SimpleNavigation
и управлять жизненным циклом дочерних компонентов в зависимости от команд первого:
class DefaultContainerComponent<Configuration : Any, SlotComponent : Any>( componentContext: ComponentContext, private val configurations: List<Configuration>, serializer: KSerializer<Configuration>, private val slotFactory: (componentContext: ComponentContext, config: Configuration) -> SlotComponent ) : ComponentContext by componentContext, ContainerComponent<SlotComponent> { private val navigation = SimpleNavigation<(NavigationState<Configuration>) -> NavigationState<Configuration>>() override val children: Value<Children<Configuration, SlotComponent>> = children( source = navigation, stateSerializer = NavigationState.serializer(serializer), key = "tabs", initialState = { NavigationState(configurations = configurations, index = 0) }, navTransformer = { state, transformer -> transformer(state) }, stateMapper = { state, children -> Children( items = children.map { it as Child.Created }, selected = state.index, ) }, backTransformer = { state -> state.takeIf { it.index != null } ?.takeIf { it.index!! > 0 } ?.let { eligibleState -> { eligibleState.copy(index = eligibleState.index!! - 1) } } }, childFactory = { configuration, componentContext -> slotFactory(componentContext, configuration) }, ) override fun onSelect(index: Int) {...} override fun onMove(fromIndex: Int, toIndex: Int) {...} override fun onTabCloseClicked(index: Int) {...} @Serializable private data class NavigationState<Configuration : Any>(...) {...} }
Тут
-
source
— это как раз поставщик новых состояний.
-
stateSerializer
— вынужденная мера для сохранения состояний, для которой ранее мы передавалиserializer
в качестве аргумента конструктора.
-
key
— это ключ, который должен быть уникальным для дочерних элементов. Так как нашим дочерним элементом контейнера является группа вкладок (группа детей), то мы эту группу называем некоторой строкой «tabs». Если еще раз вызватьchildren(...)
, мы получим ошибкуAnother supplier is already registered with the key: tabs
. Именно поэтому здесь нельзя делатьget() =
, а нужно просто=
(об этом было ранее).
-
Также задаем начальное состояние навигации в
initialState
. Тут я решил, что по умолчанию выбранной будет самая первая вкладка.
-
Еще есть
navTransformer
, который требует рассказать, каким образом из события получить новое состояние. Дело в том, что методnavigate
принимает на вход совсем не лямбду, как мы делали это выше (обязательно перечитайте «Учимся обновлять состояние вкладок», если непонятно, о чем речь), а просто дженерикT
. Это значит, что мы можем вnavigate()
передавать дата-классы с новым состоянием, а вnavTransformer
решать, как из старогоstate
и из событияtransformer
получить новыйstate
. Например, так:navTransformer = { state: Int, transformer: String -> if (transformer == "увеличь") { state + 1 } else { state } },
для вызванного ранее
navigation.navigate("увеличь")
. -
stateMapper
— аналогичная конструкция, которая просит объяснить, как изNavigationState
@Serializable private data class NavigationState<Configuration : Any>( val configurations: List<Configuration>, val index: Int?, ) : NavState<Configuration> {...}
Получить
Children
interface ContainerComponent<out SlotComponent : Any> { // val children: Value<Children<*, SlotComponent>> // fun onSelect(index: Int) // fun onMove(fromIndex: Int, toIndex: Int) // fun onTabCloseClicked(index: Int) class Children<out C : Any, out T : Any>( val items: List<Child.Created<C, T>>, val selected: Int?, ) }
На первый взгляд это кажется лишним усложнением и ужасом, но на самом деле нужно для того, чтобы отделить наблюдаемое извне состояние компонента от его внутренней реализации (инкапсуляция). В данном случае
NavigationState == Children
, но это может измениться с ростом кодовой базы. Допустим, если нам нужно будет хранить много сложно специфичной мета-информации, которая прямо влияет на нашу навигацию: ориентация экрана, ширина окна, фаза луны и тп). Эту инфу светить не стоит, потому что она техническая — с другой стороны, а где ей еще быть, если не в дата-классе с состоянием навигации? Поэтому идет разделение на Mutable-представление (внутреннее) и Immutable-представление (внешнее). -
backTransformer
— как реагировать на кнопку «назад». Допустим, можно выбрать вкладку слева от текущей (если они расположены слева направо). -
childFactory
— как делать дочерние компоненты. Так как мы решили сделать переиспользуемый компонент, эту фабрику вынесли наружу и она передается в конструкторе какslotFactory
. Наши слоты (контент во вкладках) создаются именно здесь.
И, на самом деле, на этом этапе можно считать, что мы почти победили — осталось лишь добавить ui.
Делаем UI
В decompose так принято, что программа состоит из компонентов, а UI для этих компонентов называется контентом. Поэтому код обычно выглядит как группа файлов:
interface SomeComponent { val state: Value<КакойТоДатаКласс> } class DefaultSomeComponent(...): SomeComponent, ... {...} @Composable fun SomeContent(component: SomeComponent, modifier: Modifier) { val state by component.state.subscribeAsState() Text(state.какоеТоПоле) // или val state = component.state.subscribeAsState() Text(state.value.какоеТоПоле) }
Так и в нашем случае: мы сделали интерфейс и класс, осталось подготовить к нему функцию с контентом.
Помните требования в начале?
5. 🔥 Поддержка произвольного внешнего вида вкладки.
6. 🔥 Поддержка произвольного внешнего вида компонента.
7. 🔥 Поддержка произвольной компоновки табов и контента.
Чтобы поддержать их, необходимо создать функцию, которая принимает на вход лямбды и по ним будет строить @Composable
контент. Лямбды, кстати, тоже должны быть @Composable
, потому что мы хотим передавать в них графику. Определимся с тем, что мы хотим получить:
-
По переданному компоненту слота (это тот самый дочерний компонент навигации, то есть сам слот, который был создан в
slotFactory
), мы должны рисовать вкладку (ту самую, с кнопкой «закрыть» и заголовком). -
По переданному ui-компоненту «горизонтальная группа вкладок» и компоненту слота (тот самый, созданный в
slotFactory
), мы должны рисовать весьlayout
. То есть компоновать группу вкладок и выбранный слот. Может быть, рисовать вкладки вверху, или внизу, или вставить между слотом и вкладками какой-нибудь еще компонент.
Важно уточнить, что этот дизайн может быть полностью изменен. Для моей задачи было удобно создать функцию, которая получает
@Composable
вкладку и@Composable
слота. Но у вас может быть желание видеть другие вкладки, пусть даже если они на паузе (допустим, если это просмотр изображений). Также вы можете захотеть сделать абстрактную компоновку вкладок: вертикальное расположение, круглое как в GTA V или все что угодно. В таком случае, вам нужно хорошо понять содержание этой статьи и переписать представленную функцию так, чтобы она подошла под вашу задачу. Поэтому стоит сконцентрировать внимание именно на том, как будет использоваться компонентContainerComponent
внутри функции, а не на то, как эта функция работает в целом.
Давайте реализуем желаемое (дисклеймер: не бояться, внизу будет упрощенный вид):
@Composable internal fun <SlotComponent : Any> ContainerContent( navigationComponent: ContainerComponent<SlotComponent>, tabContent: @Composable ( index: Int, slotComponent: SlotComponent, modifier: Modifier, isSelected: Boolean, isDragging: Boolean, onClose: () -> Unit) -> Unit, tabsArrangement: Arrangement.Horizontal = Arrangement.spacedBy(4.dp), containerContent: @Composable (innerTabs: @Composable (modifier: Modifier) -> Unit, slotComponent: SlotComponent?) -> Unit ) { val children by navigationComponent.children.subscribeAsState() containerContent( { tabRowModifier -> TabDraggableRow( items = children.items.map { it.instance }, onMove = navigationComponent::onMove, modifier = tabRowModifier, tabHorizontalSpacing = tabsArrangement, itemContent = { index, itemComponent, isDragging, modifier -> tabContent( index, itemComponent, modifier, children.selected == index, isDragging, { navigationComponent.onTabCloseClicked(index) } ) } ) }, children.selected?.let { children.items[it] }?.instance ) }
Функция получилась неочевидной, а тот горизонтальный бар TabDraggableRow
с гифки начале будет реализован в следующей статье. Поэтому давайте ее упростим и попробуем оставить только самое важное:
@Composable internal fun <SlotComponent : Any> ContainerContent( navigationComponent: ContainerComponent<SlotComponent>, tabContent: @Composable (slotComponent: SlotComponent, onClose: () -> Unit) -> Unit, containerContent: @Composable (innerTabs: @Composable (modifier: Modifier) -> Unit, slotComponent: SlotComponent?) -> Unit ) { val children: ContainerComponent.Children<*, SlotComponent> by navigationComponent.children.subscribeAsState() val slots: List<SlotComponent> = children.items.map { it.instance } val activeSlotComponent: SlotComponent? = children.selected?.let { children.items[it] }?.instance containerContent( /* innerTabs= */ { modifier -> LazyRow(modifier) { itemsIndexed(slots) { index: Int, item: SlotComponent -> tabContent(item, { navigationComponent.onTabCloseClicked(index) }) } } }, /* slotComponent= */ activeSlotComponent ) }
Здесь я специально добавил типы, так что давайте посмотрим, что мы получили:
-
children
— актуальное состояние навигации, над которым мы работали в предыдущих пунктах. Так как под капотом тамoperator fun State.getValue(...)
, мы вместо обращения к полюvalue
можем сразу распаковать объект c состоянием черезby
. -
slots
— из состояния получаем инстансы дочерних компонентов навигации (слотов). -
И несложной махинацией получаем активный компонент через индекс
selected
(он nullable, потому что в представленной навигации есть возможность закрыть все вкладки).
А дальше мы просто передаем эту информацию в лябду containerContent
.
Финалочка
И как использовать такой компонент?
ContainerContent( navigationComponent = containerComponent, tabContent = { slotComponent, onClose -> // Здесь рисуем табы }, containerContent = { innerTabs, slotComponent -> // Здесь рисуем контент открытой вкладки } )
И получаем пример такой код использования
fun main() { val containerComponent = runOnUiThread { DefaultContainerComponent( componentContext = DefaultComponentContext(...), configurations = listOf(Config("readme.txt"), Config("build.gradle.kts")), serializer = Config.serializer(), slotFactory = { componentContext, config -> DefaultSlotComponent(componentContext, config.filename) } ) } application { Window(...) { ContainerContent( navigationComponent = containerComponent, tabContent = { slotComponent, onClose -> // Здесь рисуем табы Card { Row { Text(slotComponent.model.subscribeAsState().value.title) Button(onClose) { Text("x") } } } }, containerContent = { innerTabs, slotComponent -> // Здесь рисуем контент открытой вкладки Column(rootStyle) { innerTabs(Modifier.fillMaxWidth()) Spacer(spacerStyle) LazyColumn(Modifier.fillMaxSize()) { item { SlotContent( component = slotComponent, modifier = Modifier.fillMaxSize() ) } } } } ) } } }
В реальной жизни для работы состояний компонентов также нужно поставлять в Window
контроллер жизненного цикла
LifecycleController( lifecycleRegistry = lifecycle, windowState = windowState, windowInfo = LocalWindowInfo.current, )
а также нужно научить вкладки меняться местами. Для этого можно использовать кнопки или жесты (на гифке используются жесты, однако это не вариант для просмотра изображений — тут уже отлично впишутся обработчики нажатий на клавиатуру и кнопки со стрелочками).
Ремарка по UI
Так как и вкладкам, и контенту слота доступен SlotContent
, мы можем хранить в его состоянии title
, а также делать этот title
динамичным, изменяя его при каждом обновлении жизненного цикла:
interface SlotComponent { val model: Value<Model> data class Model( val counter: Int, val title: String, val body: String ) }
Именно так и реализовано в исходном коде. При создании слота мы подписываемся на жизненный цикл:
init { lifecycle.subscribe( onStart = handler::resume, onStop = handler::pause, ) stateKeeper.register(key = KEY_STATE, strategy = State.serializer()) { handler.state.value } }
где используем ранее созданный обработчик событий жизненного цикла
class DefaultSlotComponent( componentContext: ComponentContext, private val tabTitle: String ) : ComponentContext by componentContext, SlotComponent { // retainedInstance регистрирует для onDestroy события и возвращает Handler. private val handler = retainedInstance { Handler( initialState = stateKeeper.consume(key = KEY_STATE, strategy = State.serializer()) ?: State(title = "(created) $tabTitle"), tabTitle = tabTitle ) } ... }
А дальше просто говорим, что делать при определенных событиях:
private class Handler(initialState: State, private val tabTitle: String) : InstanceKeeper.Instance { val state: MutableValue<State> = MutableValue(initialState) private var job: Job? = null fun resume() { state.update { it.copy(title = "(active) $tabTitle") } ...что-то с job... } fun pause() { state.update { it.copy(title = "(paused) $tabTitle") } ...что-то с job... } override fun onDestroy() { pause() } }
Обработчик хранит в себе состояние нашего компонента и отвечает за жизненный цикл. Чтобы это состояние стало доступно извне, нужно его передать в переменную model
, которую мы определили в интерфейсе SlotComponent#model
. А сделать состояние доступным извне проще простого:
class DefaultSlotComponent(...) : ComponentContext by componentContext, SlotComponent { private val handler = retainedInstance {...} /* Просто вытаскиваем его из Handler и маппим из внутренного представления в общедоступное, как делали это ранее в stateMapper */ override val model: Value<Model> = handler.state.map { it.toModel() }
И в завершение
ссылка на оригинал статьи https://habr.com/ru/articles/872164/
Добавить комментарий