Subscribe for UI recipes

Overscroll animations in Jetpack Compose

Add bouncy overscroll animations to all your scrolling elements

Overscroll animations in Jetpack Compose

Overscroll animations are used to indicate to the user when they have reached the bounds of a scrolling element. In this article, we will create a custom Modifier, utilizing nested scrolling, that will help us build our own animation for this action.

Final overscroll bounce animation on a LazyColumn

But before I start, let me make it clear that this is a bit of a workaround. Jetpack Compose already has OverscrollEffect which we can use to create custom overscroll animations. The problem with this is that we can only apply this to scrollable lists that we create using Modifier.scrollable.

This is not so ideal since most of the lists we create in Compose are LazyLists(HorizontalPager, LazyColumn, etc.). These Composables do not expose an overscrollEffect parameter for us to customize.
There is already an issue report created for this here, so please upvote so that we can have this natively in Compose one day.

Anyways, let's get hacking!

Setup

We will be using NestedScrollConnection to retrieve the scroll events for this animation. In my previous article, I already used this method to create an overscroll effect for a flip pager.
But in this article, we will expand on this and build a universal approach that will work for all LazyLists and Pagers.

To organize our code, let's create a modifier that we can apply to any composable that we want an overscroll animation on and set up the required parameters.

@Composable  
fun Modifier.customOverscroll( 
    orientation: Orientation,  
    onNewOverscrollAmount: (Float) -> Unit,  
    animationSpec: SpringSpec<Float> = spring(stiffness = Spring.StiffnessLow)  
): Modifier {
	// implementation goes here
}

First, we pass in the orientation. This tells us the direction of the scroll: horizontal or vertical.

Next, onNewOverscrollAmount is a function that will be used to get the distance overscrolled. This distance is what we will use to drive our animations.

And finally, we have an optional animationSpec that we can use to tweak the timing of the settling animation. I found that a low stiffness works best for the animation we will create so let's set that as the default for now.

We could avoid passing the orientation by overloading this Modifier for LazyListState and PagerState. It's just one parameter change but it is one less area we could mess up and cause some bugs.
@Composable  
fun Modifier.customOverscroll(  
    listState: LazyListState,  
    onNewOverscrollAmount: (Float) -> Unit,  
    animationSpec: SpringSpec<Float> = spring(stiffness = Spring.StiffnessLow)  
) = customOverscroll(
    orientation = remember { listState.layoutInfo.orientation },  
    onNewOverscrollAmount = onNewOverscrollAmount,  
    animationSpec = animationSpec  
)  
  
@Composable  
fun Modifier.customOverscroll(  
    pagerState: PagerState,  
    onNewOverscrollAmount: (Float) -> Unit,  
    animationSpec: SpringSpec<Float> = spring(stiffness = Spring.StiffnessLow)  
) = customOverscroll(
    orientation = remember { pagerState.layoutInfo.orientation },  
    onNewOverscrollAmount = onNewOverscrollAmount,  
    animationSpec = animationSpec  
)

We can then apply this modifier like this:

val listState = rememberLazyListState()  
var animatedOverscrollAmount by remember { mutableFloatStateOf(0f) }  

Box(  
	modifier = Modifier  
		.customOverscroll(  
			listState,  
			onNewOverscrollAmount = { animatedOverscrollAmount = it }            
		)  
		.offset { IntOffset(0, animatedOverscrollAmount.roundToInt()) }
) {
	LazyColumn {  
		... 
	}   
}

For this article, we will be implementing a simple ("simple") bounce animation. Here we will take the animatedOverscrollAmount value and apply it using the offset modifier.

CustomOverscroll Implementation

Inside customOverscroll, we will store and control the overscroll distance through an Animatable.

@Composable  
fun Modifier.customOverscroll(  
    ...
): Modifier {  
    val overscrollAmountAnimatable = remember { Animatable(0f) }  
  
    LaunchedEffect(Unit) {  
        snapshotFlow { overscrollAmountAnimatable.value }.collect {  
            onNewOverscrollAmount(it)  
        }  
    }
    ...
}

Here we are just initializing overscrollAmountAnimatable and then returning it whenever it changes through the onNewOverscrollAmount function.

This can work for some effects, but for our bounce effect, it would look better if we had a curve on our value to get a "rubber-band" effect. We can do this using a custom Easing implementation.

@Composable  
fun Modifier.customOverscroll(...): Modifier {  
    val overscrollAmountAnimatable = remember { Animatable(0f) }  
    var length by remember { mutableFloatStateOf(1f) }  
  
    LaunchedEffect(Unit) {  
        snapshotFlow { overscrollAmountAnimatable.value }.collect {  
            onNewOverscrollAmount(  
                CustomEasing.transform(it / (length * 1.5f)) * length  
            )  
        }  
    }
    ...
	return this  
        .onSizeChanged {  
            length = when (orientation) {  
                Orientation.Vertical -> it.height.toFloat()  
                Orientation.Horizontal -> it.width.toFloat()  
            }  
        }
}  
  
val CustomEasing: Easing = CubicBezierEasing(0.5f, 0.5f, 1.0f, 0.25f)

NestedScrollConnection

And now, let's create the star of the show, the NestedScrollConnection. Think of this as gaining master control over all scroll events of any children of the composable.
We can intercept the scroll and fling events before they get to the children composables by overriding the onPreScroll and onPreFling functions.
And afterwards, we get the scroll and fling events that were not consumed by the children composables using onPostScroll and onPostFling. These happen when the user has reached the bounds of the list, but they are still scrolling.

@Composable  
fun Modifier.customOverscroll(  
    ...
): Modifier {  

	...
	
	val nestedScrollConnection = remember {  
	    object : NestedScrollConnection {
		    // we will override the 
		    // functions here (onPostSroll, onPreFling, etc.)
	    }
	}   
	
	return this
		.onSizeChanged { ... } 
		.nestedScroll(nestedScrollConnection)
}

Here we create the NestedScrollConnection and simply apply it to our Modifier. Now, wherever we apply our customOverscroll, the scrollable children will pass their events through nestedScrollConnection.
Let's now override all 4 functions and learn how each is used to build our overscroll animation.

1. onPostScroll

This is called when the list has reached its bounds but the user is still scrolling, with their finger still in contact with the screen.

override fun onPostScroll(  
    consumed: Offset,  
    available: Offset,  
    source: NestedScrollSource  
): Offset {  
    scope.launch {  
        overscrollAmountAnimatable.snapTo(targetValue = calculateOverscroll(available))
    }  
    return Offset.Zero  
}

We will use the available Offset to calculate how much we should set our overscrollAmountAnimatable.

Since this is a user event action, we do not want any animation on overscrollAmountAnimatable so as to appear as responsive as possible. That's why we set its value using snapTo rather than animateTo.

The calculating of the overscroll is happening inside the calculateOverscroll function. This is because we will reuse its logic later when overriding the onPreScroll function.

private fun calculateOverscroll(available: Offset): Float {  
    val previous = overscrollAmountAnimatable.value  
    val newValue = previous + when (orientation) {  
        Orientation.Vertical -> available.y  
        Orientation.Horizontal -> available.x  
    }  
    return when {  
        previous > 0 -> newValue.coerceAtLeast(0f)  
        previous < 0 -> newValue.coerceAtMost(0f)  
        else -> newValue  
    }  
}

This function takes into account the orientation of the scrolling and adds the amount from the correct axis. After that, it makes sure that the overscroll value never switches from positive to negative and vice versa. This is to prevent the overscroll animation jumping to the other end of the list.

And with all that, we get this:

List overscrolls but does not settle back to starting point

Note that the list overscrolls, but does not settle back when released. This will be fixed in the next section.

2. onPostFling

This is called when the user flings the list with enough velocity to reach its bounds and beyond. Once the list reaches its end, onPostFling will give us the velocity of the scroll. We can then use this to animate our overscrollAmountAnimatable value.

override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { 
    val availableVelocity = when (orientation) {  
        Orientation.Vertical -> available.y  
        Orientation.Horizontal -> available.x  
    }  
    
    overscrollAmountAnimatable.animateTo(  
        targetValue = 0f,  
        initialVelocity = availableVelocity,  
        animationSpec = animationSpec  
    )  
    
    return available  
}

First we get the available velocity for our corresponding orientation. We then use this velocity in our animateTo function as we animate the overscrollAmountAnimatable to 0f. At this point, overscrollAmountAnimatable is already at 0, but the initial velocity forces it beyond 0 before settling back. This gives us this result when the list is scrolled with enough velocity to reach its bounds.

Overscroll animation works when the list is flinged from the center

And now, the list will always settle back to the start (or end) when overscrolled.

3. onPreScroll

At this point, the basic functionality is implemented. A user will see the bouncy animation when they reach the end of the list. But things get buggy when we start playing around with the overscroll animation.
For example, if we overscroll the list, and then, without lifting our finger, start scrolling in the opposite direction.

Visual bug when the list is scrolled before overscroll animation is completed

As you can see, our nestedScrollConnection sends this scroll event to our list, causing an ugly visual bug.

Enter onPreScroll!

override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {  
    if (overscrollAmountAnimatable.value != 0f && source != NestedScrollSource.SideEffect) {  
        scope.launch {  
            overscrollAmountAnimatable.snapTo(calculateOverscroll(available))  
        }  
        return available  
    }  
  
    return super.onPreScroll(available, source)  
}

When the user has overscrolled and they are still have their finger on the screen, we will perform the same operation as in onPostScroll using the same calculateOverscroll() function from earlier.

Now if we scroll in the opposite direction after overscrolling, we won't affect the list itself until appropriate.

onPreScroll is implemented and fixes scrolling bug

4. onPreFling

I saved the most complicated, and the most inconsequential for last ;)
The issue that is solved by overriding this function is a little harder to discover.
But it's the little interactions that can make or break an animation.

You can trigger this bug when you overscroll a list, and then fling it in the opposite direction. If onPreFling is called before overscrollAmountAnimatable gets to 0, then we get a similar bug like before where the list starts scrolling before the overscroll animation finishes.
If that's not clear, here is a visual of the bug.

Visual bug when list is flinged while overscroll animation is not completed

The code for this is much more than the previous overrides. But don't worry. I will post it in full first, and then dissect its functionality.

override suspend fun onPreFling(available: Velocity): Velocity {  
    val availableVelocity = when (orientation) {  
        Orientation.Vertical -> available.y  
        Orientation.Horizontal -> available.x  
    }  
  
    if (overscrollAmountAnimatable.value != 0f && availableVelocity != 0f) {  
        val previousSign = overscrollAmountAnimatable.value.sign  
        var consumedVelocity = availableVelocity  
        val predictedEndValue = exponentialDecay<Float>().calculateTargetValue(  
            initialValue = overscrollAmountAnimatable.value,  
            initialVelocity = availableVelocity,  
        )  
        if (predictedEndValue.sign == previousSign) {  
            overscrollAmountAnimatable.animateTo(  
                targetValue = 0f,  
                initialVelocity = availableVelocity,  
                animationSpec = animationSpec,  
            )  
        } else {  
            try {  
                overscrollAmountAnimatable.animateDecay(  
                    initialVelocity = availableVelocity,  
                    animationSpec = exponentialDecay()  
                ) {  
                    if (value.sign != previousSign) {  
                        consumedVelocity -= velocity  
                        scope.launch {  
                            overscrollAmountAnimatable.snapTo(0f)  
                        }  
                    }  
                }  
            } catch (e: Exception) {  
            }  
        }  
  
        return when (orientation) {  
            Orientation.Vertical -> Velocity(0f, consumedVelocity)  
            Orientation.Horizontal -> Velocity(consumedVelocity, 0f)  
        }  
    }  
  
    return super.onPreFling(available)  
}

This code predicts where a fling will land on, and changes the animation technique accordingly.

val availableVelocity = when (orientation) {  
    Orientation.Vertical -> available.y  
    Orientation.Horizontal -> available.x  
} 

First, we get the availableVelocity according to the current orientation.

if (overscrollAmountAnimatable.value != 0f && availableVelocity != 0f) {  
    ...
}  
  
return super.onPreFling(available)

Next, we check if the list is currently overscrolled, or if we have any velocity available. If any of these two conditions are false, we will not perform any operations.

var consumedVelocity = availableVelocity  
val previousSign = overscrollAmountAnimatable.value.sign  
val predictedEndValue = exponentialDecay<Float>().calculateTargetValue(  
    initialValue = overscrollAmountAnimatable.value,  
    initialVelocity = availableVelocity,  
)

Inside the if statement, we set up our consumedVelocity. In the previous overrides, we did not really care how much we consumed from the available Offset or Velocity. But here we have to calculate accurately, otherwise, we will have some unsightly stuttering when the velocity randomly changes.
Next we get the sign of the current overscrollAmountAnimatable value. We will use this to decide which animation method to use, and detect a change in signs later on.
Last, we will predict the end value of the fling using calculateTargetValue(). As the name suggests, this does not run any animation, but just gives us the end value based on the velocity and current value.

if (predictedEndValue.sign == previousSign) {  
    overscrollAmountAnimatable.animateTo(  
        targetValue = 0f,  
        initialVelocity = availableVelocity,  
        animationSpec = animationSpec,  
    )  
} else {  
    ...
}

If the sign of the predictedEndValue does not change, then we know that the fling was not powerful enough to reach the bounds of the list. In this case, we will use the familiar animateTo() to settle the overscrollAmountAnimatable value to 0.

if (predictedEndValue.sign == previousSign) {  
    ... 
} else {  
    try {  
        overscrollAmountAnimatable.animateDecay(  
            initialVelocity = availableVelocity,  
            animationSpec = exponentialDecay()  
        ) {  
            if (value.sign != previousSign) {  
                consumedVelocity -= velocity  
                scope.launch {  
                    overscrollAmountAnimatable.snapTo(0f)  
                }  
            }  
        }  
    } catch (e: Exception) {  
    }  
}

But if the sign of the predictedEndValue changes, then the fling was powerful enough to go beyond the bounds of the list. This means that after the list reaches the bounds, we will have to transfer the leftover velocity to scroll the list itself.
Instead of animateTo(), we will use animateDecay(). This function does not take a target value, but just applies the velocity and the value. It will eventually stop when the exponentialDecay function brings the velocity down to 0.

The exponentialDecay() function can take in different friction and threshold values. You could tweak these, but just make sure that you use the same values in the exponentialDecay() that calculates the predictedEndValue from earlier.

But in our case, we will prevent the velocity from ever hitting 0. Once the value of the animation changes signs, we will subtract the animation's current velocity from consumedVelocity and then snap overscrollAmountAnimatable to 0f. We do all this in animateDecay()'s block parameter, that is called on every frame of the animation.
Since we are interrupting overscrollAmountAnimatable's animation with the snapTo() call, it will throw a MutationInterruptedException. This interruption is expected though so we surround the animateDecay() call in a try/catch block to ensure that the code execution continues.

if (predictedEndValue.sign == previousSign) {  
    ... 
} else {  
    ...
}  
  
return when (orientation) {  
    Orientation.Vertical -> Velocity(0f, consumedVelocity)  
    Orientation.Horizontal -> Velocity(consumedVelocity, 0f)  
}

And finally, we construct a Velocity object based on the orientation and return that.

Whew!

Now when we fling the list after an overscroll, it behaves appropriately.

onPreFling is implemented fixing the flinging bug

Conclusion

And with that, we have a custom modifier for implementing overscroll animations. Remember, with all the orientation specific code, this will work not only for LazyColumn, but also LazyRow, HorizontalPager and VerticalPager.
On top of that, once you get the distance from onNewOverscrollAmount, you can implement any animation you can think of using this. One could do classic animations like the android 4.0 holo glow, or wild new effects using shaders ;)
Go crazy!
For example, I added a simple spacing offset to the list items to make them more spaced out as you overscroll:

ListItem(  
    modifier = Modifier  
        .graphicsLayer {  
            translationY = (animatedOverscrollAmount * .07f) *  
                    if (animatedOverscrollAmount > 0) index else ratings.lastIndex - index  
        },
)

I would love to see what you will come up with. And if you experience any bugs or have any questions, let me know in the comments. Nested scrolling can be a buggy affair sometimes. Code for the customOverscroll modifier is available here.

Thanks for reading and good luck!

Mastodon