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()
inGestureDetector.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()
inGestureDetector.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:
- Create a custom, touch-based scrolling implementation.
- 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:
- 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.
- 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.
- 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);