DEV Community

exyte
exyte

Posted on

Jetpack Compose Tutorial: Replicating the Water Level Widget

Image description
Apple's apps and widgets always were a staple of design, and inspirations for our Replicating Series: Activity Application and Card application. When they announced the new Apple Watch Ultra, the design of the depth gauge widget caught our eye, and we thought it would be cool to replicate it on Android! As usual for our Android replicating challenges, we used the Jetpack Compose framework.

Image description
This article will tell you how we went about implementing it - creating a wave effect, having the water snap around the text, and blending colors. We feel like this will be useful both to beginners and those already acquainted with Jetpack Compose.

Water Level

First, let's consider the most trivial problem - how to calculate and animate the water level.

Image description

enum class WaterLevelState {
    StartReady,
    Animating,
}
Enter fullscreen mode Exit fullscreen mode

Next, we define the duration of the animation and the initial state:

val waveDuration by rememberSaveable { mutableStateOf(waveDurationInMills) }
var waterLevelState by remember { mutableStateOf(WaterLevelState.StartReady) }
Enter fullscreen mode Exit fullscreen mode

After that we need to define how the water's progress should change. It's necessary for recording the progress on the screen as text and for drawing the water level.

val waveProgress by waveProgressAsState(
    timerState = waterLevelState,
    timerDurationInMillis = waveDuration
)
Enter fullscreen mode Exit fullscreen mode

Here's a closer look at waveProgressAsState. We use animatable because it gives us a little more control and customization. For example, we can specify different animationSpec for different states.
Now to calculate the coordinates of the water's edge that needs to be drawn on the screen:

val waterLevel by remember(waveProgress, containerSize.height) {
    derivedStateOf {
        (waveProgress * containerSize.height).toInt()
    }
}
Enter fullscreen mode Exit fullscreen mode

After all this preliminary work we can move on to creating actual waves.

Waves

The most common way to simulate a wave is to use a sine graph that moves horizontally at a certain speed.

Image description

We want it to look more realistic, and it will have to flow over the elements on the screen, so we need a more sophisticated approach. The main idea of the implementation is to define a set of points representing the height of the wave. The values are animated to create the wave effect.

Image description

First, we create a list with points to store the values:

val points = remember(spacing, containerSize) {
    derivedStateOf {
        (-spacing..containerSize.width + spacing step spacing).map { x ->
            PointF(x.toFloat(), waterLevel)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, in the case of normal water flow when there are no obstacles in its path, we simply fill it with the values of the water level. We will consider the other cases later.

LevelState.PlainMoving -> {
    points.value.map {
        it.y = waterLevel
    }
}
Enter fullscreen mode Exit fullscreen mode

Consider an animation that will change the height of each point. Animating all the points would take a heavy toll on the performance and battery. So, in order to save resources, we will only use a small number of Float animation values:

@Composable
fun createAnimationsAsState1(
    pointsQuantity: Int,
): MutableList<State<Float>> {
    val animations = remember { mutableListOf<State<Float>>() }
    val random = remember { Random(System.currentTimeMillis()) }
    val infiniteAnimation = rememberInfiniteTransition()

    repeat(pointsQuantity / 2) {
        val durationMillis = random.nextInt(2000, 6000)
        animations += infiniteAnimation.animateFloat(
            initialValue = 0f,
            targetValue = 1f,
            animationSpec = infiniteRepeatable(
                animation = tween(durationMillis),
                repeatMode = RepeatMode.Reverse,
            )
        )
    }
    return animations
}
Enter fullscreen mode Exit fullscreen mode

To prevent the animation from repeating every 15 points and the waves from being identical, we can set the initialMultipliers:

@Composable
fun createInitialMultipliersAsState(pointsQuantity: Int): MutableList<Float> {
    val random = remember { Random(System.currentTimeMillis()) }
    return remember {
        mutableListOf<Float>().apply {
            repeat(pointsQuantity) { this += random.nextFloat() }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now to add the waves - iterate through all the points and calculate the new heights.

points.forEachIndexed { index, pointF ->
    val newIndex = index % animations.size

    var waveHeight = calculateWaveHeight(
        animations[newIndex].value,
        initialMultipliers[index],
        maxHeight
    )
    pointF.y = pointF.y - waveHeight
}
return points
Enter fullscreen mode Exit fullscreen mode

Adding initialMultipliers to currentSize will reduce the possibility of repeating values. And using linear interpolation will help smoothly change the height:

private fun calculateWaveHeight(
    currentSize: Float,
    initialMultipliers: Float,
    maxHeight: Float
): Float {
    var waveHeightPercent = initialMultipliers + currentSize
    if (waveHeightPercent > 1.0f) {
        val diff = waveHeightPercent - 1.0f
        waveHeightPercent = 1.0f - diff
    }

    return lerpF(maxHeight, 0f, waveHeightPercent)
}
Enter fullscreen mode Exit fullscreen mode

Now the most interesting part - how to make the water flow around UI elements.

Interactive water movement

We start by defining 3 states that water has when its level decreases. The PlainMoving name speaks for itself, WaveIsComing is for the moment when the water comes up to the UX element the water will flow around and you have to show it. FlowsAround is the actual moment of flowing around a UI element.

sealed class LevelState {
    object PlainMoving : LevelState()
    object FlowsAround : LevelState()
    object WaveIsComing: LevelState()
}
Enter fullscreen mode Exit fullscreen mode

We understand that the water level is higher than the item if the water level is less than the item position minus the buffer. This area is shown in red on the below picture.

fun isAboveElement(waterLevel: Int, bufferY: Float, position: Offset) = waterLevel < position.y - bufferY
Enter fullscreen mode Exit fullscreen mode

Image description

When the water level is at the level of the element, it is too early to start flowing around yet. This area is shown in grey in the next picture.

fun atElementLevel(
    waterLevel: Int,
    buffer: Float,
    elementParams: ElementParams,
) = (waterLevel >= (elementParams.position.y - buffer)) &&
        (waterLevel < (elementParams.position.y + elementParams.size.height * 0.33))
Enter fullscreen mode Exit fullscreen mode

Image description

fun isWaterFalls(
    waterLevel: Int,
    elementParams: ElementParams,
) = waterLevel >= (elementParams.position.y + elementParams.size.height * 0.33) &&
        waterLevel <= (elementParams.position.y + elementParams.size.height)
Enter fullscreen mode Exit fullscreen mode

Another question we have to consider is this - how to calculate the timing of the water flow? The animations of the waterfall and of the wave increase occurs when the water level is in the blue zone. Thus, we need to calculate the time at which the water level passes 2/3 of the element's height.

@Composable
fun rememberDropWaterDuration(
    elementSize: IntSize,
    containerSize: IntSize,
    duration: Long,
): Int {
    return remember(
        elementSize,
        containerSize
    ) { (((duration * elementSize.height * 0.66) / (containerSize.height))).toInt() }
}
Enter fullscreen mode Exit fullscreen mode

Let's take a closer look at the flow around the element. The shape of the water flow is based on a parabola - we chose a simple shape for the sake of the tutorial. We use the points shown in the picture through which the parabola passes. We do not extend the parabola below the current water level (the horizontal dim red line).

Image description

is LevelState.FlowsAround -> {
    val point1 = PointF(
        position.x,
        position.y - buffer / 5
    )
    val point2 = point1.copy(x = position.x + elementSize.width)
    val point3 = PointF(
        position.x + elementSize.width / 2,
        position.y - buffer
    )
    val p = Parabola(point1, point2, point3)
    points.value.forEach {
        val pr = p.calculate(it.x)
        if (pr > waterLevel) {
            it.y = waterLevel
        } else {
            it.y = pr
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's look at the waterfall animation: we will use the same parabola, changing its height from the initial position, and the OvershootInterpolator for a softer fall effect.

val parabolaHeightMultiplier = animateFloatAsState(
    targetValue = if (levelState == LevelState.WaveIsComing) 0f else -1f,
    animationSpec = tween(
        durationMillis = dropWaterDuration,
        easing = { OvershootInterpolator(6f).getInterpolation(it) }
    )
)
Enter fullscreen mode Exit fullscreen mode

In this case, we use the height multiplier animation so that eventually the height of the parabola becomes 0.

val point1 by remember(position, elementSize, waterLevel, parabolaHeightMultiplier) {
    mutableStateOf(
        PointF(
            position.x,
            waterLevel + (elementSize.height / 3f + buffer / 5) * parabolaHeightMultiplier.value
        )
    )
}
val point2 by remember(position, elementSize, waterLevel, parabolaHeightMultiplier) {
    mutableStateOf(
        PointF(
            position.x + elementSize.width,
            waterLevel + (elementSize.height / 3f + buffer / 5) * parabolaHeightMultiplier.value
        )
    )
}
val point3 by remember(position, elementSize, parabolaHeightMultiplier, waterLevel) {
    mutableStateOf(
        PointF(
            position.x + elementSize.width / 2,
            waterLevel + (elementSize.height / 3f + buffer) * parabolaHeightMultiplier.value
        )
    )
}
return produceState(
    initialValue = Parabola(point1, point2, point3),
    key1 = point1,
    key2 = point2,
    key3 = point3
) {
    this.value = Parabola(point1, point2, point3)
}
Enter fullscreen mode Exit fullscreen mode

In addition, we need to change the size of the waves in places that overlap the UI element, because at the moment of the water falling motion they increase, and then decrease to their normal size.

val point1 by remember(position, elementSize, waterLevel, parabolaHeightMultiplier) {
    mutableStateOf(
        PointF(
            position.x,
            waterLevel + (elementSize.height / 3f + buffer / 5) * parabolaHeightMultiplier.value
        )
    )
}
val point2 by remember(position, elementSize, waterLevel, parabolaHeightMultiplier) {
    mutableStateOf(
        PointF(
            position.x + elementSize.width,
            waterLevel + (elementSize.height / 3f + buffer / 5) * parabolaHeightMultiplier.value
        )
    )
}
val point3 by remember(position, elementSize, parabolaHeightMultiplier, waterLevel) {
    mutableStateOf(
        PointF(
            position.x + elementSize.width / 2,
            waterLevel + (elementSize.height / 3f + buffer) * parabolaHeightMultiplier.value
        )
    )
}
return produceState(
    initialValue = Parabola(point1, point2, point3),
    key1 = point1,
    key2 = point2,
    key3 = point3
) {
    this.value = Parabola(point1, point2, point3)
}
Enter fullscreen mode Exit fullscreen mode

The wave's height is increased in a radius around the UI element for more realism.

Image description

val elementRangeX = (position.x - bufferX)..(position.x + elementSize.width + bufferX)
points.forEach { index, pointF ->
    if (levelState.value is LevelState.WaveIsComing && pointF.x in elementRangeX) {
        waveHeight *= waveMultiplier
    }
}
Enter fullscreen mode Exit fullscreen mode

Now it's time for combining everything we have, and add color blending.

Combining all the elements

There are several ways you can paint on the canvas using a blend mode.
The first method that came to mind is to use a bitmap to draw paths, and to draw the text using Blend modes on a bitmapCanvas. This approach uses an old implementation of the canvas from Android view, so we decided to go natively instead - applying BlendMode for color blending. First, we draw waves on the canvas.

Canvas(
    modifier = Modifier
        .background(Water)
        .fillMaxSize()
) {
    drawWaves(paths)
}
Enter fullscreen mode Exit fullscreen mode

During the implementation we use drawIntoCanvas so that we can use paint.pathEffectCornerPathEffect to smooth out the waves.

fun DrawScope.drawWaves(
    paths: Paths,
) {
    drawIntoCanvas {
        it.drawPath(paths.pathList[1], paint.apply {
            color = Blue
        })
        it.drawPath(paths.pathList[0], paint.apply {
            color = Color.Black
            alpha = 0.9f
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

To see how much space the text takes up, we put the Text element into a Box. Since Text does not support blendMode in the layout, we need to draw text on the Canvas using blendMode, so we use the drawWithContent modifier to only draw the text on the Canvas, but not the text element.
To make blend mode work, a new layer needs to be created. To achieve this, we can use .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)*. The rendering of the content will always be rendered into an offscreen buffer first and then drawn to the destination, regardless of any other parameters configured on the graphics layer.

  • (This is an update to our previous implementation that used a .graphicsLayer(alpha = 0.99f) hack. @romainguy helped us with a cleaner choice in the comments).
Box(
    modifier = modifier
        .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
        .drawWithContent {
            drawTextWithBlendMode(
                mask = paths.pathList[0],
                textStyle = textStyle,
                unitTextStyle = unitTextStyle,
                textOffset = textOffset,
                text = text,
                unitTextOffset = unitTextProgress,
                textMeasurer = textMeasurer,
            )
        }
) {
    Text(
        modifier = content().modifier
            .align(content().align)
            .onGloballyPositioned {
                elementParams.position = it.positionInParent()
                elementParams.size = it.size
            },
        text = "46FT",
        style = content().textStyle
    )
}
Enter fullscreen mode Exit fullscreen mode

First we draw the text, then we draw a wave, which is used as a mask. Here's the official documentation regarding different blend modes available to developers

Image description

fun DrawScope.drawTextWithBlendMode(
    mask: Path,
    textMeasurer: TextMeasurer,
    textStyle: TextStyle,
    text: String,
    textOffset: Offset,
    unitTextOffset: Offset,
    unitTextStyle: TextStyle,
) {
    drawText(
        textMeasurer = textMeasurer,
        topLeft = textOffset,
        text = text,
        style = textStyle,
    )
    drawText(
        textMeasurer = textMeasurer,
        topLeft = unitTextOffset,
        text = "FT",
        style = unitTextStyle,
    )

    drawPath(
        path = mask,
        color = Water,
        blendMode = BlendMode.SrcIn
    )
}
Enter fullscreen mode Exit fullscreen mode

Now you can see the whole result:

Image description

Conclusion

This turned out to be quite a complex implementation, but that's expected given the source material. We were glad that a lot could be done using the native Compose tooling. You can also tweak the parameters to get a more compelling water effect, but we decided to stop at this proof of concept. As usual, the repo contains the full implementation. If you like this tutorial, you can check how to implement the audio dribble app here or find more interesting stuff in our blog.

Top comments (0)