Анимация смены темы в Android-версии Telegram на протяжении долгого времени вдохновляет разработчиков на попытки реверс-инжениринга этого красивого трюка: в сети немало подробных гайдов, как сделать подобную анимацию при помощи традиционных XML View и даже Flutter. Но реализаций этой элегантной (хоть и совершенно бесполезной) анимации на Jetpack Compose мне найти так и не удалось, что привело к созданию маленькой библиотеки для анимирования смены темы при помощи этой технологии.
Вера в будущее KMP также подтолкнула меня к тому, чтобы сделать ее из коробки готовой к использованию в Compose-Multiplatform проектах, с поддержкой всех основных платформ (Android, iOS, Desktop JVM, Web WASM+JS).
Хотя сама библиотека вышла крайне компактной, ее реализация оказалась довольно нетривиальной и на мой субъективный взгляд может быть интересна каждому, кто изучает Compose или ищет подобные решения для своего проекта.

На старте написания библиотеки сами собой возникли ряд требований, которым она должна была отвечать:
Лаконичное API: минимум действий со стороны пользователя библиотеки по подключению анимации. Желательно, чтобы максимум логики жило под капотом и только там.
Широкие возможности кастомизации: доступ к переопределению анимации на полностью кастомную, чтение состояние темы из произвольного источника данных.
Автоматическая поддержка любой кастомной темы (а не только, например, гугловой
MaterialTheme).
Исходя из этих ограничений, напрашивалось решение, подобное реализованному в Telegram: при смене темы сделать "скриншот" экрана в старой теме, затем еще один в новой а анимировать изображение от одного состояния к другому. Что ж, легко сказать...
Как известно, Compose отрисовывает UI в 3 фазы: Composition, Layout, Drawing. В данном случае нас интересует только последняя. После ряда проб и ошибок (таких как отрисовка абсолютно черного экрана вместо анимации, промелькивание целевой темы на один кадр и так далее) пришло понимание, что технически полный пайплайн анимации должен выглядеть так:
Пользователь библиотеки подает команду на анимацию сменой состояния.
Наш
Modifier.Nodeзахватывает текущее изображение интерфейса (все еще в старой теме) и отчитывается командой в ответ: мол, "записано, давай анимироваться".Менеджер состояния меняет обозреваемый в Compose флаг на целевую тему и отдает команду о перехвате отрисовки
Получая новую тему уже из скоупа композиции,
Modifier.Nodeанимирует отрисовку
Может показаться, что некоторые шаги в этом алгоритме избыточны, но на деле изъятие хотя бы одного из них порождает визуальные баги, причем иногда самым неожиданным образом.
Самая мякотка реализации занимает всего около 130 строк кода, поэтому позволю себе подушнить и привести ее полностью:
internal class ThemeAnimationNode(
private var graphicsLayer: GraphicsLayer,
private var state: ThemeAnimationState,
private var isDark: Boolean,
) : Modifier.Node(), DrawModifierNode {
private var animationProgress = 0f
private var prevImageBitmap: ImageBitmap? = null
private var currentImageBitmap: ImageBitmap? = null
private var animationPhase = AnimationPhase.Idle
private enum class AnimationPhase {
Idle,
InterceptDraw,
Animate,
}
override fun onAttach() {
super.onAttach()
observeRecordRequests()
}
private var recordRequestsJob: Job? = null
private fun observeRecordRequests() {
recordRequestsJob?.cancel()
recordRequestsJob = coroutineScope.launch {
state.requestRecord.collectLatest { request ->
when (request) {
RecordStatus.RecordRequested -> {
recordInitialImage()
state.requestRecord.value = RecordStatus.Recorded
}
RecordStatus.PrepareForAnimation -> {
animationPhase = AnimationPhase.InterceptDraw
}
RecordStatus.Initial,
RecordStatus.Recorded -> Unit
}
}
}
}
fun updateState(
newGraphicsLayer: GraphicsLayer,
newState: ThemeAnimationState,
newIsDark: Boolean,
) {
graphicsLayer = newGraphicsLayer
if (state != newState) {
state = newState
observeRecordRequests()
}
if (isDark != newIsDark) {
isDark = newIsDark
runAnimation()
}
}
private var animationJob: Job? = null
private fun runAnimation() {
animationJob?.cancel()
animationJob = coroutineScope.launch {
runAnimationSuspend()
}
}
private suspend fun runAnimationSuspend() {
animationProgress = 0f
animationPhase = AnimationPhase.Animate
prevImageBitmap = currentImageBitmap
currentImageBitmap = graphicsLayer.toImageBitmap()
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = state.animationSpec
) { value, _ ->
animationProgress = value
invalidateDraw()
}
animationPhase = AnimationPhase.Idle
invalidateDraw()
}
override fun ContentDrawScope.draw() {
val old = prevImageBitmap
val new = currentImageBitmap
val progress = animationProgress
val phase = animationPhase
val position = state.buttonPosition
when (phase) {
AnimationPhase.InterceptDraw if old != null -> {
drawImage(old)
}
AnimationPhase.Animate if old != null && new != null -> {
drawImage(old)
with(state.format) {
drawAnimationLayer(
image = new,
progress = progress,
pressPosition = position,
useDynamicContent = state.useDynamicContent
)
}
}
else -> {
// Record content to graphicsLayer for future snapshots
graphicsLayer.record {
this@draw.drawContent()
}
// Draw the recorded layer
drawLayer(graphicsLayer)
}
}
}
private suspend fun recordInitialImage() {
val image = graphicsLayer.toImageBitmap()
prevImageBitmap = image
currentImageBitmap = image
}
}К этому коду привело несколько интересных наблюдений, на которых хотелось бы остановиться подробнее. Во-первых, запись старой темы важно сделать до того, как состояние в скоупе композиции обновилось. Во-вторых, после успешной записи и до того, как смена темы достигла композиции, нужно перевести отрисовку в режим перехвата: рисуем старую картинку, еще не пускаем новую, но и дефолтный контент тоже не пропускаем (в противном случае перед анимацией на один кадр мелькнет новая тема). В-третьих, анимацию нужно начинать только после того, как обновленный флаг темы достигнет композиции, иначе экран в новом состоянии не успеет записаться (о рекомпозиции нам сигнализирует updateState).

И наконец, графический слой, на который мы записываем содержимое экрана, должен быть сразу после этого отрисован, иначе при попытке анимации может иногда отобразиться... черный экран. То есть вот так нельзя:
graphicsLayer.record {
this@draw.drawContent()
}
drawContent()Кто-то посчитает эти четыре строчки кода грубой ошибкой и богохульством, но их фатальная неправильность не была так уж очевидна лично для меня в момент написания.
В результате всех ухищрений наконец получаем красивую анимацию с круговым расширением темы от кнопки к краям экрана:
На этом, в общем-то, статью можно было бы и закончить. Но в действительности написание библиотеки на этом было завершено лишь наполовину. Прекрасно понимая, что реальные бизнес-задачи зачастую диктуют совершенно неожиданные вещи, я потратил еще несколько дней, чтобы сделать библиотеку как можно более гибкой и масштабируемой:
Добавилась возможность анимированной иконки на кнопке смены темы. В качестве фреймворка векторных анимаций использована Lottie и ее порт в Compose Multiplatform - Compottie.
Добавился провайдер смены темы, в виде интерфейса
ThemeProvider, который можно переопределить любой реализацией, в том числе опираясь на локальный или удаленный персистентный источник данных.
Чтобы убедиться, что механизм ThemeProvider работает действительно хорошо, я написал небольшую подбиблиотеку, предоставляющую кроссплатформенное локальное персистентное хранилище выбранной темы. Ее можно подключить отдельно, если есть желание воспользоваться коробочным решением. Под капотом оно использует Androidx DataStore для всех платформ и localStorage для Kotlin WASM/JS (потому что DataStore не предоставляется для браузерных таргетов). Репозиторий и краткая документация обеих библиотек публично доступен тут: жмяк.
А на этом мой рассказ об этом маленьком эксперименте с анимациями в Compose подошел к концу и до новых встреч на просторах Хабра.
