What You Will Build

A mesmerizing liquid fill animation inside a circular container, where a sine-wave surface undulates while the liquid level rises and falls. This effect is perfect for progress indicators, loading screens, or decorative UI in wellness and weather apps.

Why This Pattern Matters

Canvas-based animations in Jetpack Compose unlock effects that standard UI components cannot achieve. This project teaches you how to combine rememberInfiniteTransition with Canvas drawing, sine-wave math, and path clipping — skills that transfer to any custom graphical animation.

Key Compose Concepts

  • rememberInfiniteTransition — drives continuous animation without manual coroutine management.
  • Canvas composable — gives pixel-level control over drawing.
  • Path + clipPath — constrains the liquid to a circular boundary.

Step 1: Set Up the Infinite Transition

We need two animated values: a phase that sweeps from 0 to 2*PI to drive the wave, and a level that oscillates between 30% and 70% fill:

@Composable
fun LiquidAnimationScreen() {
    val infiniteTransition = rememberInfiniteTransition(label = "liquid")
    val phase by infiniteTransition.animateFloat(
        0f, (2 * Math.PI).toFloat(),
        infiniteRepeatable(tween(3000, easing = LinearEasing)),
        label = "phase"
    )
    val level by infiniteTransition.animateFloat(
        0.3f, 0.7f,
        infiniteRepeatable(tween(5000), RepeatMode.Reverse),
        label = "level"
    )
    // ... Canvas drawing next
}

Step 2: Draw the Liquid Surface with Sine Waves

Inside a Canvas, build a Path that traces a wavy surface by combining two sine functions at different frequencies. This creates a more organic, non-repeating wave:

Canvas(Modifier.size(250.dp)) {
    // Background circle
    drawCircle(Color(0xFF1A1A2E), size.width / 2)

    val path = Path()
    val waterLevel = size.height * (1f - level)
    path.moveTo(0f, size.height)
    for (x in 0..size.width.toInt()) {
        val y = waterLevel +
            15f * sin(x * 0.03f + phase) +
            8f * sin(x * 0.05f + phase * 1.5f)
        path.lineTo(x.toFloat(), y)
    }
    path.lineTo(size.width, size.height)
    path.close()

    // Clip to circle, then draw
    val clipPath = Path().apply {
        addOval(Rect(0f, 0f, size.width, size.height))
    }
    drawContext.canvas.save()
    drawContext.canvas.clipPath(clipPath.asAndroidPath())
    drawPath(path, Color(0xFF4ECDC4).copy(alpha = 0.7f))
    drawContext.canvas.restore()
}

Step 3: Wrap It in a Dark Theme

Box(
    Modifier.fillMaxSize().background(Color(0xFF0F0F23)),
    contentAlignment = Alignment.Center
) {
    // Canvas goes here
}

Tips and Pitfalls

  • Two sine waves are better than one. A single sine looks mechanical. Combining two at different frequencies and amplitudes creates an organic, liquid feel.
  • clipPath requires asAndroidPath(). Compose Path and Android Path are different types; you must convert before clipping.
  • Performance: Iterating pixel-by-pixel is fine for a 250dp canvas but can get expensive at full screen. Consider stepping by 2px for larger areas.
  • Color temperature: Teal on dark navy (0xFF4ECDC4 on 0xFF0F0F23) is a high-contrast combo that reads well. Lower the alpha for a more subtle water effect.

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.