Анимация смены темы в 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. В данном случае нас интересует только последняя. После ряда проб и ошибок (таких как отрисовка абсолютно черного экрана вместо анимации, промелькивание целевой темы на один кадр и так далее) пришло понимание, что технически полный пайплайн анимации должен выглядеть так:

  1. Пользователь библиотеки подает команду на анимацию сменой состояния.

  2. Наш Modifier.Node захватывает текущее изображение интерфейса (все еще в старой теме) и отчитывается командой в ответ: мол, "записано, давай анимироваться".

  3. Менеджер состояния меняет обозреваемый в Compose флаг на целевую тему и отдает команду о перехвате отрисовки

  4. Получая новую тему уже из скоупа композиции, 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 подошел к концу и до новых встреч на просторах Хабра.