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.

import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.rememberScrollableState import androidx.compose.foundation.gestures.scrollable 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.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp // 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),  ) }

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

open Modifier

This property is deprecated. This has been replaced with `node`.

Cmn
Boolean

Whether this OverscrollEffect is currently displaying overscroll.

Cmn
open DelegatableNode

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

Cmn

Extension functions

OverscrollEffect

Returns a wrapped version of this that will not handle any incoming events.

Cmn
OverscrollEffect

Returns a wrapped version of this with an empty OverscrollEffect.node.

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.Orientation 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 import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp 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.OverscrollEffect import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.overscroll import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.material.Text import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp var dragPosition by remember { mutableStateOf(0f) } val minPosition = -1000f val maxPosition = 1000f val overscrollEffect = rememberOverscrollEffect() val draggableState = rememberDraggableState { delta ->  // Horizontal, so convert the delta to a horizontal offset  val deltaAsOffset = Offset(delta, 0f)  val performDrag: (Offset) -> Offset = { 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)  }  if (overscrollEffect != null) {  // Wrap the original logic inside applyToScroll  overscrollEffect.applyToScroll(deltaAsOffset, NestedScrollSource.UserInput, performDrag)  } else {  performDrag(deltaAsOffset)  } } 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.Orientation 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 import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp 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.OverscrollEffect import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.overscroll import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.material.Text import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp var dragPosition by remember { mutableStateOf(0f) } val minPosition = -1000f val maxPosition = 1000f val overscrollEffect = rememberOverscrollEffect() val draggableState = rememberDraggableState { delta ->  // Horizontal, so convert the delta to a horizontal offset  val deltaAsOffset = Offset(delta, 0f)  val performDrag: (Offset) -> Offset = { 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)  }  if (overscrollEffect != null) {  // Wrap the original logic inside applyToScroll  overscrollEffect.applyToScroll(deltaAsOffset, NestedScrollSource.UserInput, performDrag)  } else {  performDrag(deltaAsOffset)  } } 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

open val effectModifierModifier

A Modifier that will draw this OverscrollEffect

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

isInProgress

val isInProgressBoolean

Whether this OverscrollEffect is currently displaying overscroll.

Returns
Boolean

true if this OverscrollEffect is currently displaying overscroll

node

open val nodeDelegatableNode

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.

Extension functions

withoutEventHandling

fun OverscrollEffect.withoutEventHandling(): OverscrollEffect

Returns a wrapped version of this that will not handle any incoming events. This means that calls to OverscrollEffect.applyToScroll / OverscrollEffect.applyToFling will directly execute the provided performScroll / performFling lambdas, without the OverscrollEffect ever seeing the incoming values. OverscrollEffect.node will still be attached, so that overscroll can render.

This can be useful if you want to render an OverscrollEffect in a different component that normally provides events to overscroll, such as a androidx.compose.foundation.lazy.LazyColumn. Use this along with withoutVisualEffect to create two wrapped instances: one that does not handle events, and one that does not draw, so you can ensure that the overscroll effect is only rendered once, and only receives events from one source.

withoutVisualEffect

fun OverscrollEffect.withoutVisualEffect(): OverscrollEffect

Returns a wrapped version of this with an empty OverscrollEffect.node. This prevents the overscroll effect from applying any visual effect, but it will still handle events.

This can be used along with withoutEventHandling in cases where you wish to change where overscroll is rendered for a given component. Pass this wrapped instance that doesn't render to the component that handles events (such as androidx.compose.foundation.lazy.LazyColumn) to prevent it from drawing the overscroll effect. Then to separately render the original overscroll effect, you can directly pass it to Modifier.overscroll (since that modifier only renders, and does not handle events). If instead you want to draw the overscroll in another component that handles events, such as a different lazy list, you need to first wrap the original overscroll effect with withoutEventHandling to prevent it from also dispatching events.

import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.overscroll import androidx.compose.foundation.rememberOverscrollEffect import androidx.compose.foundation.withoutVisualEffect import androidx.compose.material.Text import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp val items = remember { (1..100).toList() } val state = rememberLazyListState() val overscroll = rememberOverscrollEffect() // Create a wrapped version of the above overscroll effect that does not apply a visual effect. // This will be used inside LazyColumn to provide events to overscroll, without letting // LazyColumn render the overscroll effect internally. val overscrollWithoutVisualEffect = overscroll?.withoutVisualEffect() LazyColumn(  content = { items(items) { Text("Item $it") } },  state = state,  modifier =  Modifier.size(300.dp)  .clip(RectangleShape)  // Manually render the overscroll on top of the lazy list _and_ the 'decorations' we  // are  // manually drawing, to make sure they will also be included in the overscroll  // effect.  .overscroll(overscroll)  .drawBehind {  state.layoutInfo.visibleItemsInfo.drop(1).forEach { info ->  val verticalOffset = info.offset.toFloat()  drawLine(  color = Color.Red,  start = Offset(0f, verticalOffset),  end = Offset(size.width, verticalOffset),  )  }  },  // Pass the overscroll effect that does not apply a visual effect inside the LazyList to  // receive overscroll events  overscrollEffect = overscrollWithoutVisualEffect, )