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.