Do you have a keen eye for fine details when it comes to UX? Do you try to go that extra step when implementing a design to give it a bit more sparkle? Me too!
This article will introduce a concept which is quite common under the hood in touch-based operating systems like Android and iOS but, having never come across any material that discusses using this technique for simple UI tweaks, I figured it deserved its own article. For the purposes of this article I have dubbed the technique Velocity Based Animation.
Sounds fancy β but what is it?
Simply put: velocity based animation just describes a method of tweaking animation parameters based on the velocity of some movement which is currently in progress.
I understand that is a fairly vague description, especially considering I prefixed it with "simply put", but let's apply that description to a frequently seen movement on mobile.
The fling-scroll
If we take something like LazyColumn
from Compose we can see a clear example of velocity based animation that we get for free: if you fling the list, the speed at which it begins scrolling (and animating) is based on your initial velocity, and as that velocity decays the scroll animation slows until it comes to rest.
The gentle fade-in of a scrolling list
I love this simple effect. Like many developers out there, I've been using this style of list item fade-in animation as a quick win for some extra UI candy that is not too over the top. It's one of those tools we have which is quite simple to implement, is subtle, yet instantly changes the feel of our UI and can give it a more polished look when used in the right circumstances.
What happens when I do this?
There's one issue with our simple effect which has bugged me in the past.
Recently I was impatiently scrolling very quickly back to the top of a list in an app which will remain nameless, and while staring at the blank looking screen I thought "reducing the animation duration would make this better", but immediately I realised that would make the animation seem nonexistent when scrolling at a more normal speed.
Using velocity to inform animation duration
My initial (read: naive) solution was to make my own copy of DefaultFlingBehavior
which exposes the current velocity, and pass the velocity to each item so it can use a different animation duration depending on how fast the scroll is. I caught myself before I even finished writing the code as I realised this was a terrible idea due to recomposition.
The customised FlingBehavior is a good idea, but how we utilise it in a way that makes sense in Compose needed some thought.
Expand to see code for VelocityTrackingFlingBehavior.kt
@Composable
internal fun rememberVelocityTrackingFlingBehavior(): VelocityTrackingFlingBehavior {
val flingSpec = rememberSplineBasedDecay<Float>()
return remember(flingSpec) {
VelocityTrackingFlingBehavior(flingSpec)
}
}
internal class VelocityTrackingFlingBehavior(
private val flingDecay: DecayAnimationSpec<Float>
) : FlingBehavior {
private var _currentVelocity = mutableStateOf(0f)
val currentVelocity: State<Float> = _currentVelocity
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
// come up with the better threshold, but we need it since spline curve gives us NaNs
return if (abs(initialVelocity) > 1f) {
var velocityLeft = initialVelocity
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = initialVelocity,
).animateDecay(flingDecay) {
_currentVelocity.value = this.velocity
val delta = value - lastValue
val consumed = scrollBy(delta)
lastValue = value
velocityLeft = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
}
velocityLeft
} else {
initialVelocity
}
}
}
(or create your own by modifying Compose's own DefaultFlingBehavior
)
Normalizing our velocity
Velocity is a rapidly changing value which means it becomes a recomposition nightmare if you try to use it as-is, so we can normalize the velocity and use Compose's derivedStateOf
to ensure our variable representing velocity isn't going to trigger other unwanted recompositions:
val normalizedVelocity by remember {
derivedStateOf {
normalizeVelocity(velocityTrackingFlingBehavior.currentVelocity.value)
}
}
The normalizeVelocity
function is quite simple, first ensuring velocity is always a positive number β we don't care about direction for this use case β then reducing it down to one of 11 possible values:
fun normalizeVelocity(velocity: Float): Float {
return when (if (velocity < 0) velocity * -1 else velocity) {
in 0f..5000f -> 0f
in 5001f..10000f -> 1f
in 10001f..15000f -> 2f
in 15001f..20000f -> 3f
in 20001f..30000f -> 4f
in 30001f..40000f -> 5f
in 40001f..50000f -> 6f
in 50001f..60000f -> 7f
in 60001f..70000f -> 8f
in 70001f..80000f -> 9f
else -> 10f
}
}
Once we have a normalized value in the range 0f..10f we can build a function which gives us a duration (in milliseconds) for each possible velocity level:
fun velocityBasedFadeInDuration(normalizedScrollVelocity: Float): Int {
return when (normalizedScrollVelocity) {
0f -> 600
1f -> 500
2f -> 400
3f -> 300
4f -> 200
5f -> 100
6f -> 50
7f -> 30
8f -> 15
9f -> 10
else -> 5
}
}
π Note: I'd encourage you to play around with the values in the last two functions β these are just some initial values I came up with while playing around and there are improvements to be made here. If you're really keen you could combine these two functions into one, then use each item's height as a secondary input to calculate the ideal fade in duration based on the velocity.
Finally, we want to defer the read of the state of normalizedVelocity
as long as possible to ensure the minimum amount of code is recomposed when it changes. To achieve this, we should create a getter for the current value of normalizedVelocity
:
val getCurrentVelocity: () -> Float = { normalizedVelocity }
Putting it all together
Inside our LazyColumn we can now pass the velocity getter function into individual items:
items(articles) { newsArticle ->
Article(newsArticle = newsArticle, getCurrentVelocity = getCurrentVelocity)
}
And here is what our Article composable might look like:
@Composable
fun Article(newsArticle: NewsArticle, getCurrentVelocity: () -> Float) {
val animateDuration = velocityBasedFadeInDuration(getCurrentVelocity())
val animatedAlpha = remember { Animatable(initialValue = 0f) }
LaunchedEffect(Unit) {
animatedAlpha.animateTo(
targetValue = 1f,
animationSpec = tween(animateDuration)
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { alpha = animatedAlpha.value }
) {
...
}
}
Wrapping up
Before | After |
---|---|
I hope this article was able to teach you something or inspire you to think about other interesting things we can do with animations in Compose.
Is it necessary to tweak this level of detail in all our animations? Probably not. But is it fun? Absolutely. For me, using velocity as an input to animation duration is just the tip of the iceberg when it comes to subconsciously-noted animation detail, and I look forward to playing more with this kind of thing in pursuit of aesthetically pleasing user experiences.
You can find the full code to go along with this article in the repo on GitHub:
morrisseyai / VelocityBasedAnimation
This is a sample project to demonstrate the concepts outlined in my article about Velocity Based Animation with Compose
VelocityBasedAnimation
This is a sample project to demonstrate the concepts outlined in my article about Velocity Based Animation with Compose.
Follow me on twitter / GitHub.
Big thanks to Ben Trengrove for the advice and review of this post.
Top comments (1)
Hats off to you, you're one of these developers who truly leave a smile on my face when using your gorgeous, thoughtfully designed apps!!
Thanks for the insightful and aesthetically-polished article!