Connect with us

Разработка

Забавные анимации с меш-градиентами в Jetpack Compose 1.12

Теперь оно появилось: в Jetpack Compose 1.12 представлен MeshGradientPainter.

Опубликовано

/

     
     

В Jetpack Compose всегда были интерфейсы для линейных и радиальных градиентов. Но если хотелось получить более сложное смешивание цветов, подходящего встроенного решения не было. Теперь оно появилось: в Jetpack Compose 1.12 представлен MeshGradientPainter.

Как работает MeshGradient

Представьте сетчатый градиент как сетку из точек на экране. Для каждой точки нужно задать две вещи:

  1. Её положение по координатам X и Y.
  2. Её цвет.

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)
    )
}

Анимация волн

Хороший способ сделать экран более живым — добавить простую анимацию воды.

Волну можно представить как синусоиду.

Представьте колесо обозрения, которое вращается с постоянной скоростью. Если следить только за своей высотой над землёй, получится следующее:

Забавные анимации с меш-градиентами в Jetpack Compose 1.12

  • В нижней точке вы начинаете подниматься медленно.
  • В середине движетесь вверх быстро.
  • В верхней точке снова замедляетесь, на мгновение почти останавливаетесь, а затем плавно начинаете двигаться вниз.

Эта естественная плавная кривая, где движение замедляется у крайних точек и ускоряется в середине, — это синусоида. Её можно использовать и для изображения водной волны.

В нашем сетчатом градиенте поверхность воды состоит из 6 точек — столбцы от 0 до 5.

Если просто задать всем шести точкам:

height = sin(time)

то:

  • все 6 точек одновременно поднимутся;
  • все 6 точек одновременно опустятся.

Это не волна, а просто горизонтальная линия, двигающаяся вверх и вниз. Поэтому представьте «волну» на стадионе. Сначала встаёт человек 0. Через небольшую долю секунды — человек 1. Потом человек 2, человек 3 и так далее. Поскольку каждый столбец начинает движение с небольшой задержкой, гребень волны визуально перемещается по экрану слева направо.

В коде эта задержка выглядит просто:

Height = sin(time − delay)

Но у этой формулы есть проблема. Современные экраны смартфонов могут обновляться 120 раз в секунду.

Помним, что в каждом ряду есть 6 отдельных точек.

За одну секунду при 120 кадрах в секунду экран перерисовывается 120 раз:

  1. Только Row 0, 6 точек: 6 × 120 = 720 вычислений в секунду.
  2. Только Row 1, 6 точек: 6 × 120 = 720 вычислений в секунду.
  3. Row 0 + Row 1, всего 12 точек: 12 × 120 = 1440 вычислений в секунду.

Математический трюк — формула разности аргументов (или формула вычитания углов).

sin(time−delay)= sin(time)cos(delay) — cos(time)sin(delay)

Здесь можно разделить вычисления на время и пространство.

  1. Время (ωt). На весь экран есть только одни часы. Поэтому sin(time) и cos(time) можно вычислить один раз на кадр.
  2. Пространство (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.
Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: