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:
- Draw the heart
- 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
- 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()
}
...
Explanation
- We start by defining the path of a heart using cubic Bézier curves.
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.
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.
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.
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.
close()
We finish off by calling the drawPath
method while passing the WhatsApp green color 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:
val progress = remember { Animatable(0f) }
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)
)
}
Here, we animate progress
from 0f
to 0.6f
when the composable first appears.
- We stop at
0.6f
instead of1.0f
because we want the bounce to reach 60% of the screen’s height, not a full oscillation. - The
tween
withLinearEasing
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
}
-
Fade in: During the first 10% of progress (
0f → 0.1f
), alpha increases gradually from0
to1
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
and0.4f
, the heart stays fully opaque. -
Fade out: After
0.4f
, alpha drops back to0
, 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
)
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
}
-
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
and0.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()
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)
Explanation
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.
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 smalleramplitudeCtrl
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 value3f
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()
-
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 byfrequency
and2π
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)
-
yPos
determines the vertical position of the heart. The starting point isbottomY
, which places the heart near the bottom of the screen. Asprogress.value
increases, the heart’s vertical position moves upward.Multiplying
size.height
byprogress.value
gives the total vertical distance covered. For example, whenprogress.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)
- 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>()) }
-
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
}
}
- 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
) intoactiveHeartAmplitudes
.
- Waits
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)
}
}
}
- 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.
- Has
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)