Blog Infos
Author
Published
Topics
, , , ,
Published

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.

iOS Presentation of how mesh gradient works. Gif from Apple Educational video about Mesh gradient.

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

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
)

Logically point 0.0, 0.0 represents left-top corner of device and 1,0 represents right top, 0,1 represents 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
    )

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.

Library implemented as below,
1. The Composable Layer (MeshGradient.kt)

At its core, the MeshGradient composable wrapper. It doesn’t draw anything directly in Compose. Instead, it uses the AndroidView composable to embed a classic Android GLSurfaceView into your Compose UI. This GLSurfaceView is a dedicated surface for rendering high-performance 2D graphics using OpenGL ES. Idea to choose OpenGL is to support complex animation with high frame rate.

The magic happens in the update block of the AndroidView. Whenever the points or colors arrays you provide to the MeshGradient composable change (for example, during an animation), this block is triggered. It then safely passes the new data to the OpenGL rendering thread.

2. The Rendering Engine (MeshGradientRenderer.kt)

This class is the main engine that drives the OpenGL rendering. It implements the GLSurfaceView.Renderer interface and has a few key responsibilities:

  • onSurfaceCreated(): This is called once to set up the OpenGL environment. It creates an instance of MeshGradientRendererHelper which prepares the actual mesh and the shader programs.
  • updatePoints(): This is the method called from the update block in the MeshGradient composable. It updates the internal currentPoints and currentColors that will be used in the next frame.
  • onDrawFrame(): This method is called for every single frame that needs to be rendered. Its only job is to clear the screen and tell the MeshGradientRendererHelper to draw the mesh using the most up-to-date points and colors.
3. The Mesh and Shader Setup (MeshGradientRendererHelper.kt)

This helper class does the heavy lifting for the GPU.

  • Geometry Creation: It doesn’t just draw a simple 4×4 grid. To create a smooth gradient, it creates a highly subdivided grid (e.g., 64×64 subdivisions per cell). This generates thousands of tiny triangles (vertices) that the GPU can color individually, which is the key to the smooth appearance. It calculates their positions and stores them in uvBuffer and indexBuffer.
  • Shader Compilation: This is the most critical part. It compiles two small programs called shaders that will run directly on the GPU.
  • Vertex Shader: This program runs for every single vertex in that highly subdivided grid. Its main job is to figure out its final position and color. It takes the 4x4 control points and colors you provided and uses a Catmull-Rom interpolation to calculate a smooth, curved value. This is what creates the organic, non-linear look of the gradient.
  • Fragment Shader: This program runs for every single pixel on the screen. It takes the interpolated color calculated by the vertex shader and simply paints the pixel with that color.
4. State Management for Animation (MeshGradientState.kt)

This is a state holder that makes animations easy and efficient.

  • rememberMeshGradientState creates and remembers an instance of MeshGradientState.
  • The state holder contains the list of mesh points as Animatable values.
  • The snapAllPoints() function allows for very fast, per-frame updates of the mesh points without the overhead of standard Compose state recomposition, making it perfect for the LaunchedEffect loops used in the animation examples.

In summary, the workflow is:

Compose UI (ProgressButtonWithMesh) -> Updates MeshGradientState -> Triggers MeshGradient recomposition -> AndroidView queues an update -> MeshGradientRenderer receives new points/colors -> onDrawFrame tells MeshGradientRendererHelper to draw -> GPU runs shaders to interpolate and render the smooth gradient.

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.

Android device screen recording.

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

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

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Reimagining Android Dialogs with Jetpack Compose

Traditional Android dialogs are hard to test, easy to leak, and painful to customize — and in a world of Compose-first apps, they’re overdue for an upgrade.
Watch Video

Reimagining Android Dialogs with Jetpack Compose

Keith Abdulla
Staff Android Engineer, Design Systems
Block

Reimagining Android Dialogs with Jetpack Compose

Keith Abdulla
Staff Android Engine ...
Block

Reimagining Android Dialogs with Jetpack Compose

Keith Abdulla
Staff Android Engineer, D ...
Block

Jobs

Use Case 4: UI Component

ComposeMeshGradient is a very powerful library, you can be very creating of mixing and creating new patterns for each UI element in android. Below gif is a submit button which shows loading mesh animation and shimmer effect after loading is done. It is pure Mesh Gradient, no other animations used in background.

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!

This article was previously published on proandroiddev.com.

Menu