Site icon AppTractor

Создание изменяемого циферблата в стиле Ferrari с помощью Compose Multiplatform

Я высоко ценю дизайн, выходящий за рамки функциональности — тот, в котором чувствуется замысел, стоящий за каждой деталью. Поэтому, когда Ferrari представила концепт информационно-развлекательной системы Luce, созданный в сотрудничестве с дизайн-студией Джони Айва LoveFrom, я остановил скроллинг. Показанная приборная панель была не просто красивой — это был отличный пример лаконичного дизайна в сочетании с восхитительными анимациями. Один элемент особенно привлек мое внимание: единый круглый циферблат, плавно трансформирующийся из часов в секундомер и компас. Минималистично, уверенно и дотошно изысканно.

Для меня хороший UX — это именно это: отсутствие препятствий, наличие намерения. Не просто то, что всё работает, а то, что это ощущается правильно — что каждый переход плавный, каждая пропорция продумана, каждая деталь заслужена. Когда я увидел этот циферблат, я подумал: я хочу это создать. Не для того, чтобы выпустить продукт, а чтобы понять его, почувствовать пальцами, сколько труда вложено в то, что выглядит таким лёгким.

Я выбрал Compose Multiplatform — фреймворк, который я хорошо знаю и искренне люблю, — и начал создавать.

Отказ от ответственности

Этот проект предназначен исключительно для образовательных целей и не связан, не ассоциирован, не авторизован, не одобрен и никоим образом официально не связан с Ferrari S.p.A., LoveFrom или любыми их дочерними или аффилированными компаниями. «Ferrari», логотип «Гарцующий конь», название «Luce» и все связанные с ним дизайны являются зарегистрированными товарными знаками и исключительной интеллектуальной собственностью Ferrari S.p.A. Этот проект не приносит дохода и существует исключительно как техническое исследование для сообщества разработчиков.

Что делаем

Циферблат имеет три режима, каждый со своим уникальным визуальным оформлением:

Переключение между режимами происходит плавно. Все меняется: цвет циферблата, форма центра, длина делений, стрелки — все анимируется одновременно. Центр часов в форме капсулы расширяется вбок в корпус дополнительных циферблатов секундомера или сжимается в лаконичный круг компаса. Эта хореография самая сложная задача, но и самая приятная, когда все получится.

Техническая сторона

Рисование циферблата: Canvas и полярные координаты

Весь циферблат нарисован на холсте Compose Canvas с использованием только линий, дуг, контуров и текста. Нет изображений или векторных ресурсов — все создано процедурно.

Основная геометрия — полярная. Каждая метка, стрелка и надпись размещаются путем преобразования угла и радиуса в декартовы координаты:

val x = center.x + (radius * cos(angleRad)).toFloat()
val y = center.y + (radius * sin(angleRad)).toFloat()

Стрелки часов отрисованы как объекты Path — прямоугольник со скругленным верхом (полукругом arcTo) и опциональным хвостом для секундной стрелки. Правильное определение направления дуги на холсте Compose (ось Y направлена ​​вниз, поэтому вращение по часовой стрелке соответствует положительному направлению) потребовало тщательного обдумывания.

Масштабирование стало интересной задачей. Поскольку циферблат должен выглядеть одинаково при любом размере и плотности экрана, я использую единый коэффициент sizeRatio, вычисленный на основе фактической ширины холста в пикселях:

sizeRatio = canvasWidth / 1144f  // 1144px is the base design size

Каждый размер — длина делений, ширина штрихов, ширина линии, размер шрифта — выражается как кратное значению sizeRatio. Важная деталь: для рисования на холсте нельзя использовать dp.toPx(), поскольку toPx() учитывает плотность дисплея. На дисплее с разрешением 2× холст уже имеет вдвое больше пикселей; значение dp, которое также удваивается, приводит к непоследовательным пропорциям на разных экранах. Решение состоит в работе с чистыми долями пикселя от размера холста.

Для текста тот же принцип применяется к sp: вместо fontSize = 48.sp * sizeRatio (который включает коэффициент DPI дисплея из sp), я вычисляю точный размер холста в пикселях и преобразую его обратно в sp, нейтрализуя плотность и масштаб шрифта:

fontSize = (48f * sizeRatio / density / fontScale).sp

Анимации Compose: морфинг состоянияPermalink

Эффект морфинга обеспечивается функцией Compose updateTransition, декларативным API, который синхронно управляет несколькими анимациями. Связывая этот единый переход с несколькими методами расширения — такими как animateColor для циферблата или animateDp для размеров и положений — мы можем точно определить, как должно выглядеть каждое свойство для наших трех состояний: Часы, Секундомер и Компас.

Синтаксис удивительно прост: вы просто определяете целевое значение для каждого состояния, а Compose позаботится обо всем остальном, обеспечивая плавные анимации с красиво интерполированными значениями. Поскольку вся система привязана к текущему состоянию uiState, переключение режимов запускает скоординированную хореографию, в которой каждый визуальный элемент «знает», куда ему нужно переместиться:

val transition = updateTransition(targetState = uiState, label = "DialTransition")

val outerBackgroundColor by transition.animateColor(
    transitionSpec = { tween(600) }, label = "FaceColor"
) { state ->
    when (state) {
        is UiState.Clock     -> Color(0xFFCCCCCC)
        is UiState.Stopwatch -> Color(0xFFFFDD00)
        is UiState.Compass   -> Color(0xFFCCCCCC)
    }
}

val primaryTickLength by transition.animateDp(
    transitionSpec = { tween(600) }, label = "TickLength"
) { state ->
    when (state) {
        is UiState.Clock -> 36.dp
        is UiState.Stopwatch -> 48.dp
        is UiState.Compass -> 24.dp
    }
}

Что касается углов наклона стрелок, я узнал кое-что неочевидное: transition.animateFloat предназначен для дискретных целевых значений состояния, а не для непрерывно изменяющихся значений. Использование его для отслеживания секунд в реальном времени приводило к тому, что стрелки анимировались назад каждые 60 секунд — в анимации целевой угол перескакивал с ~360° на ~0° и выбирал кратчайший путь. Решение заключалось в том, чтобы вычислять углы наклона стрелок непосредственно из прошедшего времени в каждом кадре (без анимации) и использовать систему переходов только для двух безразмерных коэффициентов интерполяции:

val nonCompassLerp by transition.animateFloat(...) { state ->
    if (state is UiState.Compass) 0f else 1f
}
val stopwatchLerp by transition.animateFloat(...) { state ->
    if (state is UiState.Stopwatch) 1f else 0f
}

Фактический угол стрелки затем представляет собой смесь положений «замороженных» часов и «замороженного» секундомера, масштабированную с учётом этих коэффициентов:

val secondsHandAngle = (clockSecondsAngle + (swSecondsAngle - clockSecondsAngle) * swRatio) * nonCompassLerp

Это также потребовало использования отдельных счетчиков времени для часов и секундомера, чтобы каждый из них «замирал», когда его режим неактивен. Таким образом, при переключении со секундомера обратно на часы стрелки анимируются с того места, где остановился секундомер, а не с того места, куда случайно перескочило время на часах при сбросе счетчика прошедшего времени.

Compose Multiplatform: одна кодовая база, четыре цели

Вся логика рендеринга находится в общем Main. Один и тот же код отрисовки Canvas работает на:

Матрица сборки проста, потому что Kotlin Multiplatform берет на себя основную работу: нет кода рендеринга для каждой платформы, нет логики определения размеров, специфичной для платформы. Система sizeRatio обрабатывает остальное.

Рабочий процесс GitHub Actions собирает дистрибутив Wasm при каждом изменении в main и автоматически развертывает его — без каких-либо ручных действий. Вы можете увидеть результат в реальном времени здесь:

Это не просто анимация — это полностью интерактивная демонстрация

То, что вы видите выше, — это не предварительно записанная последовательность: это живое интерактивное приложение, созданное с помощью Compose Multiplatform. Вы можете запустить его на Android, на компьютере или прямо здесь, в браузере — и оно будет вести себя одинаково везде.

Используйте кнопки для переключения между режимами и попробуйте:

Каждый переход между этими состояниями плавный и интерполированный благодаря описанной выше системе анимации Compose — но данные, лежащие в основе каждого режима, реальны (или реалистично смоделированы в случае направления компаса), что делает это полноценным интерактивным опытом, а не готовой демонстрацией.

Роль AIPermalink

Я начал создавать это в основном вручную — прорабатывал геометрию, работал с конечным автоматом, писал начальные анимации. Но в нескольких моментах помощь ИИ действительно имела большое значение.

Геометрия углов и траекторий стрелок циферблата (направления дуг, знаки развертки, преобразование координат) включает в себя достаточно граничных случаев в системе координат Compose y-вниз, поэтому наличие второй пары глаз, которая могла бы разобраться в расчетах и ​​заметить, когда я неправильно задал угол развертки, сэкономило реальное время.

Ошибка анимации — вращение стрелок назад на 60-й секунде — была незаметной. Первопричина (использование transition.animateFloat в качестве непрерывного трекера, хотя он предназначен для дискретных целей) не была очевидна, если смотреть на симптом. Обсуждение механизма вычисления значений «от»/«до» animateFloat помогло мне быстро найти решение.

Ошибка масштабирования плотности — непоследовательные пропорции текста и делений на разных дисплеях (и платформах) — потребовала понимания всей цепочки: пиксели холста → sizeRatiodp.toPx() → дополнительный коэффициент плотности. Точное определение места возникновения проблемы с плотностью позволило провести систематический аудит всего кода отрисовки.

И во всем этом цикл итераций «это выглядит неправильно, вот почему я так думаю, давайте разберемся» стал быстрее и точнее благодаря ИИ, способному учитывать полный контекст проблемы.

Что я узнал

Создание чего-то прекрасного, даже в миниатюре и даже в качестве учебного упражнения, требует той же тщательности, которая делает реальные продукты приятными на ощупь. Циферблат Ferrari Luce визуально имеет, возможно, десять движущихся частей. Его доскональная реализация включала в себя: полярную геометрию, конечные автоматы анимации, масштабирование, не зависящее от плотности, кроссплатформенный рендеринг, развертывание WebAssembly и немало отладки тонких ошибок.

Хороший UX — это не только финальный кадр. Это касается каждого кадра между ними.

Exit mobile version