DEV Community

Cover image for Creating bouncing animations using Sine waves (Kotlin + Jetpack Compose): Part 2
Terrence Aluda
Terrence Aluda

Posted on

Creating bouncing animations using Sine waves (Kotlin + Jetpack Compose): Part 2

Part 1 dealt with the theoretical framework, touching on the analysis of the movements and a basic primer on waves, sine waves in particular. In this part, we shift gears to implementation. We’ll demonstrate how to map these properties directly into Kotlin code with Jetpack Compose to generate the animations. Below is what we will come up with:

You will need to be familiar with:

  • The Android Canvas
  • Jetpack Compose
  • Kotlin
  • Coroutines
  • General Android development

Access the full code in this GitHub repository.

What we will be doing

To maintain the flow, we will follow the steps below:

  1. Draw the heart
  2. Build the animation:
    • Implement the fade in and fade out
    • Implement the size increment and reduction (scaling)
    • Curve out the path using the Sine wave formula
  3. Bundle everything together to display the multiple heart movements

Let's get in.

Drawing the heart

To draw the heart, we will use bezier curves. Bezier curves give us the flexibility of drawing the curves that we want using control points. Of particular use is the Path.cubicTo method. Although I won't dive into the details about the method, I made some slides where I highlighted how the method works to help draw curves.

Below is the code used to draw the heart:

Hosted in the drawHeart function.

...
val width = size / 2f
val height = (size / 3f) * 2f

val path = Path().apply {
    moveTo(x + width / 2f, y + height * 0.75f) // Bottom tip

    cubicTo(
        x - width * 0.25f, y + height * 0.45f, // Left control point 1
        x + width * 0.25f, y + height * 0.05f, // Left control point 2
        x + width / 2f, y + height * 0.3f   // Top center dip
    )

    cubicTo(
        x + width * 0.75f, y + height * 0.05f, // Right control point 1
        x + width * 1.25f, y + height * 0.45f, // Right control point 2
        x + width / 2f, y + height * 0.75f  // Back to bottom tip
    )

    close()
}
...

Enter fullscreen mode Exit fullscreen mode

Explanation

  • We start by defining the path of a heart using cubic Bézier curves.
val width = size / 2f
val height = (size / 3f) * 2f
Enter fullscreen mode Exit fullscreen mode
  • Then calculate the proportions of the heart relative to the given size. The heart’s width is half the size, while its height is two-thirds.

We start drawing the bottom tip of the heart.

val path = Path().apply {
    moveTo(x + width / 2f, y + height * 0.75f) // Bottom tip
Enter fullscreen mode Exit fullscreen mode

After that, we draw the left lobe of the heart using a cubic Bézier curve, moving from the bottom tip up to the dip at the top center.

cubicTo(
    x - width * 0.25f, y + height * 0.45f, // Left control point 1
    x + width * 0.25f, y + height * 0.05f, // Left control point 2
    x + width / 2f, y + height * 0.3f      // Top center dip
)
Enter fullscreen mode Exit fullscreen mode

We then mirror the left lobe with another Bézier curve for the right lobe, bringing the path back to the bottom tip.

cubicTo(
    x + width * 0.75f, y + height * 0.05f, // Right control point 1
    x + width * 1.25f, y + height * 0.45f, // Right control point 2
    x + width / 2f, y + height * 0.75f     // Back to bottom tip
)
Enter fullscreen mode Exit fullscreen mode

To complete the heart shape, we close the path.

close()
Enter fullscreen mode Exit fullscreen mode

We finish off by calling the drawPath method while passing the WhatsApp green color code.

drawPath(path, Color(0xFF25D366))
Enter fullscreen mode Exit fullscreen mode

Later on, we will add the Color.copy method for the color fade in and out.

Implement the fade in and fade out

Hosted in the BouncingHeartAnimation function.

We start by creating an Animatable that represents the normalized progress of the animation:

val progress = remember { Animatable(0f) }
Enter fullscreen mode Exit fullscreen mode

This progress value starts at 0f and will animate upwards.

// Launch one-shot animation when composable appears
LaunchedEffect(Unit) {
    progress.animateTo(
        targetValue = 0.6f,
        animationSpec = tween(animSpeed, easing = LinearEasing)
    )
}
Enter fullscreen mode Exit fullscreen mode

Here, we animate progress from 0f to 0.6f when the composable first appears.

  • We stop at 0.6f instead of 1.0f because we want the bounce to reach 60% of the screen’s height, not a full oscillation.
  • The tween with LinearEasing means this change happens smoothly at a constant speed.

Controlling opacity with alpha

Next, we derive an alpha value (opacity) from the animation progress:

val alpha = when {
    progress.value < 0.1f -> progress.value / 0.1f
    progress.value > 0.4f -> 0f
    else -> 1f
}
Enter fullscreen mode Exit fullscreen mode
  • Fade in: During the first 10% of progress (0f → 0.1f), alpha increases gradually from 0 to 1 as tabulated in the table below.
Progress Alpha
0 0
0.01 0.1
0.02 0.2
0.03 0.3
0.04 0.4
0.05 0.5
0.06 0.6
0.07 0.7
0.08 0.8
0.09 0.9
0.1 1
  • Fully visible: Between 0.1f and 0.4f, the heart stays fully opaque.
  • Fade out: After 0.4f, alpha drops back to 0, making the heart disappear before the animation ends.

The result will be that each heart rises to 60% of the screen height, smoothly fading in at the start and fading out before reaching the top.

To effect this, we will pass alpha to the Color.copy in the drawPath method like so:

drawPath(path, 
         Color(0xFF25D366).copy(alpha) //this place
)
Enter fullscreen mode Exit fullscreen mode

Implementing the size increment and reduction

Hosted in the BouncingHeartAnimation function.

For the scaling, we adjust the size of the heart so that it grows when it fades in, stays steady in the middle, and then shrinks as it fades out:

val heartSize = 90f

// Scale grows while fading in, shrinks while fading out
val scale = when {
    progress.value < 0.1f -> 0.5f + (progress.value / 0.1f) * 0.5f
    progress.value > 0.3f -> 1.0f - ((progress.value - 0.3f) / 0.7f) * 1.25f
    else -> 1f
}
Enter fullscreen mode Exit fullscreen mode
  • Fade-in growth

    For the first 10% of the animation (progress < 0.1f), the heart scales from 50% of its size (0.5f) up to full size (1f).

  • Formula: 0.5f + (progress_value) * 0.5f linearly grows it.

Progress Scale
0 0.5
0.01 0.55
0.02 0.6
0.03 0.65
0.04 0.7
0.05 0.75
0.06 0.8
0.07 0.85
0.08 0.9
0.09 0.95
0.1 1
  • This makes the heart pop into view.

    • Stable size

    Between 0.1f and 0.3f, the heart stays at 100% scale. This is the “steady bouncing” phase of the animation.

    • Scaling out

    After 0.3f, the heart begins shrinking below its original size.

The formula is: 1.0f - ((progress - 0.3f) / 0.7f) * 1.25f.

Progress Alpha
0.3 1
0.35 1.083333333
0.4 1.166666667
0.45 1.25
0.5 1.333333333
0.55 1.416666667
0.6 1.5
  • This linearly decreases the scale until the heart vanishes, making the fade-out more natural (like it’s drifting away).

The scale will be passed in as a parameter like so:

val heartPx = (heartSize * scale).dp.toPx()
Enter fullscreen mode Exit fullscreen mode

Setting out the path using the Sine wave formula

Hosted in the BouncingHeartAnimation function.

We use the code below:

val centerX = size.width / 2
val bottomY = size.height - heartPx

val amplitude = size.width / amplitudeCtrl
val frequency = 3f
val xPos =
    centerX + amplitude * kotlin.math.sin(
        progress.value * frequency  * 2 * Math.PI *  amplitudePhaseShift
    ).toFloat()
val yPos = bottomY - (size.height * progress.value)
Enter fullscreen mode Exit fullscreen mode

Explanation

val centerX = size.width / 2
val bottomY = size.height - heartPx
Enter fullscreen mode Exit fullscreen mode
  • centerX represents the horizontal middle of the screen. It acts as the baseline for the horizontal movement of the heart.
  • bottomY gives the vertical baseline at the bottom of the screen, adjusted by subtracting the heart’s pixel size (heartPx) so the heart sits properly inside the screen rather than clipping past the bottom.
val amplitude = size.width / amplitudeCtrl
val frequency = 3f
Enter fullscreen mode Exit fullscreen mode
  • amplitude defines how wide the heart moves from left to right. It is calculated from the screen width divided by a control parameter (amplitudeCtrl). A smaller amplitudeCtrl value increases the side-to-side movement, while a larger value makes the motion tighter and closer to the center.
  • frequency controls how many oscillations occur during the animation. In this case, the value 3f means the sine function will complete three cycles across the course of the progress animation.
val xPos =
    centerX + amplitude * kotlin.math.sin(
        progress.value * frequency * 2 * Math.PI * amplitudePhaseShift
    ).toFloat()
Enter fullscreen mode Exit fullscreen mode
  • xPos determines the horizontal position of the heart at any point in time.
  • The sine function generates a smooth oscillation that shifts the heart left and right.

    progress.value runs from 0 up to the target value (in this case, 0.6). Multiplying it by frequency and converts the linear progress into a wave with a certain speed.

    amplitudePhaseShift shifts the entire wave left or right along the horizontal axis. This is useful when animating multiple hearts at once, since each heart can be given a slightly different phase shift to avoid moving identically.

    The result of the sine calculation is multiplied by the amplitude and then added to centerX. This ensures the movement happens around the middle of the screen rather than starting from the edge.

val yPos = bottomY - (size.height * progress.value)
Enter fullscreen mode Exit fullscreen mode
  • yPos determines the vertical position of the heart. The starting point is bottomY, which places the heart near the bottom of the screen. As progress.value increases, the heart’s vertical position moves upward.

    Multiplying size.height by progress.value gives the total vertical distance covered. For example, when progress.value = 0.6f, the heart has risen to 60% of the screen height from the bottom.

Displaying the multiple bouncing hearts

Hosted in the MultipleHearts function.

It draws multiple hearts on the screen, each with different animation parameters.

Amplitudes list

val amplitudes = listOf(15, 9, 6, 6, 5)
Enter fullscreen mode Exit fullscreen mode
  • This defines how wide each heart wiggles left-right.
  • Bigger numbers mean wider wave oscillation (heart moves more left-right).
  • Smaller numbers result in a tighter wiggle and a straighter rise.
  • Each value will be assigned to a separate heart.

Active hearts amplitudes

var activeHeartAmplitudes by remember { mutableStateOf(listOf<Int>()) }
Enter fullscreen mode Exit fullscreen mode
  • activeHeartAmplitudes holds the list of currently visible heart amplitudes. It starts empty and then new hearts are added gradually, so they don’t all start at once.

Launching staggered starts

LaunchedEffect(Unit) {
    amplitudes.forEachIndexed { index, amp ->
        delay(index * 100L) // 0ms, 100ms, 200ms...
        activeHeartAmplitudes = activeHeartAmplitudes + amp
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Runs once when the composable enters the composition.
  • Iterates through the amplitude list (amplitudes).
  • For each entry:

    • Waits index * 100ms i.e:
    • Index 0 heart starts immediately.
    • Index 1 heart starts after 100ms.
    • Index 2 after 200ms, etc.
    • Appends that amplitude (amp) into activeHeartAmplitudes.

This causes the hearts to enter one after another instead of all at once, creating a cascading flow (like bubbles rising).

Displaying the hearts

Box(modifier = Modifier.fillMaxSize()) {
    activeHeartAmplitudes.forEachIndexed { index, amp ->
        if (index == 0) {
            BouncingHeartAnimation(
                amplitudeCtrl = amp,
                animSpeed = 1000,
                amplitudePhaseShift = 0.25f
            )
        } else if (index == 3 || index == 4) {
            BouncingHeartAnimation(
                amplitudeCtrl = amp,
                amplitudePhaseShift = 0.5f
            )
        } else {
            BouncingHeartAnimation(amplitudeCtrl = amp)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • A full-screen Box is used as the container.
  • Loops through activeHeartAmplitudes (the ones that have been "activated").
  • For each one, it launches a BouncingHeartAnimation with different parameters depending on its index:

    First heart (index 0):
    • Has animSpeed = 1000 (faster animation).
    • Has a phase shift of 0.25, so its sine wave is offset compared to others.
    Hearts at index 3 and 4:
    • Use a phase shift of 0.5f, meaning their oscillation starts opposite from the others.
    • This prevents all hearts from swinging left-right in sync, making it more natural.

    All other hearts:

    • Use default parameters with only amplitudeCtrl applied.

With that, you have gotten the basics of how trigonometric waves can be used to control animations. You can try adjusting the frequency, wavelength, and other parameters to attempt to replicate the same look of the WhatsApp animations. I hope you enjoyed the read.

Top comments (0)