In this series of articles we are trying to recreate this amazing clock animation
This is a second part of our animation series. Part 1 is here
TLDR:
– The source code of the final version of this animation could be found here.
– There will be two different approaches to recreate the same animation — one by using parallel threads and another by using a little bit of math.
– Both of them look good. Please tell in the comments which one you like more!!
So far in Part 1 we managed to :
- Create an endless rotating animation
- Draw an clock hand which shrinks first 12h and then extends back during the following 12h
- Draw 12 dots which disappear and appear back when necessary
In this part 2 we will:
- Create dots assemble animation
- Create 12 parallel disassemble animations.
Step 4: Assemble animation of a Clock Hand
In part 1 we already stated that all dots, which clock hand collects, follow the same line and collected one by one. Similar transition starts every hour with a small difference — travel distance of a dot becomes shorter and shorter.
Assemble animation without rotation
The travel distance can be calculated with this simple function:
private fun calculateAssembleDistance(stepHeight: Float, currentHour: Int): Float = stepHeight * (23 - currentHour)
The smaller currentHour
is, the longer the distance. It will be decreased every hour by stepHeight
(which as we discussed in previous part is equals to 1/24th of the screen heigh).
Now we have to create an animation which will return us values from 0f to 1f and based on this value we will either be on the edge of the screen, or on the tip of the clock hand.
But do we actually need an animation?
We know that this transition depends on the rotation angle — during a full rotation circle we have to make 12 transitions like that, one after another. Which means that we have to do each one every 30 degrees, and we have to do that consistently — otherwise the transition will not be smooth.
We know that every 30 degrees a new hour starts, so we have to transform these 0..30 degrees to 0..1 , and after that we’re done.
That’s what we came up with
// Start calculation each time the animationAngle changes. | |
val assembleValue = remember(animationAngle) { | |
// We only need this animation for second rotation | |
if (animationAngle >= 360) { | |
// Reversed linear interpolation between 0..30 degrees, transformed into 0..1 | |
(animationAngle % 30) / 30 | |
} else -1f | |
} |
Now we have to draw a dot transition, and based on the animation value, draw it in the proper place. As the dot will be drawn along with a clock hand, it should be put under the same rotation section as a clock hand is.
Spacer( | |
... | |
.drawBehind { | |
… | |
// A rotation section of a clock hand | |
rotate(animationAngle, pivot = center){ | |
// Drawing a clock hand itself | |
drawLine( | |
color = Color.White, | |
start = center, | |
end = endOffset, | |
strokeWidth = strokeWidth, | |
) | |
// Drawing a clock hand | |
if (assembleValue != -1f) { | |
val positionY = halfStroke + | |
calculateAssembleDistance(stepHeight, currentHour) * | |
assembleValue | |
val start = Offset(size.width / 2, positionY - halfStroke) | |
val end = Offset(size.width / 2, positionY + halfStroke) | |
drawLine( | |
color = Color.White, | |
start = start, | |
end = end, | |
strokeWidth = strokeWidth | |
) | |
} | |
… | |
}}) |
The full code for this step could be found here
That’s how our animation will look like
Animation after Step 5
Job Offers
Our animation is almost finished! The only thing which is left is a parallel animation for shooting out the dots.
Step 5: Parallel animations
As we discussed in Part 1, dots are spreading in parallel and going in different directions. They also slow down when they approach their final positions.
To achieve that we’ll use 12 parallel animations with easings and will start each animation every new hour ( every 30 degrees).
For that we’ll be using Channels
and coroutines Flow
. They will allow us to create the required amount of parallel threads.
val currentHourChannel = remember { Channel<Int>(12, BufferOverflow.DROP_OLDEST) } val currentHourFlow = remember(currentHourChannel) { currentHourChannel.receiveAsFlow() }
Sending Events
An event should be sent to the channel
every hour, and the currentHour
should be updated accordingly.
We’ll be updating currentHour
and sending a value to the currentHourChannel
at the same time. That way we will be sure that currentHour
will not be updated earlier.
// Remove derivedStateOf | |
var currentHour by remember { mutableStateOf(0) } | |
LaunchedEffect(animationAngle) { | |
// Add hour calculation inside of a launchEffect | |
val newCurrentHour = animationAngle.toInt() / 30 | |
if (newCurrentHour != currentHour) { | |
currentHour = newCurrentHour | |
// Sending currentHour through channel | |
currentHourChannel.trySend(currentHour) | |
} | |
} |
Receiving events
Parallel animations will be handled through 12 Animatables
. Each Animatable
will be keeping a state from 0f to 1f.
val disassembleAnimations = remember { hours.map { Animatable(1f) } }
Channel events will be received through currentHourFlow
. On each new event a new animation will be launched asynchronously with appropriate length and easing.
// Assume that duration is 1/12th of the whole duration, which equals to the length of 2 hours. It can be longer or shorter if necessary | |
val disassembleDuration = duration / 12 | |
LaunchedEffect(currentHourFlow) { | |
currentHourFlow.collectLatest { | |
// launch each animation asynchronously | |
launch { | |
if (currentHour < 12) { | |
disassembleAnimations[currentHour].snapTo(0f) | |
// Set a tween spec with LinearOutSlowIn easing | |
disassembleAnimations[currentHour].animateTo( | |
1f, | |
tween(disassembleDuration, easing = LinearOutSlowInEasing) | |
) | |
} | |
} | |
} | |
} |
By changing animationEasing
we can change how our animation looks and tweak it as we want.
Drawing an animation
Instead of 12 static dots which we had before, we will be drawing dots which can change their positions. Position will be based on the animation value and a specific hour. Because each hour clock hand gets shorter, the travel distance should decrease accordingly.
We already have all animation values in dissassembleAnimations
array and for each hour dot we will use those values.
Spacer( | |
... | |
.drawBehind { | |
... | |
hours.forEach { | |
if (!dotsVisibility[it]) return@forEach | |
val degree = it * 30f | |
rotate(degree) { | |
// Based on the hour value, the travel distance will be longer. | |
val positionY = halfStroke + | |
stepHeight * it * (1 - disassembleAnimations[it].value) | |
val start = Offset(size.width / 2, positionY - halfStroke) | |
val end = Offset(size.width / 2, positionY + halfStroke) | |
drawLine( | |
color = Color.White, | |
start = start, | |
end = end, | |
strokeWidth = strokeWidth, | |
) | |
} | |
} | |
} |
And that’s how our animation looks like now 🔥🔥🔥
Final animation!
The full code for this step could be found here
That’s it! We managed to recreate this animation by using a lot of tools which Jetpack Compose and Kotlin can offer such as InfiniteTransition
, animateFloat
, Channel
, Flow
and other useful apis.
🤔 But what if I tell you that we can write this animation in a single go, without creating a bunch of threads and animations? And a progress of which can be controlled with a slider? Like this
Animation controlled by a slider!
That’s what we are going to do in Part 3 of “Amazing Clock ⏰ animation with Jetpack Compose” series. Stay tuned!
You can check the full source code for this animation in my Github repository.
Again, if you liked this article I would really appreciate it if you clap 👏👏👏 for it. You can do it multiple times, up to 15 as I remember.
Great coding!
This article was previously published on proandroiddev.com