OverscrollEffect


An OverscrollEffect represents a visual effect that displays when the edges of a scrolling container have been reached with a scroll or fling. For the default platform effect that should be used in most cases, see androidx.compose.foundation.gestures.ScrollableDefaults.overscrollEffect.

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.

Note: this API is currently experimental and liable to change. The overall behavioral characteristics of applyToScroll and applyToFling are unlikely to change, but the relationship and layering between the stateful part of overscroll and the effectModifier is likely to be changed in the future. This should not have a large impact on custom overscroll implementations, but it may require you to separate parts of the implementation into different parts that implement different interfaces. Fewer changes are expected if you are not implementing a custom OverscrollEffect, but instead are just calling applyToFling and applyToScroll on an existing instance.

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.overscroll
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.IntOffset

@OptIn(ExperimentalFoundationApi::class)
// 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.Drag) {
            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

    // as we're building an offset modifiers, let's offset of our value we calculated
    override val effectModifier: Modifier = Modifier.offset {
        IntOffset(x = 0, y = overscrollOffset.value.roundToInt())
    }
}

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)
    )
}

Summary

Public functions

suspend Unit
applyToFling(
    velocity: Velocity,
    performFling: suspend (Velocity) -> Velocity
)

Applies overscroll to performFling.

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

Applies overscroll to performScroll.

Cmn

Public properties

Modifier

A Modifier that will draw this OverscrollEffect

Cmn
Boolean

Whether this OverscrollEffect is currently displaying overscroll.

Cmn

Public functions

applyToFling

suspend fun applyToFling(
    velocity: Velocity,
    performFling: suspend (Velocity) -> Velocity
): Unit

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:

import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember

var dragPosition by remember { mutableStateOf(0f) }
val minPosition = -1000f
val maxPosition = 1000f

val draggableState = rememberDraggableState { delta ->
    val newPosition = (dragPosition + delta).coerceIn(minPosition, maxPosition)
    dragPosition = newPosition
}

Box(
    Modifier
        .size(100.dp)
        .draggable(draggableState, orientation = Orientation.Horizontal),
    contentAlignment = Alignment.Center
) {
    Text("Drag position $dragPosition")
}

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.

import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.overscroll
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Velocity

var dragPosition by remember { mutableStateOf(0f) }
val minPosition = -1000f
val maxPosition = 1000f

val overscrollEffect = ScrollableDefaults.overscrollEffect()

val draggableState = rememberDraggableState { delta ->
    // Horizontal, so convert the delta to a horizontal offset
    val deltaAsOffset = Offset(delta, 0f)
    // Wrap the original logic inside applyToScroll
    overscrollEffect.applyToScroll(deltaAsOffset, NestedScrollSource.Drag) { remainingOffset ->
        val remainingDelta = remainingOffset.x
        val newPosition = (dragPosition + remainingDelta).coerceIn(minPosition, maxPosition)
        // Calculate how much delta we have consumed
        val consumed = newPosition - dragPosition
        dragPosition = newPosition
        // Return how much offset we consumed, so that we can show overscroll for what is left
        Offset(consumed, 0f)
    }
}

Box(
    Modifier
        // Draw overscroll on the box
        .overscroll(overscrollEffect)
        .size(100.dp)
        .draggable(
            draggableState,
            orientation = Orientation.Horizontal,
            onDragStopped = {
                overscrollEffect.applyToFling(Velocity(it, 0f)) { velocity ->
                    if (dragPosition == minPosition || dragPosition == maxPosition) {
                        // If we are at the min / max bound, give overscroll all of the velocity
                        Velocity.Zero
                    } else {
                        // If we aren't at the min / max bound, consume all of the velocity so
                        // overscroll won't show. Normally in this case something like
                        // Modifier.scrollable would use the velocity to update the scroll state
                        // with a fling animation, but just do nothing to keep this simpler.
                        velocity
                    }
                }
            }
        ),
    contentAlignment = Alignment.Center
) {
    Text("Drag position $dragPosition")
}
Parameters
velocity: Velocity

total Velocity available

performFling: suspend (Velocity) -> Velocity

the 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.

applyToScroll

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:

import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember

var dragPosition by remember { mutableStateOf(0f) }
val minPosition = -1000f
val maxPosition = 1000f

val draggableState = rememberDraggableState { delta ->
    val newPosition = (dragPosition + delta).coerceIn(minPosition, maxPosition)
    dragPosition = newPosition
}

Box(
    Modifier
        .size(100.dp)
        .draggable(draggableState, orientation = Orientation.Horizontal),
    contentAlignment = Alignment.Center
) {
    Text("Drag position $dragPosition")
}

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.

import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.overscroll
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Velocity

var dragPosition by remember { mutableStateOf(0f) }
val minPosition = -1000f
val maxPosition = 1000f

val overscrollEffect = ScrollableDefaults.overscrollEffect()

val draggableState = rememberDraggableState { delta ->
    // Horizontal, so convert the delta to a horizontal offset
    val deltaAsOffset = Offset(delta, 0f)
    // Wrap the original logic inside applyToScroll
    overscrollEffect.applyToScroll(deltaAsOffset, NestedScrollSource.Drag) { remainingOffset ->
        val remainingDelta = remainingOffset.x
        val newPosition = (dragPosition + remainingDelta).coerceIn(minPosition, maxPosition)
        // Calculate how much delta we have consumed
        val consumed = newPosition - dragPosition
        dragPosition = newPosition
        // Return how much offset we consumed, so that we can show overscroll for what is left
        Offset(consumed, 0f)
    }
}

Box(
    Modifier
        // Draw overscroll on the box
        .overscroll(overscrollEffect)
        .size(100.dp)
        .draggable(
            draggableState,
            orientation = Orientation.Horizontal,
            onDragStopped = {
                overscrollEffect.applyToFling(Velocity(it, 0f)) { velocity ->
                    if (dragPosition == minPosition || dragPosition == maxPosition) {
                        // If we are at the min / max bound, give overscroll all of the velocity
                        Velocity.Zero
                    } else {
                        // If we aren't at the min / max bound, consume all of the velocity so
                        // overscroll won't show. Normally in this case something like
                        // Modifier.scrollable would use the velocity to update the scroll state
                        // with a fling animation, but just do nothing to keep this simpler.
                        velocity
                    }
                }
            }
        ),
    contentAlignment = Alignment.Center
) {
    Text("Drag position $dragPosition")
}
Parameters
delta: Offset

total scroll delta available

source: NestedScrollSource

the source of the delta

performScroll: (Offset) -> Offset

the 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
Offset

the delta consumed from delta by the operation of this function - including that consumed by performScroll.

Public properties

effectModifier

val effectModifierModifier

A Modifier that will draw this OverscrollEffect

isInProgress

val isInProgressBoolean

Whether this OverscrollEffect is currently displaying overscroll.

Returns
Boolean

true if this OverscrollEffect is currently displaying overscroll