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

  • Thread starter Thread starter Terrence Aluda
  • Start date Start date
T

Terrence Aluda

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

Code:
...
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()
}
...

Explanation​

  • We start by defining the path of a heart using cubic BΓ©zier curves.

Code:
val width = size / 2f
val height = (size / 3f) * 2f
  • 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.


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

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.


Code:
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
)

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


Code:
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
)

To complete the heart shape, we close the path.


Code:
close()

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


Code:
drawPath(path, Color(0xFF25D366))

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:


Code:
val progress = remember { Animatable(0f) }

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


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

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:


Code:
val alpha = when {
    progress.value < 0.1f -> progress.value / 0.1f
    progress.value > 0.4f -> 0f
    else -> 1f
}
  • Fade in: During the first 10% of progress (0f β†’ 0.1f), alpha increases gradually from 0 to 1 as tabulated in the table below.
ProgressAlpha
00
0.010.1
0.020.2
0.030.3
0.040.4
0.050.5
0.060.6
0.070.7
0.080.8
0.090.9
0.11
  • 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:


Code:
drawPath(path, 
         Color(0xFF25D366).copy(alpha) //this place
)

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:


Code:
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
}
  • 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.
ProgressScale
00.5
0.010.55
0.020.6
0.030.65
0.040.7
0.050.75
0.060.8
0.070.85
0.080.9
0.090.95
0.11

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

ProgressAlpha
0.31
0.351.083333333
0.41.166666667
0.451.25
0.51.333333333
0.551.416666667
0.61.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:


Code:
val heartPx = (heartSize * scale).dp.toPx()

Setting out the path using the Sine wave formula​

Hosted in the BouncingHeartAnimation function.

We use the code below:


Code:
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)

Explanation​


Code:
val centerX = size.width / 2
val bottomY = size.height - heartPx
  • 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.

Code:
val amplitude = size.width / amplitudeCtrl
val frequency = 3f
  • 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.

Code:
val xPos =
    centerX + amplitude * kotlin.math.sin(
        progress.value * frequency * 2 * Math.PI * amplitudePhaseShift
    ).toFloat()
  • 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 2Ο€ 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.

Code:
val yPos = bottomY - (size.height * progress.value)

  • 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​


Code:
val amplitudes = listOf(15, 9, 6, 6, 5)
  • 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​


Code:
var activeHeartAmplitudes by remember { mutableStateOf(listOf<Int>()) }
  • 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​


Code:
LaunchedEffect(Unit) {
    amplitudes.forEachIndexed { index, amp ->
        delay(index * 100L) // 0ms, 100ms, 200ms...
        activeHeartAmplitudes = activeHeartAmplitudes + amp
    }
}
  • 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​


Code:
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)
        }
    }
}
  • 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.

Continue reading...
 


Join 𝕋𝕄𝕋 on Telegram
Channel PREVIEW:
Back
Top