overscroll

Compose Modifier

Common
fun Modifier.overscroll(overscrollEffect: OverscrollEffect?): Modifier

Renders overscroll from the provided overscrollEffect.

This modifier attaches the provided overscrollEffect's OverscrollEffect.node to the hierarchy, which renders the actual effect. Note that this modifier is only responsible for the visual part of overscroll - on its own it will not handle input events. In addition to using this modifier you also need to propagate events to the overscrollEffect, most commonly by using a androidx.compose.foundation.gestures.scrollable.

Alternatively, you can use a higher level API such as verticalScroll or androidx.compose.foundation.lazy.LazyColumn and provide a custom OverscrollEffect - these components will both render and provide events to the OverscrollEffect, so you do not need to manually render the effect with this modifier.

Parameters

overscrollEffectthe OverscrollEffect to render

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