Site icon AppTractor

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

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

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

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

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

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

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

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

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

height = sin(time)

то:

Это не волна, а просто горизонтальная линия, двигающаяся вверх и вниз. Поэтому представьте «волну» на стадионе. Сначала встаёт человек 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. Базовые интервалы сетки

2. Row 0 — поверхность: crest + sway

3. Row 1 — слой под поверхностью: trail

Вот и всё. Урок математики окончен. Главный результат — плавная анимация без пропусков кадров.

Exit mobile version