OverscrollEffect

Interface

Common
interface OverscrollEffect

An OverscrollEffect represents a visual effect that displays when the edges of a scrolling container have been reached with a scroll or fling. To create an instance of the default / currently provided OverscrollFactory, use rememberOverscrollEffect.

To implement, make sure to override node - this has a default implementation for compatibility reasons, but is required for an OverscrollEffect to render.

OverscrollEffect conceptually 'decorates' scroll / fling events: consuming some of the delta or velocity before and/or after the event is consumed by the scrolling container. applyToScroll applies overscroll to a scroll event, and applyToFling applies overscroll to a fling.

Higher level components such as androidx.compose.foundation.lazy.LazyColumn will automatically configure an OverscrollEffect for you. To use a custom OverscrollEffect you first need to provide it with scroll and/or fling events - usually by providing it to a androidx.compose.foundation.gestures.scrollable. Then you can draw the effect on top of the scrolling content using Modifier.overscroll.

Properties

Common
val isInProgress: Boolean

Whether this OverscrollEffect is currently displaying overscroll.

Returns

true if this OverscrollEffect is currently displaying overscroll
Common

Deprecated This has been replaced with node. If you are calling this property to render overscroll, use Modifier.overscroll() instead. If you are implementing OverscrollEffect, override node instead to render your overscroll.

val effectModifier: Modifier

A Modifier that will draw this OverscrollEffect

This API is deprecated- implementers should instead override node. Callers should use Modifier.overscroll.

Common
val node: DelegatableNode

The DelegatableNode that will render this OverscrollEffect and provide any required size or other information to this effect.

In most cases you should use Modifier.overscroll to render this OverscrollEffect, which will internally attach this node to the hierarchy. The node should be attached before applyToScroll or applyToFling is called to ensure correctness.

This property should return a single instance, and can only be attached once, as with other DelegatableNodes.

Functions

fun applyToScroll(
        delta: Offset,
        source: NestedScrollSource,
        performScroll: (Offset) -> Offset,
    ): Offset

Applies overscroll to performScroll. performScroll should represent a drag / scroll, and returns the amount of delta consumed, so in simple cases the amount of overscroll to show should be equal to delta - performScroll(delta). The OverscrollEffect can optionally consume some delta before calling performScroll, such as to release any existing tension. The implementation must call performScroll exactly once. This function should return the sum of all the delta that was consumed during this operation - both by the overscroll and performScroll.

For example, assume we want to apply overscroll to a custom component that isn't using androidx.compose.foundation.gestures.scrollable. Here is a simple example of a component using androidx.compose.foundation.gestures.draggable instead:

To apply overscroll, we need to decorate the existing logic with applyToScroll, and return the amount of delta we have consumed when updating the drag position. Note that we also need to call applyToFling - this is used as an end signal for overscroll so that effects can correctly reset after any animations, when the gesture has stopped.

Parameters

deltatotal scroll delta available
sourcethe source of the delta
performScrollthe scroll action that the overscroll is applied to. The Offset parameter represents how much delta is available, and the return value is how much delta was consumed. Any delta that was not consumed should be used to show the overscroll effect.

Returns

the delta consumed from delta by the operation of this function - including that consumed by performScroll.
suspend fun applyToFling(velocity: Velocity, performFling: suspend (Velocity) -> Velocity)

Applies overscroll to performFling. performFling should represent a fling (the release of a drag or scroll), and returns the amount of Velocity consumed, so in simple cases the amount of overscroll to show should be equal to velocity - performFling(velocity). The OverscrollEffect can optionally consume some Velocity before calling performFling, such as to release any existing tension. The implementation must call performFling exactly once.

For example, assume we want to apply overscroll to a custom component that isn't using androidx.compose.foundation.gestures.scrollable. Here is a simple example of a component using androidx.compose.foundation.gestures.draggable instead:

To apply overscroll, we decorate the existing logic with applyToScroll, and return the amount of delta we have consumed when updating the drag position. We then call applyToFling using the velocity provided by onDragStopped.

Parameters

velocitytotal Velocity available
performFlingthe Velocity consuming lambda that the overscroll is applied to. The Velocity parameter represents how much Velocity is available, and the return value is how much Velocity was consumed. Any Velocity that was not consumed should be used to show the overscroll effect.

Code Examples

OverscrollSample

@Composable
fun OverscrollSample() {
    // our custom offset overscroll that offset the element it is applied to when we hit the bound
    // on the scrollable container.
    class OffsetOverscrollEffect(val scope: CoroutineScope) : OverscrollEffect {
        private val overscrollOffset = Animatable(0f)
        override fun applyToScroll(
            delta: Offset,
            source: NestedScrollSource,
            performScroll: (Offset) -> Offset,
        ): Offset {
            // in pre scroll we relax the overscroll if needed
            // relaxation: when we are in progress of the overscroll and user scrolls in the
            // different direction = substract the overscroll first
            val sameDirection = sign(delta.y) == sign(overscrollOffset.value)
            val consumedByPreScroll =
                if (abs(overscrollOffset.value) > 0.5 && !sameDirection) {
                    val prevOverscrollValue = overscrollOffset.value
                    val newOverscrollValue = overscrollOffset.value + delta.y
                    if (sign(prevOverscrollValue) != sign(newOverscrollValue)) {
                        // sign changed, coerce to start scrolling and exit
                        scope.launch { overscrollOffset.snapTo(0f) }
                        Offset(x = 0f, y = delta.y + prevOverscrollValue)
                    } else {
                        scope.launch { overscrollOffset.snapTo(overscrollOffset.value + delta.y) }
                        delta.copy(x = 0f)
                    }
                } else {
                    Offset.Zero
                }
            val leftForScroll = delta - consumedByPreScroll
            val consumedByScroll = performScroll(leftForScroll)
            val overscrollDelta = leftForScroll - consumedByScroll
            // if it is a drag, not a fling, add the delta left to our over scroll value
            if (abs(overscrollDelta.y) > 0.5 && source == NestedScrollSource.UserInput) {
                scope.launch {
                    // multiply by 0.1 for the sake of parallax effect
                    overscrollOffset.snapTo(overscrollOffset.value + overscrollDelta.y * 0.1f)
                }
            }
            return consumedByPreScroll + consumedByScroll
        }
        override suspend fun applyToFling(
            velocity: Velocity,
            performFling: suspend (Velocity) -> Velocity,
        ) {
            val consumed = performFling(velocity)
            // when the fling happens - we just gradually animate our overscroll to 0
            val remaining = velocity - consumed
            overscrollOffset.animateTo(
                targetValue = 0f,
                initialVelocity = remaining.y,
                animationSpec = spring(),
            )
        }
        override val isInProgress: Boolean
            get() = overscrollOffset.value != 0f
        // Create a LayoutModifierNode that offsets by overscrollOffset.value
        override val node: DelegatableNode =
            object : Modifier.Node(), LayoutModifierNode {
                override fun MeasureScope.measure(
                    measurable: Measurable,
                    constraints: Constraints,
                ): MeasureResult {
                    val placeable = measurable.measure(constraints)
                    return layout(placeable.width, placeable.height) {
                        val offsetValue = IntOffset(x = 0, y = overscrollOffset.value.roundToInt())
                        placeable.placeRelativeWithLayer(offsetValue.x, offsetValue.y)
                    }
                }
            }
    }
    val offset = remember { mutableStateOf(0f) }
    val scope = rememberCoroutineScope()
    // Create the overscroll controller
    val overscroll = remember(scope) { OffsetOverscrollEffect(scope) }
    // let's build a scrollable that scroll until -512 to 512
    val scrollStateRange = (-512f).rangeTo(512f)
    Box(
        Modifier.size(150.dp)
            .scrollable(
                orientation = Orientation.Vertical,
                state =
                    rememberScrollableState { delta ->
                        // use the scroll data and indicate how much this element consumed.
                        val oldValue = offset.value
                        // coerce to our range
                        offset.value = (offset.value + delta).coerceIn(scrollStateRange)
                        offset.value - oldValue // indicate that we consumed what's needed
                    },
                // pass the overscroll to the scrollable so the data is updated
                overscrollEffect = overscroll,
            )
            .background(Color.LightGray),
        contentAlignment = Alignment.Center,
    ) {
        Text(
            offset.value.roundToInt().toString(),
            style = TextStyle(fontSize = 32.sp),
            modifier =
                Modifier
                    // show the overscroll only on the text, not the containers (just for fun)
                    .overscroll(overscroll),
        )
    }
}