Overscroll effect

On devices running Android 12 and higher, the visual behavior for overscroll events changes.

On Android 11 and lower, an overscroll event causes the visual elements to have a glow; on Android 12 and higher, the visual elements stretch and bounce back on a drag event and they fling and bounce back on a fling event:

New overscroll behaviors affect dragging and flinging animations.

The behavior applies to all apps that use EdgeEffect, and for all content that is inside the following classes:

The visual effect works for both vertical and horizontal scrolling. Because it applies by default to all apps that don't opt out of overscroll, it provides a more-consistent UI experience for users.

Best practices

To ensure that the new overscroll experience works well with your app, follow these best practices:

Stretch EdgeEffect usage

EdgeEffect adds two APIs for implementing the stretch overscroll effect.

float getDistance()
float onPullDistance(float deltaDistance, float displacement)

To give the best user experience with stretch overscroll, do the following:

  • When the user releases and touches the contents during the release animation, register the touch as a "catch." The user stops the animation and begins manipulating the stretch again.
  • When the user moves their finger in the opposite direction of the stretch, release the stretch until it is fully gone and then begin scrolling.
  • When the user flings during a stretch, fling the EdgeEffect to enhance the stretch effect.

Catch the animation

When a user catches an active stretch animation, EdgeEffect.isFinished() returns false. This indicates that the stretch should be manipulated by the touch motion. In most containers, this is detected in onInterceptTouchEvent(), as shown in the following code snippet:

Kotlin

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
  ...
  when (action and MotionEvent.ACTION_MASK) {
    MotionEvent.ACTION_DOWN ->
      ...
      isBeingDragged = !edgeEffectBottom.isFinished() ||
          !edgeEffectTop.isFinished()
      ...
  }
  return isBeingDragged
}

Java

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
  ...
  switch (action & MotionEvent.ACTION_MASK) {
    case MotionEvent.ACTION_DOWN:
      ...
      mIsBeingDragged = !mEdgeEffectBottom.isFinished()
          || !mEdgeEffectTop.isFinished();
      ...

In the preceding example, onInterceptTouchEvent() returns true when mIsBeingDragged is true, so it is sufficient for consuming the event before the child has an opportunity to consume it.

Release the overscroll effect

It's important to release the stretch effect prior to scrolling to keep the stretch from be applied to the scrolling content. The following code sample shows this:

Kotlin

override fun onTouchEvent(ev: MotionEvent): Boolean {
  val activePointerIndex = ev.actionIndex

  when (ev.getActionMasked()) {
    MotionEvent.ACTION_MOVE ->
      val x = ev.getX(activePointerIndex)
      val y = ev.getY(activePointerIndex)
      var deltaY = y - mLastMotionY
      val pullDistance = deltaY / height
      val displacement = x / width

      if (deltaY < 0f && mEdgeEffectTop.distance > 0f) {
        deltaY -= height * mEdgeEffectTop
            .onPullDistance(pullDistance, displacement);
      }
      if (deltaY > 0f && mEdgeEffectBottom.distance > 0f) {
        deltaY += height * mEdgeEffectBottom
            .onPullDistance(-pullDistance, 1 - displacement);
      }

      ...
  }

Java

@Override
public boolean onTouchEvent(MotionEvent ev) {

  final int actionMasked = ev.getActionMasked();

  switch (actionMasked) {
    case MotionEvent.ACTION_MOVE:
      final float x = ev.getX(activePointerIndex);
      final float y = ev.getY(activePointerIndex);
      float deltaY = y - mLastMotionY;
      float pullDistance = deltaY / getHeight();
      float displacement = x / getWidth();

      if (deltaY < 0 && mEdgeEffectTop.getDistance() > 0) {
        deltaY -= getHeight() * mEdgeEffectTop
            .onPullDistance(pullDistance, displacement);
      }
      if (deltaY > 0 && mEdgeEffectBottom.getDistance() > 0) {
        deltaY += getHeight() * mEdgeEffectBottom
            .onPullDistance(-pullDistance, 1 - displacement);
      }
            ...

When dragging, before passing the touch event to nested scrolling or dragging the scroll, the EdgeEffect's pull distance must be consumed. In the preceding code sample, getDistance() returns a positive value when an edge effect is being displayed and can be released with motion. When the touch event releases the stretch, it is first consumed by the EdgeEffect so that it will be completely released before other effects, such as nested scrolling, are displayed. You can use getDistance() to learn how much pull distance is required to release the current effect.

onPullDistance() differs from onPull() by returning the consumed amount of the passed delta. onPull() had previously allowed negative values for the total distance for glow effects. On Android 12 and higher, if onPull() or onPullDistance() are passed negative deltaDistance values when getDistance() is 0, there will be no change in the stretch.

Opt out

You can opt out of overscroll in your XML layout file or programmatically. The following XML code shows the android:overScrollMode set in the layout file.

<!-- Via markup -->
<ScrollView
  ...
  android:overScrollMode="never"
  ...
>

Opt out programmatically, as shown in the following code snippet:

Kotlin

<!-- Programmatically-->
...
recyclerview.overScrollMode = View.OVER_SCROLL_NEVER
...

Java

<!-- Programmatically-->
...
recyclerview.setOverScrollMode(View.OVER_SCROLL_NEVER);
...

Provide feedback

Your feedback is important to us. Let us know if you discover issues or have ideas for improving this feature. Take a look at the existing issues before you create a new one. You can add your vote to an existing issue by clicking the star button.

Create a new issue

See the Issue Tracker documentation for more information.