DEV Community

Cover image for Beyond Static: Bringing Your Android UI to Life with Mesh Gradients
Omkar Deshmukh
Omkar Deshmukh

Posted on

Beyond Static: Bringing Your Android UI to Life with Mesh Gradients

Create stunning, interactive, and animated backgrounds in Jetpack Compose with the power of ComposeMeshGradient.

Apple release mesh gradients in WWDC 2024 for iOS 18 and Xcode 16 which allows developers to create intricate, dynamic backgrounds by defining a grid of colored points, enabling smooth transitions across multiple colors. Android developers have looked on with a hint of envy at the fluid, beautiful mesh gradients available natively in SwiftUI. While linear and radial gradients are useful, they can feel static and lifeless. The dynamic, organic feel of a mesh gradient can elevate a user interface from functional to delightful.

What if you could bring that same magic to Jetpack Compose?

I developed new library called ComposeMeshGradient, an open-source library that makes creating high-performance, animated, and interactive mesh gradients in Android not just possible, but easy.

What Exactly is a Mesh Gradient?

Think of a standard gradient as a simple blend between two or more colors along a single line. A mesh gradient, on the other hand, is like a flexible, colored net stretched across your screen. You define the colors at each intersection (or “vertex”) of the net, and the library smoothly interpolates the colors in between.

how mesh gradients work

The real power comes when you move those intersection points. By animating the vertices of the mesh, you can create everything from subtle, shifting color scapes to dramatic, interactive visual effects.
Because ComposeMeshGradient uses OpenGL ES for rendering, these animations are incredibly performant, ensuring a smooth 60 FPS experience without bogging down your UI thread.

Getting Started

Adding ComposeMeshGradient to your project is simple. Just add the dependency to your build.gradle.kts file:

// kotlin DSL
dependencies {
    implementation("io.github.om252345:composemeshgradient:0.1.0") // hosted on maven central.
}
Enter fullscreen mode Exit fullscreen mode

The core of the library is the MeshGradient composable. You give it a grid size, a list of colors, and an array of Offset points that define the shape of the mesh. You can animate Offsets or Colors by using jetpack compose animation apis.

How to use it

Use MeshGradient composable in you code, give it a grid of offset and colors for each offset. Define width and height of grid, Offset and Colors array sizes should be same and equal to width * height. Offsets are normalised position ranging from 0–1 in float.

    MeshGradient(
        width = 3,
        height = 3,
        points = arrayOf(
            Offset(0f, 0f), Offset(0.5f, 0f), Offset(1f, 0f),
            Offset(0f, 0.5f), Offset(0.5f, 0.5f), Offset(1f, 0.5f),
            Offset(0f, 1f), Offset(0.5f, 1f), Offset(1f, 1f)
        ),
        colors = arrayOf(
            Color.Red, Color.Magenta, Color.Blue,
            Color.Red, Color.Magenta, Color.Blue,
            Color.Red, Color.Magenta, Color.Blue
        ),
        modifier = modifier
    )
Enter fullscreen mode Exit fullscreen mode

Logically point 0.0, 0.0 represents left-top corner of device and 1,0 represents right top, 0,1 represnts bottom-left and 1,1 represents bottom-right. Colors also work same. Above code will create a gradient of red, magenta, blue in column like representation.

You can animate a point of color as follows,

val infiniteTransition = rememberInfiniteTransition()
    val animatedMiddleOffset = infiniteTransition.animateOffset(
        initialValue = Offset(0.0f, 0.0f),
        targetValue = Offset(1f, 1f),
        animationSpec = infiniteRepeatable(
            animation = tween(1500, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        )
    )

    MeshGradient(
        width = 3,
        height = 3,
        points = arrayOf(
            Offset(0f, 0f), Offset(0.5f, 0f), Offset(1f, 0f),
            Offset(0f, 0.5f), animatedMiddleOffset.value, Offset(1f, 0.5f),
            Offset(0f, 1f), Offset(0.5f, 1f), Offset(1f, 1f)
        ),
        colors = arrayOf(
            Color.Red, Color.Magenta, Color.Blue,
            Color.Red, Color.Magenta, Color.Blue,
            Color.Red, Color.Magenta, Color.Blue
        ),
        modifier = modifier
    )
Enter fullscreen mode Exit fullscreen mode

Here we have created a inifiniteTransition in jetpack compose and using animateOffset() api provided in this library. If you use This animatedOffset in you points grid, it will animate that points based on inifiniteTransition parameters. As simple as that. Similarly you can animate colors grid as well. This gives unlimited combinations you can create with Mesh gradients library. This api is very similar to what SwiftUI does.

Real world examples

Use Case 1: The Spotify “Lava Lamp”

Spotify’s now-playing screen is a masterclass in ambient UI. The background is a slowly morphing, fluid gradient that pulls colors from the album art. It’s engaging without being distracting.

We can achieve this exact effect using ComposeMeshGradient with a SimplexNoise animation. This algorithm generates smooth, natural-looking random values, which we can use to gently move the internal points of our mesh.

Example Code: SimplexNoiseMeshExample.kt (from samples in library)

By tying the colors array to the palette of a song's album art, you can create a dynamic, immersive listening experience that feels unique to every track.

example of simplex mesh animation

Use Case 2: The Headspace “Zen” Ripple

Meditation and wellness apps like Headspace or Calm often use interactive visuals to help users focus and relax. A common pattern is a “touch to interact” screen that responds with a calming animation.

Using ComposeMeshGradient, we can create a beautiful water-like ripple effect that emanates from every user tap. This provides immediate, satisfying feedback and creates a tranquil, zen-like experience.

The code below creates a 4x4 mesh and listens for tap gestures. Each tap generates a new "ripple" that travels outwards, distorting the mesh points as it passes.

Here’s how you can build it:

import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import io.github.om252345.composemeshgradient.MeshGradient
import io.github.om252345.composemeshgradient.rememberMeshGradientState
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
private data class Ripple(val center: Offset, val startTimeNanos: Long)
@Composable
fun InteractiveTouchMeshGradient(modifier: Modifier = Modifier) {
    val width = 4
    val height = 4
    val colors = remember {
        listOf(
            Color(0xff4AB8C2), Color(0xff4099B9), Color(0xff3B79A8), Color(0xff395B93),
            Color(0xff7FD3A9), Color(0xff5EC6B8), Color(0xff4099B9), Color(0xff3B79A8),
            Color(0xffB1E192), Color(0xff7FD3A9), Color(0xff5EC6B8), Color(0xff4099B9),
            Color(0xffDFF168), Color(0xffB1E192), Color(0xff7FD3A9), Color(0xff5EC6B8)
        )
    }
    val basePoints = remember {
        Array(width * height) { i ->
            val col = i % width
            val row = i / width
            Offset(x = col / (width - 1f), y = row / (height - 1f))
        }
    }
    val meshState = rememberMeshGradientState(points = basePoints)
    var viewSize by remember { mutableStateOf(IntSize.Zero) }
    val ripples = remember { mutableStateListOf<Ripple>() }
    LaunchedEffect(Unit) {
        val currentPoints = basePoints.map { it }.toMutableList()
        while (true) {
            withFrameNanos { frameTimeNanos ->
                for (i in basePoints.indices) {
                    currentPoints[i] = basePoints[i]
                }
                val ripplesToRemove = mutableListOf<Ripple>()
                for (ripple in ripples) {
                    val elapsedTimeMillis = (frameTimeNanos - ripple.startTimeNanos) / 1_000_000f
                    val rippleDuration = 1500f
                    if (elapsedTimeMillis > rippleDuration) {
                        ripplesToRemove.add(ripple)
                        continue
                    }
                    val progress = elapsedTimeMillis / rippleDuration
                    val currentRadius = progress * 1.5f
                    val waveWidth = 0.2f
                    for (i in currentPoints.indices) {
                        val point = basePoints[i]
                        val dx = point.x - ripple.center.x
                        val dy = point.y - ripple.center.y
                        val distanceToCenter = sqrt(dx * dx + dy * dy)
                        if (distanceToCenter > currentRadius - waveWidth && distanceToCenter < currentRadius + waveWidth) {
                            val distanceFromWaveCenter = distanceToCenter - currentRadius
                            val waveFactor = (sin((distanceFromWaveCenter / waveWidth) * PI + (progress * 2 * PI)) + 1) / 2
                            val amplitude = (1 - progress) * 0.1f
                            val pushFactor = waveFactor * amplitude
                            val angle = atan2(dy, dx)
                            val displacedX = currentPoints[i].x + cos(angle) * pushFactor
                            val displacedY = currentPoints[i].y + sin(angle) * pushFactor
                            currentPoints[i] = Offset(displacedX, displacedY)
                        }
                    }
                }
                ripples.removeAll(ripplesToRemove)
                launch {
                    meshState.snapAllPoints(currentPoints)
                }
            }
        }
    }
    Box(
        modifier = modifier
            .onSizeChanged { viewSize = it }
            .pointerInput(Unit) {
                detectTapGestures(
                    onTap = { offset ->
                        if (viewSize.width > 0 && viewSize.height > 0) {
                            ripples.add(
                                Ripple(
                                    center = Offset(
                                        x = offset.x / viewSize.width,
                                        y = offset.y / viewSize.height
                                    ),
                                    startTimeNanos = System.nanoTime()
                                )
                            )
                        }
                    }
                )
            }
    ) {
        MeshGradient(
            modifier = Modifier.fillMaxSize(),
            width = width,
            height = height,
            points = meshState.points.toTypedArray(),
            colors = colors.toTypedArray()
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Use Case 3: Dynamic Weather App Backgrounds

Imagine a weather app that doesn’t just show you an icon of the sun; it makes you feel the weather. With ComposeMeshGradient, you can dynamically change the background’s colors to reflect the current conditions.

This isn’t about a complex animation, but about reactivity. By observing a state (e.g., from a ViewModel), you can seamlessly swap the colors array passed to your MeshGradient.

@Composable
fun WeatherBackground(weatherState: WeatherState) {
// Define color palettes for different weather conditions
    val sunnyColors = remember { arrayOf(Color.Yellow, Color.Cyan, /*...*/) }
    val rainyColors = remember { arrayOf(Color.DarkGray, Color.Blue, /*...*/) }
    val snowyColors = remember { arrayOf(Color.White, Color.LightGray, /*...*/) }
    // Select the colors based on the current state
    val currentColors = when (weatherState.condition) {
        "Sunny" -> sunnyColors
        "Rainy" -> rainyColors
        "Snowy" -> snowyColors
        else -> sunnyColors
    }

    // Animate the color change for a smooth transition
    val animatedColors = currentColors.map {
        animateColorAsState(targetValue = it, animationSpec = tween(1000)).value
    }
    // Use a simple static mesh for the background
    MeshGradient(
        width = 3,
        height = 3,
        points = /* ... static points ... */,
        colors = animatedColors.toTypedArray(),
        modifier = Modifier.fillMaxSize()
    )
}
Enter fullscreen mode Exit fullscreen mode

This creates a subtle, elegant UI that feels more connected to the data it’s presenting.

The Future is Fluid
Static designs are a thing of the past. Modern, engaging user interfaces are fluid, interactive, and delightful. ComposeMeshGradient provides a powerful and performant tool to help Android developers build these next-generation experiences.

Whether you’re creating an ambient music player, a calming wellness app, or just want to add a touch of life to your UI, give ComposeMeshGradient a try.

Find the full library and more examples on GitHub!

Top comments (0)