Разработка
Забавные анимации с меш-градиентами в Jetpack Compose 1.12
Теперь оно появилось: в Jetpack Compose 1.12 представлен MeshGradientPainter.
В Jetpack Compose всегда были интерфейсы для линейных и радиальных градиентов. Но если хотелось получить более сложное смешивание цветов, подходящего встроенного решения не было. Теперь оно появилось: в Jetpack Compose 1.12 представлен MeshGradientPainter.
Как работает MeshGradient
Представьте сетчатый градиент как сетку из точек на экране. Для каждой точки нужно задать две вещи:
- Её положение по координатам X и Y.
- Её цвет.
Compose соединяет эти точки и автоматически смешивает цвета между ними.
(Light Blue) --------- (Light Blue) --------- (Light Blue) <-- Top
| | |
(Medium Blue) -------- (Medium Blue) -------- (Medium Blue) <-- Middle
| | |
(Dark Navy) ---------- (Dark Navy) ---------- (Dark Navy) <-- Bottom
На сайте Android Developers есть хороший пример, с которого можно начать.
MeshGradient для воды
Допустим, мы делаем приложение для отслеживания потребления воды и хотим визуально показывать, сколько воды выпил пользователь.
Чтобы получить более-менее реалистичный вид воды, я выбрал сетку 5×5, то есть 6 рядов точек. Вот как можно создать ощущение глубокой воды.
1. Цвет становится темнее с глубиной
- Верхний ряд: яркие бирюзовые оттенки и цвет пены — там, где на поверхность падает свет.
- Средние ряды: насыщенные океанические синие оттенки.
- Нижний ряд: тёмно-синий.
2. Двигается только поверхность
Чтобы вода выглядела глубокой, мы двигаем только верхний ряд точек. Нижние ряды остаются полностью неподвижными. Это создаёт ощущение спокойной и тяжёлой толщи воды под поверхностью.
Если нужна только водная заливка без анимации, код может выглядеть так:
@Composable
fun WaterGradient(modifier: Modifier = Modifier) {
val rowHeights = listOf(0.14f, 0.34f, 0.52f, 0.68f, 0.84f, 1.00f)
val rowColors = listOf(
Color(0xFF9BF0E8), // Row 0: Foam (Surface highlight)
Color(0xFF4FD6E8), // Row 1: Aqua (Sunlit shallow)
Color(0xFF2AB0E0), // Row 2: Clear blue
Color(0xFF1C86D4), // Row 3: Mid-depth
Color(0xFF1355B8), // Row 4: Deep ocean
Color(0xFF0B2E86) // Row 5: Dark abyss
)
val waterPainter = MeshGradientPainter(rows = 5, columns = 5, hasBicubicColor = true) {
for (row in 0..5) {
val y = rowHeights[row]
val color = rowColors[row]
for (col in 0..5) {
val x = col / 5.0f
setVertex(row, col, Offset(x, y), color)
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.paint(waterPainter)
)
}
Анимация волн
Хороший способ сделать экран более живым — добавить простую анимацию воды.
Волну можно представить как синусоиду.
Представьте колесо обозрения, которое вращается с постоянной скоростью. Если следить только за своей высотой над землёй, получится следующее:

- В нижней точке вы начинаете подниматься медленно.
- В середине движетесь вверх быстро.
- В верхней точке снова замедляетесь, на мгновение почти останавливаетесь, а затем плавно начинаете двигаться вниз.
Эта естественная плавная кривая, где движение замедляется у крайних точек и ускоряется в середине, — это синусоида. Её можно использовать и для изображения водной волны.
В нашем сетчатом градиенте поверхность воды состоит из 6 точек — столбцы от 0 до 5.
Если просто задать всем шести точкам:
height = sin(time)
то:
- все 6 точек одновременно поднимутся;
- все 6 точек одновременно опустятся.
Это не волна, а просто горизонтальная линия, двигающаяся вверх и вниз. Поэтому представьте «волну» на стадионе. Сначала встаёт человек 0. Через небольшую долю секунды — человек 1. Потом человек 2, человек 3 и так далее. Поскольку каждый столбец начинает движение с небольшой задержкой, гребень волны визуально перемещается по экрану слева направо.
В коде эта задержка выглядит просто:
Height = sin(time − delay)
Но у этой формулы есть проблема. Современные экраны смартфонов могут обновляться 120 раз в секунду.
Помним, что в каждом ряду есть 6 отдельных точек.
За одну секунду при 120 кадрах в секунду экран перерисовывается 120 раз:
- Только Row 0, 6 точек: 6 × 120 = 720 вычислений в секунду.
- Только Row 1, 6 точек: 6 × 120 = 720 вычислений в секунду.
- Row 0 + Row 1, всего 12 точек: 12 × 120 = 1440 вычислений в секунду.
Математический трюк — формула разности аргументов (или формула вычитания углов).
sin(time−delay)= sin(time)cos(delay) — cos(time)sin(delay)
Здесь можно разделить вычисления на время и пространство.
- Время (
ωt). На весь экран есть только одни часы. Поэтомуsin(time)иcos(time)можно вычислить один раз на кадр. - Пространство (
delay). Наши шесть столбцов находятся в фиксированных позициях X = 0.0, 0.2, 0.4, 0.6, 0.8 и 1.0. Их задержки никогда не меняются.
Поскольку задержки фиксированы, значения cos(delay) и sin(delay) тоже постоянны. Их можно заранее вычислить и сохранить в массивах LEAD и LAG:
// Precomputed values for each column: // Column: 0 1 2 3 4 5 private val LEAD = floatArrayOf(1.000f, 0.924f, 0.707f, 0.383f, 0.000f, -0.383f) // cos(delay) private val LAG = floatArrayOf(0.000f, 0.383f, 0.707f, 0.924f, 1.000f, 0.924f) // sin(delay)
Теперь значение волны для любого столбца вычисляется всего двумя быстрыми умножениями:
fun surface(col: Int): Float = LEAD[col] * sinTime - LAG[col] * cosTime
surface(col) возвращает плавно меняющееся значение от -1.0 до +1.0.
Расставляем точки с помощью Offset(X, Y)
1. Базовые интервалы сетки
- Шаг по X: 6 точек равномерно делят пространство на 5 интервалов:
1.0 / 5 = 0.20. - Шаг по Y: Row 0 начинается с
0.14, а не с0.0, чтобы оставить сверху запас 14% и гребень волны не обрезался краем экрана.
2. Row 0 — поверхность: crest + sway
- Положение по Y: базовая высота —
0.14. К ней прибавляетсяcrest * surface(col). Приcrest = 0.04поверхность колеблется от0.10до0.18. - Положение по X: внутренние точки немного смещаются влево и вправо на
sway * surface(col)сsway = 0.015. Совместное движение по X и Y создаёт более естественную почти круговую траекторию. - Фиксация краёв: у столбцов 0 и 5 координата X остаётся равной
0.0и1.0, то есть края прикреплены к границам экрана. При этом Y продолжает колебаться, поэтому создаётся ощущение, что волна входит в экран и выходит из него.
3. Row 1 — слой под поверхностью: trail
- Положение по Y: базовая высота —
0.34. К ней прибавляетсяtrail * surface(col). - При
trail = 0.016, то есть всего 40% амплитуды основной поверхности, этот слой слегка изгибается под гребнем, создавая иллюзию световых переливов в воде.
Вот и всё. Урок математики окончен. Главный результат — плавная анимация без пропусков кадров.
- 💻 Полный код: GitHub Repository
- 🌐 Демонстрация в браузере: Live Web Demo — всё это работает и в Kotlin Multiplatform.
-
Новости4 недели назадВидео и подкасты о мобильной разработке 2026.31
-
Разработка4 недели назадКак стать 10x-разработчиком с помощью проговаривания мыслей
-
Аналитика промо-кампаний4 недели назадКак проверить, упоминают ли нейросети ваш бренд: обзор пяти сервисов
-
Разработка4 недели назад50 вопросов о System Design, ответы на которые должен знать каждый Senior iOS-разработчик: часть 4
