DEV Community

Cover image for Velocity Based Animation with Compose
James Morrissey
James Morrissey

Posted on

Velocity Based Animation with Compose

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

The GIF below illustrates a commonly used item animation seen when scrolling a list. As each item comes into composition, the alpha level is adjusted from 0f to 1f over a static animation duration:

(tap the GIF if it doesn't auto-play)

Gentle scrolling with a soft fade-in animation for each item

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.

When your user decides they want to scroll very quickly through the list, the fade-in animation has no time to complete, and the visual effect is a near blank screen until the velocity has decayed enough to allow each item to spend sufficient time in the frame that its alpha level is high enough to be seen:

(tap the GIF if it doesn't auto-play)

Fast scrolling makes the screen appear blank

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

(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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

📘 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 }
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 }
    ) {
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Wrapping up

Below is a side by side comparison showing before and after:

(tap the GIFs if they don't auto-play)
Before After
Fast scrolling without velocity based animation Fast scrolling with velocity based animation

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:

GitHub logo morrisseyai / 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)

Collapse
 
skaldebane profile image
Houssam Elbadissi

and I look forward to playing more with this kind of thing in pursuit of aesthetically pleasing user experiences.

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!