Animate a scroll gesture

In Android, scrolling is typically achieved by using the ScrollView class. Any standard layout that might extend beyond the bounds of its container should be nested in a ScrollView to provide a scrollable view that's managed by the framework. Implementing a custom scroller should only be necessary for special scenarios. This lesson describes such a scenario: displaying a scrolling effect in response to touch gestures using scrollers.

Your app can use scrollers (Scroller or OverScroller) to collect the data needed to produce a scrolling animation in response to a touch event. They are similar, but OverScroller also includes methods for indicating to users that they've reached the content edges after a pan or fling gesture.

  • Starting in Android 12 (API level 31), the visual elements stretch and bounce back on a drag event, and fling and bounce back on a fling event.
  • On Android 11 (API level 30) and lower, the boundaries display a "glow" effect after a drag or fling gesture to the edge.

The InteractiveChart sample uses the EdgeEffect class (actually the EdgeEffectCompat class) to display these overscroll effects.

Note: We recommend using OverScroller rather than Scroller for scrolling animations. OverScroller provides the best backward compatibility with older devices.
Also, note that you generally only need to use scrollers when implementing scrolling yourself. ScrollView and HorizontalScrollView do all of this for you if you nest your layout within them.

A scroller is used to animate scrolling over time, using platform-standard scrolling physics such as friction, velocity, and other qualities. The scroller itself doesn't actually draw anything. Scrollers track scroll offsets for you over time, but they don't automatically apply those positions to your view. It's your responsibility to get and apply new coordinates at a rate that will make the scrolling animation look smooth.

Refer to the following related resources:

Understand scrolling terminology

"Scrolling" is a word that can take on different meanings in Android, depending on the context.

Scrolling is the general process of moving the viewport (that is, the 'window' of content you're looking at). When scrolling is in both the x and y axes, it's called panning. The sample application provided with this class, InteractiveChart, illustrates two different types of scrolling, dragging and flinging:

  • Dragging is the type of scrolling that occurs when a user drags their finger across the touch screen. Simple dragging is often implemented by overriding onScroll() in GestureDetector.OnGestureListener. For more discussion of dragging, see Dragging and Scaling.
  • Flinging is the type of scrolling that occurs when a user drags and lifts their finger quickly. After the user lifts their finger, you generally want to keep scrolling (moving the viewport), but decelerate until the viewport stops moving. Flinging can be implemented by overriding onFling() in GestureDetector.OnGestureListener, and by using a scroller object. This is the use case that is the topic of this lesson.
  • Panning. When scrolling along both the X and Y axes, it's called panning.

It's common to use scroller objects in conjunction with a fling gesture, but they can be used in any context where you want the UI to display scrolling in response to a touch event. For example, you could override onTouchEvent() to process touch events directly, and produce a scrolling effect or a "snapping to page" animation in response to those touch events.

Components that contain built-in scrolling implementations

The following Android components contain built-in support for scrolling and overscrolling behavior:

  • RecyclerView
  • ListView
  • GridView
  • ScrollView
  • NestedScrollView
  • HorizontalScrollView
  • ViewPager
  • ViewPager2

If your app needs to support scrolling and overscrolling inside a different component, do the following:

  1. Create a custom, touch-based scrolling implementation.
  2. To support devices that run Android 12 and higher, implement the stretch overscroll effect.

Create a custom touch-based scrolling implementation

This section describes how to create your own scroller, if your app uses a component that doesn't contain built-in support for scrolling and overscrolling.

The following snippet comes from the InteractiveChart sample provided with this class. It uses a GestureDetector, and overrides the GestureDetector.SimpleOnGestureListener method onFling(). It uses OverScroller to track the fling gesture. If the user reaches the content edges after they perform the fling gesture, the container indicates that the user has reached the end of the content. The indication depends on the version of Android that a device runs:

  • On Android 12 and higher, the visual elements stretch and bounce back.
  • On Android 11 and lower, the visual elements display a "glow" effect.

Note: The InteractiveChart sample app displays a chart that you can zoom, pan, scroll, and so on. In the following snippet, mContentRect represents the rectangle coordinates within the view that the chart will be drawn into. At any given time, a subset of the total chart domain and range are drawn into this rectangular area. mCurrentViewport represents the portion of the chart that is currently visible in the screen. Because pixel offsets are generally treated as integers, mContentRect is of the type Rect. Because the graph domain and range are decimal/float values, mCurrentViewport is of the type RectF.

The first part of the snippet shows the implementation of onFling():

Kotlin

// The current viewport. This rectangle represents the currently visible
// chart domain and range. The viewport is the part of the app that the
// user manipulates via touch gestures.
private val mCurrentViewport = RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX)

// The current destination rectangle (in pixel coordinates) into which the
// chart data should be drawn.
private lateinit var mContentRect: Rect

private lateinit var mScroller: OverScroller
private lateinit var mScrollerStartViewport: RectF
...
private val mGestureListener = object : GestureDetector.SimpleOnGestureListener() {

    override fun onDown(e: MotionEvent): Boolean {
        // Initiates the decay phase of any active edge effects.
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
            releaseEdgeEffects()
        }
        mScrollerStartViewport.set(mCurrentViewport)
        // Aborts any active scroll animations and invalidates.
        mScroller.forceFinished(true)
        ViewCompat.postInvalidateOnAnimation(this@InteractiveLineGraphView)
        return true
    }
    ...
    override fun onFling(
            e1: MotionEvent,
            e2: MotionEvent,
            velocityX: Float,
            velocityY: Float
    ): Boolean {
        fling((-velocityX).toInt(), (-velocityY).toInt())
        return true
    }
}

private fun fling(velocityX: Int, velocityY: Int) {
    // Initiates the decay phase of any active edge effects.
    // On Android 12 and higher, the edge effect (stretch) should
    // continue.
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
            releaseEdgeEffects()
    }
    // Flings use math in pixels (as opposed to math based on the viewport).
    val surfaceSize: Point = computeScrollSurfaceSize()
    val (startX: Int, startY: Int) = mScrollerStartViewport.run {
        set(mCurrentViewport)
        (surfaceSize.x * (left - AXIS_X_MIN) / (AXIS_X_MAX - AXIS_X_MIN)).toInt() to
                (surfaceSize.y * (AXIS_Y_MAX - bottom) / (AXIS_Y_MAX - AXIS_Y_MIN)).toInt()
    }
    // Before flinging, aborts the current animation.
    mScroller.forceFinished(true)
    // Begins the animation
    mScroller.fling(
            // Current scroll position
            startX,
            startY,
            velocityX,
            velocityY,
            /*
             * Minimum and maximum scroll positions. The minimum scroll
             * position is generally zero and the maximum scroll position
             * is generally the content size less the screen size. So if the
             * content width is 1000 pixels and the screen width is 200
             * pixels, the maximum scroll offset should be 800 pixels.
             */
            0, surfaceSize.x - mContentRect.width(),
            0, surfaceSize.y - mContentRect.height(),
            // The edges of the content. This comes into play when using
            // the EdgeEffect class to draw "glow" overlays.
            mContentRect.width() / 2,
            mContentRect.height() / 2
    )
    // Invalidates to trigger computeScroll()
    ViewCompat.postInvalidateOnAnimation(this)
}

Java

// The current viewport. This rectangle represents the currently visible
// chart domain and range. The viewport is the part of the app that the
// user manipulates via touch gestures.
private RectF mCurrentViewport =
        new RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX);

// The current destination rectangle (in pixel coordinates) into which the
// chart data should be drawn.
private Rect mContentRect;

private OverScroller mScroller;
private RectF mScrollerStartViewport;
...
private final GestureDetector.SimpleOnGestureListener mGestureListener
        = new GestureDetector.SimpleOnGestureListener() {
    @Override
    public boolean onDown(MotionEvent e) {
        // Initiates the decay phase of any active edge effects.
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
            releaseEdgeEffects();
        }
        mScrollerStartViewport.set(mCurrentViewport);
        // Aborts any active scroll animations and invalidates.
        mScroller.forceFinished(true);
        ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);
        return true;
    }
    ...
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2,
            float velocityX, float velocityY) {
        fling((int) -velocityX, (int) -velocityY);
        return true;
    }
};

private void fling(int velocityX, int velocityY) {
    // Initiates the decay phase of any active edge effects.
    // On Android 12 and higher, the edge effect (stretch) should
    // continue.
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
            releaseEdgeEffects();
    }
    // Flings use math in pixels (as opposed to math based on the viewport).
    Point surfaceSize = computeScrollSurfaceSize();
    mScrollerStartViewport.set(mCurrentViewport);
    int startX = (int) (surfaceSize.x * (mScrollerStartViewport.left -
            AXIS_X_MIN) / (
            AXIS_X_MAX - AXIS_X_MIN));
    int startY = (int) (surfaceSize.y * (AXIS_Y_MAX -
            mScrollerStartViewport.bottom) / (
            AXIS_Y_MAX - AXIS_Y_MIN));
    // Before flinging, aborts the current animation.
    mScroller.forceFinished(true);
    // Begins the animation
    mScroller.fling(
            // Current scroll position
            startX,
            startY,
            velocityX,
            velocityY,
            /*
             * Minimum and maximum scroll positions. The minimum scroll
             * position is generally zero and the maximum scroll position
             * is generally the content size less the screen size. So if the
             * content width is 1000 pixels and the screen width is 200
             * pixels, the maximum scroll offset should be 800 pixels.
             */
            0, surfaceSize.x - mContentRect.width(),
            0, surfaceSize.y - mContentRect.height(),
            // The edges of the content. This comes into play when using
            // the EdgeEffect class to draw "glow" overlays.
            mContentRect.width() / 2,
            mContentRect.height() / 2);
    // Invalidates to trigger computeScroll()
    ViewCompat.postInvalidateOnAnimation(this);
}

When onFling() calls postInvalidateOnAnimation(), it triggers computeScroll() to update the values for x and y. This is typically be done when a view child is animating a scroll using a scroller object, as in this example.

Most views pass the scroller object's x and y position directly to scrollTo(). The following implementation of computeScroll() takes a different approach—it calls computeScrollOffset() to get the current location of x and y. When the criteria for displaying an overscroll "glow" edge effect are met (the display is zoomed in, x or y is out of bounds, and the app isn't already showing an overscroll), the code sets up the overscroll glow effect and calls postInvalidateOnAnimation() to trigger an invalidate on the view:

Kotlin

// Edge effect / overscroll tracking objects.
private lateinit var mEdgeEffectTop: EdgeEffect
private lateinit var mEdgeEffectBottom: EdgeEffect
private lateinit var mEdgeEffectLeft: EdgeEffect
private lateinit var mEdgeEffectRight: EdgeEffect

private var mEdgeEffectTopActive: Boolean = false
private var mEdgeEffectBottomActive: Boolean = false
private var mEdgeEffectLeftActive: Boolean = false
private var mEdgeEffectRightActive: Boolean = false

override fun computeScroll() {
    super.computeScroll()

    var needsInvalidate = false

    // The scroller isn't finished, meaning a fling or programmatic pan
    // operation is currently active.
    if (mScroller.computeScrollOffset()) {
        val surfaceSize: Point = computeScrollSurfaceSize()
        val currX: Int = mScroller.currX
        val currY: Int = mScroller.currY

        val (canScrollX: Boolean, canScrollY: Boolean) = mCurrentViewport.run {
            (left > AXIS_X_MIN || right < AXIS_X_MAX) to (top > AXIS_Y_MIN || bottom < AXIS_Y_MAX)
        }

        /*
         * If you are zoomed in and currX or currY is
         * outside of bounds and you are not already
         * showing overscroll, then render the overscroll
         * glow edge effect.
         */
        if (canScrollX
                && currX < 0
                && mEdgeEffectLeft.isFinished
                && !mEdgeEffectLeftActive) {
            mEdgeEffectLeft.onAbsorb(mScroller.currVelocity.toInt())
            mEdgeEffectLeftActive = true
            needsInvalidate = true
        } else if (canScrollX
                && currX > surfaceSize.x - mContentRect.width()
                && mEdgeEffectRight.isFinished
                && !mEdgeEffectRightActive) {
            mEdgeEffectRight.onAbsorb(mScroller.currVelocity.toInt())
            mEdgeEffectRightActive = true
            needsInvalidate = true
        }

        if (canScrollY
                && currY < 0
                && mEdgeEffectTop.isFinished
                && !mEdgeEffectTopActive) {
            mEdgeEffectTop.onAbsorb(mScroller.currVelocity.toInt())
            mEdgeEffectTopActive = true
            needsInvalidate = true
        } else if (canScrollY
                && currY > surfaceSize.y - mContentRect.height()
                && mEdgeEffectBottom.isFinished
                && !mEdgeEffectBottomActive) {
            mEdgeEffectBottom.onAbsorb(mScroller.currVelocity.toInt())
            mEdgeEffectBottomActive = true
            needsInvalidate = true
        }
        ...
    }
}

Java

// Edge effect / overscroll tracking objects.
private EdgeEffectCompat mEdgeEffectTop;
private EdgeEffectCompat mEdgeEffectBottom;
private EdgeEffectCompat mEdgeEffectLeft;
private EdgeEffectCompat mEdgeEffectRight;

private boolean mEdgeEffectTopActive;
private boolean mEdgeEffectBottomActive;
private boolean mEdgeEffectLeftActive;
private boolean mEdgeEffectRightActive;

@Override
public void computeScroll() {
    super.computeScroll();

    boolean needsInvalidate = false;

    // The scroller isn't finished, meaning a fling or programmatic pan
    // operation is currently active.
    if (mScroller.computeScrollOffset()) {
        Point surfaceSize = computeScrollSurfaceSize();
        int currX = mScroller.getCurrX();
        int currY = mScroller.getCurrY();

        boolean canScrollX = (mCurrentViewport.left > AXIS_X_MIN
                || mCurrentViewport.right < AXIS_X_MAX);
        boolean canScrollY = (mCurrentViewport.top > AXIS_Y_MIN
                || mCurrentViewport.bottom < AXIS_Y_MAX);

        /*
         * If you are zoomed in and currX or currY is
         * outside of bounds and you are not already
         * showing overscroll, then render the overscroll
         * glow edge effect.
         */
        if (canScrollX
                && currX < 0
                && mEdgeEffectLeft.isFinished()
                && !mEdgeEffectLeftActive) {
            mEdgeEffectLeft.onAbsorb((int)mScroller.getCurrVelocity());
            mEdgeEffectLeftActive = true;
            needsInvalidate = true;
        } else if (canScrollX
                && currX > (surfaceSize.x - mContentRect.width())
                && mEdgeEffectRight.isFinished()
                && !mEdgeEffectRightActive) {
            mEdgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity());
            mEdgeEffectRightActive = true;
            needsInvalidate = true;
        }

        if (canScrollY
                && currY < 0
                && mEdgeEffectTop.isFinished()
                && !mEdgeEffectTopActive) {
            mEdgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity());
            mEdgeEffectTopActive = true;
            needsInvalidate = true;
        } else if (canScrollY
                && currY > (surfaceSize.y - mContentRect.height())
                && mEdgeEffectBottom.isFinished()
                && !mEdgeEffectBottomActive) {
            mEdgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity());
            mEdgeEffectBottomActive = true;
            needsInvalidate = true;
        }
        ...
    }

Here is the section of the code that performs the actual zoom:

Kotlin

lateinit var mZoomer: Zoomer
val mZoomFocalPoint = PointF()
...

// If a zoom is in progress (either programmatically or via double
// touch), performs the zoom.
if (mZoomer.computeZoom()) {
    val newWidth: Float = (1f - mZoomer.currZoom) * mScrollerStartViewport.width()
    val newHeight: Float = (1f - mZoomer.currZoom) * mScrollerStartViewport.height()
    val pointWithinViewportX: Float =
            (mZoomFocalPoint.x - mScrollerStartViewport.left) / mScrollerStartViewport.width()
    val pointWithinViewportY: Float =
            (mZoomFocalPoint.y - mScrollerStartViewport.top) / mScrollerStartViewport.height()
    mCurrentViewport.set(
            mZoomFocalPoint.x - newWidth * pointWithinViewportX,
            mZoomFocalPoint.y - newHeight * pointWithinViewportY,
            mZoomFocalPoint.x + newWidth * (1 - pointWithinViewportX),
            mZoomFocalPoint.y + newHeight * (1 - pointWithinViewportY)
    )
    constrainViewport()
    needsInvalidate = true
}
if (needsInvalidate) {
    ViewCompat.postInvalidateOnAnimation(this)
}

Java

// Custom object that is functionally similar to Scroller
Zoomer mZoomer;
private PointF mZoomFocalPoint = new PointF();
...

// If a zoom is in progress (either programmatically or via double
// touch), performs the zoom.
if (mZoomer.computeZoom()) {
    float newWidth = (1f - mZoomer.getCurrZoom()) *
            mScrollerStartViewport.width();
    float newHeight = (1f - mZoomer.getCurrZoom()) *
            mScrollerStartViewport.height();
    float pointWithinViewportX = (mZoomFocalPoint.x -
            mScrollerStartViewport.left)
            / mScrollerStartViewport.width();
    float pointWithinViewportY = (mZoomFocalPoint.y -
            mScrollerStartViewport.top)
            / mScrollerStartViewport.height();
    mCurrentViewport.set(
            mZoomFocalPoint.x - newWidth * pointWithinViewportX,
            mZoomFocalPoint.y - newHeight * pointWithinViewportY,
            mZoomFocalPoint.x + newWidth * (1 - pointWithinViewportX),
            mZoomFocalPoint.y + newHeight * (1 - pointWithinViewportY));
    constrainViewport();
    needsInvalidate = true;
}
if (needsInvalidate) {
    ViewCompat.postInvalidateOnAnimation(this);
}

This is the computeScrollSurfaceSize() method that's called in the above snippet. It computes the current scrollable surface size, in pixels. For example, if the entire chart area is visible, this is simply the current size of mContentRect. If the chart is zoomed in 200% in both directions, the returned size will be twice as large horizontally and vertically.

Kotlin

private fun computeScrollSurfaceSize(): Point {
    return Point(
            (mContentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / mCurrentViewport.width()).toInt(),
            (mContentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / mCurrentViewport.height()).toInt()
    )
}

Java

private Point computeScrollSurfaceSize() {
    return new Point(
            (int) (mContentRect.width() * (AXIS_X_MAX - AXIS_X_MIN)
                    / mCurrentViewport.width()),
            (int) (mContentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN)
                    / mCurrentViewport.height()));
}

For another example of scroller usage, see the source code for the ViewPager class. It scrolls in response to flings, and uses scrolling to implement the "snapping to page" animation.

Implement the stretch overscroll effect

Starting in Android 12, EdgeEffect adds the following APIs for implementing the stretch overscroll effect:

  • getDistance()
  • onPullDistance()

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

  1. When the stretch animation is in effect when the user touches the contents, register the touch as a "catch". The user stops the animation and begins manipulating the stretch again.
  2. When the user moves their finger in the opposite direction of the stretch, release the stretch until it's fully gone, and then begin scrolling.
  3. 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.getDistance() returns 0. This condition indicates that the stretch should be manipulated by the touch motion. In most containers, the catch 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 = EdgeEffectCompat.getDistance(edgeEffectBottom) > 0f ||
          EdgeEffectCompat.getDistance(edgeEffectTop) > 0f
      ...
  }
  return isBeingDragged
}

Java

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
  ...
  switch (action & MotionEvent.ACTION_MASK) {
    case MotionEvent.ACTION_DOWN:
      ...
      mIsBeingDragged = EdgeEffectCompat.getDistance(mEdgeEffectBottom) > 0
          || EdgeEffectCompat.getDistance(mEdgeEffectTop) > 0;
      ...
  }
}

In the preceding example, onInterceptTouchEvent() returns true when mIsBeingDragged is true, so it's sufficient to consume 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 prevent the stretch from being applied to the scrolling content. The following code sample applies this best practice:

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 && EdgeEffectCompat.getDistance(edgeEffectTop) > 0f) {
        deltaY -= height * EdgeEffectCompat.onPullDistance(edgeEffectTop,
            pullDistance, displacement);
      }
      if (deltaY > 0f && EdgeEffectCompat.getDistance(edgeEffectBottom) > 0f) {
        deltaY += height * EdgeEffectCompat.onPullDistance(edgeEffectBottom,
            -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 && EdgeEffectCompat.getDistance(mEdgeEffectTop) > 0) {
        deltaY -= getHeight() * EdgeEffectCompat.onPullDistance(mEdgeEffectTop,
            pullDistance, displacement);
      }
      if (deltaY > 0 && EdgeEffectCompat.getDistance(mEdgeEffectBottom) > 0) {
        deltaY += getHeight() * EdgeEffectCompat.onPullDistance(mEdgeEffectBottom,
            -pullDistance, 1 - displacement);
      }
            ...

When the user is dragging, you must consume the EdgeEffect pull distance before you pass the touch event to a nested scrolling container or drag the scroll. 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.

Unlike onPull(), onPullDistance() returns the consumed amount of the passed delta. Starting in Android 12, if onPull() or onPullDistance() are passed negative deltaDistance values when getDistance() is 0, the stretch effect doesn't change. On Android 11 and lower, onPull() allows negative values for the total distance to show glow effects.

Opt out of overscroll

You can opt out of overscroll in your layout file or programmatically, as shown in the following sections:

Opt out in your layout file

The following snippet shows the android:overScrollMode set in the layout file:

<MyCustomView android:overScrollMode="never">
    ...
</MyCustomView>

Opt out programmatically

The following code snippet shows how to opt out programmatically:

Kotlin

customView.overScrollMode = View.OVER_SCROLL_NEVER

Java

customView.setOverScrollMode(View.OVER_SCROLL_NEVER);