Subscribe for UI recipes
Overscroll animations in Jetpack Compose
Add bouncy overscroll animations to all your scrolling elements
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.
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 theorientation
by overloading this Modifier forLazyListState
andPagerState
. 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 onoverscrollAmountAnimatable
so as to appear as responsive as possible. That's why we set its value usingsnapTo
rather thananimateTo
.
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:
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.
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.
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.
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.
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.
TheexponentialDecay()
function can take in different friction and threshold values. You could tweak these, but just make sure that you use the same values in theexponentialDecay()
that calculates thepredictedEndValue
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.
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!