在 Android 中,捲動功能通常是透過 ScrollView
類別實現。將任何可能超出容器邊界的標準版面配置嵌套在 ScrollView
中,以提供由架構管理的捲動檢視畫面。只有在特殊情況下,才需要實作自訂捲軸。本文說明如何使用捲軸,在觸控手勢發生時顯示捲動效果。
應用程式可以使用捲動器 (Scroller
或 OverScroller
),收集產生捲動動畫所需的資料,以回應觸控事件。兩者功能相似,但 OverScroller
也包含方法,可在使用者在平移或甩動手勢後,向使用者指出何時會到達內容邊緣。
- 從 Android 12 (API 級別 31) 開始,視覺元素會在拖曳事件中拉長並彈回,在彈轉事件中則會彈轉並彈回。
- 在 Android 11 (API 級別 30) 以下版本中,邊界在使用拖曳或彈指手勢移至邊緣後,會顯示「發光」效果。
本文件中的 InteractiveChart
範例會使用 EdgeEffect
類別顯示這些過度捲動效果。
您可以使用捲軸,以平台標準捲動物理效果 (例如摩擦力、速度和其他品質) 製作捲動動畫。捲軸本身不會繪製任何內容。捲動器會隨著時間追蹤捲動偏移,但不會自動將這些位置套用至檢視畫面。您必須以讓捲動動畫看起來流暢的速度,取得及套用新的座標。
瞭解捲動術語
捲動這個字詞在 Android 中的涵義可能因情境而異。
捲動是移動可視區域的一般程序,也就是您正在查看的內容「視窗」。如果捲動位於 x 和 y 軸,則稱為「panning」。本文中的 InteractiveChart
範例應用程式說明瞭兩種不同的捲動、拖曳和彈跳動作:
- 拖曳:使用者將手指放在觸控螢幕上時,會發生捲動的類型。您可以覆寫
GestureDetector.OnGestureListener
中的onScroll()
,實作拖曳功能。如要進一步瞭解拖曳功能,請參閱「拖曳及縮放」。 - Flinging:這是使用者快速拖曳並放開手指時發生的捲動類型。使用者放開手指後,您通常會希望繼續移動檢視區,但會減速,直到檢視區停止移動為止。您可以透過在
GestureDetector.OnGestureListener
中覆寫onFling()
並使用捲動器物件,實作快速滑動功能。 - 平移:同時沿著 x 和 y 軸捲動,稱為「平移」。
通常會將捲動器物件與揮動手勢搭配使用,但您也可以在任何需要 UI 顯示捲動畫面以回應觸控事件的情況下使用這些物件。舉例來說,您可以覆寫 onTouchEvent()
來直接處理觸控事件,並產生捲動效果或「貼齊頁面」動畫,以回應這些觸控事件。
包含內建捲動實作的元件
下列 Android 元件內建捲動和超出捲動行為支援功能:
GridView
HorizontalScrollView
ListView
NestedScrollView
RecyclerView
ScrollView
ViewPager
ViewPager2
如果您的應用程式需要在其他元件中支援捲動和超出捲動,請完成下列步驟:
- 建立自訂的觸控式捲動實作項目。
- 如要支援搭載 Android 12 以上版本的裝置,請實作延伸越區捲動效果。
建立自訂的以觸控為主的捲動實作
如果應用程式使用的元件不含內建的捲動和超出捲動功能支援,本節將說明如何建立您自己的捲動器。
以下程式碼片段來自 InteractiveChart
範例。它會使用 GestureDetector
並覆寫 GestureDetector.SimpleOnGestureListener
方法 onFling()
。它會使用 OverScroller
追蹤滑動手勢。如果使用者在執行滑動手勢後到達內容邊緣,容器會指出使用者何時到達內容結尾。這項指示會因裝置執行的 Android 版本而異:
- 在 Android 12 以上版本中,視覺元素會拉長並彈回。
- 在 Android 11 以下版本中,視覺元素會顯示發光效果。
以下程式碼片段的第一部分顯示 onFling()
的實作:
Kotlin
// Viewport extremes. See currentViewport for a discussion of the viewport. private val AXIS_X_MIN = -1f private val AXIS_X_MAX = 1f private val AXIS_Y_MIN = -1f private val AXIS_Y_MAX = 1f // The current viewport. This rectangle represents the visible chart // domain and range. The viewport is the part of the app that the // user manipulates via touch gestures. private val currentViewport = 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 must be drawn. private lateinit var contentRect: Rect private lateinit var scroller: OverScroller private lateinit var scrollerStartViewport: RectF ... private val gestureListener = 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() } scrollerStartViewport.set(currentViewport) // Aborts any active scroll animations and invalidates. scroller.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 later, the edge effect (stretch) must // 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) = scrollerStartViewport.run { set(currentViewport) (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, stops the current animation. scroller.forceFinished(true) // Begins the animation. scroller.fling( // Current scroll position. startX, startY, velocityX, velocityY, /* * Minimum and maximum scroll positions. The minimum scroll * position is generally 0 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 is 800 pixels. */ 0, surfaceSize.x - contentRect.width(), 0, surfaceSize.y - contentRect.height(), // The edges of the content. This comes into play when using // the EdgeEffect class to draw "glow" overlays. contentRect.width() / 2, contentRect.height() / 2 ) // Invalidates to trigger computeScroll(). ViewCompat.postInvalidateOnAnimation(this) }
Java
// Viewport extremes. See currentViewport for a discussion of the viewport. private static final float AXIS_X_MIN = -1f; private static final float AXIS_X_MAX = 1f; private static final float AXIS_Y_MIN = -1f; private static final float AXIS_Y_MAX = 1f; // The current viewport. This rectangle represents the visible chart // domain and range. The viewport is the part of the app that the // user manipulates via touch gestures. private RectF currentViewport = 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 must be drawn. private final Rect contentRect = new Rect(); private final OverScroller scroller; private final RectF scrollerStartViewport = new RectF(); // Used only for zooms and flings. ... private final GestureDetector.SimpleOnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() { @Override public boolean onDown(MotionEvent e) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects(); } scrollerStartViewport.set(currentViewport); scroller.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 later, the edge effect (stretch) must // 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(); scrollerStartViewport.set(currentViewport); int startX = (int) (surfaceSize.x * (scrollerStartViewport.left - AXIS_X_MIN) / ( AXIS_X_MAX - AXIS_X_MIN)); int startY = (int) (surfaceSize.y * (AXIS_Y_MAX - scrollerStartViewport.bottom) / ( AXIS_Y_MAX - AXIS_Y_MIN)); // Before flinging, stops the current animation. scroller.forceFinished(true); // Begins the animation. scroller.fling( // Current scroll position. startX, startY, velocityX, velocityY, /* * Minimum and maximum scroll positions. The minimum scroll * position is generally 0 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 is 800 pixels. */ 0, surfaceSize.x - contentRect.width(), 0, surfaceSize.y - contentRect.height(), // The edges of the content. This comes into play when using // the EdgeEffect class to draw "glow" overlays. contentRect.width() / 2, contentRect.height() / 2); // Invalidates to trigger computeScroll(). ViewCompat.postInvalidateOnAnimation(this); }
當 onFling()
呼叫 postInvalidateOnAnimation()
時,會觸發 computeScroll()
來更新 x 和 y 的值。當檢視畫面子項使用捲動器物件為捲動建立動畫效果時,通常就會這麼做,如以上範例所示。
大多數檢視畫面會將捲動器物件的 x 和 y 位置直接傳遞至 scrollTo()
。以下 computeScroll()
的實作方式採用不同的方法:呼叫 computeScrollOffset()
以取得 x 和 y 的目前位置。當符合顯示超出捲動邊緣效果的「發光」條件 (也就是顯示內容已放大、x 或 y 超出邊界,且應用程式尚未顯示超出捲動) 時,程式碼會設定超出捲動發光效果,並呼叫 postInvalidateOnAnimation()
以觸發檢視畫面無效。
Kotlin
// Edge effect/overscroll tracking objects. private lateinit var edgeEffectTop: EdgeEffect private lateinit var edgeEffectBottom: EdgeEffect private lateinit var edgeEffectLeft: EdgeEffect private lateinit var edgeEffectRight: EdgeEffect private var edgeEffectTopActive: Boolean = false private var edgeEffectBottomActive: Boolean = false private var edgeEffectLeftActive: Boolean = false private var edgeEffectRightActive: Boolean = false override fun computeScroll() { super.computeScroll() var needsInvalidate = false // The scroller isn't finished, meaning a fling or // programmatic pan operation is active. if (scroller.computeScrollOffset()) { val surfaceSize: Point = computeScrollSurfaceSize() val currX: Int = scroller.currX val currY: Int = scroller.currY val (canScrollX: Boolean, canScrollY: Boolean) = currentViewport.run { (left > AXIS_X_MIN || right < AXIS_X_MAX) to (top > AXIS_Y_MIN || bottom < AXIS_Y_MAX) } /* * If you are zoomed in, currX or currY is * outside of bounds, and you aren't already * showing overscroll, then render the overscroll * glow edge effect. */ if (canScrollX && currX < 0 && edgeEffectLeft.isFinished && !edgeEffectLeftActive) { edgeEffectLeft.onAbsorb(scroller.currVelocity.toInt()) edgeEffectLeftActive = true needsInvalidate = true } else if (canScrollX && currX > surfaceSize.x - contentRect.width() && edgeEffectRight.isFinished && !edgeEffectRightActive) { edgeEffectRight.onAbsorb(scroller.currVelocity.toInt()) edgeEffectRightActive = true needsInvalidate = true } if (canScrollY && currY < 0 && edgeEffectTop.isFinished && !edgeEffectTopActive) { edgeEffectTop.onAbsorb(scroller.currVelocity.toInt()) edgeEffectTopActive = true needsInvalidate = true } else if (canScrollY && currY > surfaceSize.y - contentRect.height() && edgeEffectBottom.isFinished && !edgeEffectBottomActive) { edgeEffectBottom.onAbsorb(scroller.currVelocity.toInt()) edgeEffectBottomActive = true needsInvalidate = true } ... } }
Java
// Edge effect/overscroll tracking objects. private EdgeEffectCompat edgeEffectTop; private EdgeEffectCompat edgeEffectBottom; private EdgeEffectCompat edgeEffectLeft; private EdgeEffectCompat edgeEffectRight; private boolean edgeEffectTopActive; private boolean edgeEffectBottomActive; private boolean edgeEffectLeftActive; private boolean edgeEffectRightActive; @Override public void computeScroll() { super.computeScroll(); boolean needsInvalidate = false; // The scroller isn't finished, meaning a fling or // programmatic pan operation is active. if (scroller.computeScrollOffset()) { Point surfaceSize = computeScrollSurfaceSize(); int currX = scroller.getCurrX(); int currY = scroller.getCurrY(); boolean canScrollX = (currentViewport.left > AXIS_X_MIN || currentViewport.right < AXIS_X_MAX); boolean canScrollY = (currentViewport.top > AXIS_Y_MIN || currentViewport.bottom < AXIS_Y_MAX); /* * If you are zoomed in, currX or currY is * outside of bounds, and you aren't already * showing overscroll, then render the overscroll * glow edge effect. */ if (canScrollX && currX < 0 && edgeEffectLeft.isFinished() && !edgeEffectLeftActive) { edgeEffectLeft.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectLeftActive = true; needsInvalidate = true; } else if (canScrollX && currX > (surfaceSize.x - contentRect.width()) && edgeEffectRight.isFinished() && !edgeEffectRightActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectRightActive = true; needsInvalidate = true; } if (canScrollY && currY < 0 && edgeEffectTop.isFinished() && !edgeEffectTopActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectTopActive = true; needsInvalidate = true; } else if (canScrollY && currY > (surfaceSize.y - contentRect.height()) && edgeEffectBottom.isFinished() && !edgeEffectBottomActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectBottomActive = true; needsInvalidate = true; } ... }
以下是實際執行縮放的程式碼部分:
Kotlin
lateinit var zoomer: Zoomer val zoomFocalPoint = PointF() ... // If a zoom is in progress—either programmatically // or through double touch—this performs the zoom. if (zoomer.computeZoom()) { val newWidth: Float = (1f - zoomer.currZoom) * scrollerStartViewport.width() val newHeight: Float = (1f - zoomer.currZoom) * scrollerStartViewport.height() val pointWithinViewportX: Float = (zoomFocalPoint.x - scrollerStartViewport.left) / scrollerStartViewport.width() val pointWithinViewportY: Float = (zoomFocalPoint.y - scrollerStartViewport.top) / scrollerStartViewport.height() currentViewport.set( zoomFocalPoint.x - newWidth * pointWithinViewportX, zoomFocalPoint.y - newHeight * pointWithinViewportY, zoomFocalPoint.x + newWidth * (1 - pointWithinViewportX), zoomFocalPoint.y + newHeight * (1 - pointWithinViewportY) ) constrainViewport() needsInvalidate = true } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this) }
Java
// Custom object that is functionally similar to Scroller. Zoomer zoomer; private PointF zoomFocalPoint = new PointF(); ... // If a zoom is in progress—either programmatically // or through double touch—this performs the zoom. if (zoomer.computeZoom()) { float newWidth = (1f - zoomer.getCurrZoom()) * scrollerStartViewport.width(); float newHeight = (1f - zoomer.getCurrZoom()) * scrollerStartViewport.height(); float pointWithinViewportX = (zoomFocalPoint.x - scrollerStartViewport.left) / scrollerStartViewport.width(); float pointWithinViewportY = (zoomFocalPoint.y - scrollerStartViewport.top) / scrollerStartViewport.height(); currentViewport.set( zoomFocalPoint.x - newWidth * pointWithinViewportX, zoomFocalPoint.y - newHeight * pointWithinViewportY, zoomFocalPoint.x + newWidth * (1 - pointWithinViewportX), zoomFocalPoint.y + newHeight * (1 - pointWithinViewportY)); constrainViewport(); needsInvalidate = true; } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this); }
這是在上述程式碼片段中呼叫的 computeScrollSurfaceSize()
方法。它會以像素為單位計算目前可捲動的表面大小。舉例來說,如果整個圖表區域都顯示在畫面上,則 mContentRect
的大小就是目前的大小。如果圖表在兩個方向都縮放 200%,則傳回的大小會是水平和垂直方向的兩倍。
Kotlin
private fun computeScrollSurfaceSize(): Point { return Point( (contentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / currentViewport.width()).toInt(), (contentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / currentViewport.height()).toInt() ) }
Java
private Point computeScrollSurfaceSize() { return new Point( (int) (contentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / currentViewport.width()), (int) (contentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / currentViewport.height())); }
如需其他捲動器使用方式的範例,請參閱 ViewPager
類別的原始碼。這個類別會因應快速滑過來捲動,並使用捲動功能實作「snap-to-page」動畫。
實作延展過度捲動效果
自 Android 12 起,EdgeEffect
新增了以下 API,可用於實作拉伸捲動邊緣效果:
getDistance()
onPullDistance()
如要提供最佳的延展超出捲動體驗,請執行下列操作:
- 當使用者觸碰內容時,如果拉伸動畫正在播放,請將觸碰事件登錄為「捕捉」。使用者停止動畫,並再次調整拉伸效果。
- 當使用者將手指向伸展方向的反方向移動時,請釋放伸展功能,直到完全消失,然後開始捲動。
- 當使用者在拉伸期間快速滑動時,請快速滑動
EdgeEffect
以強化拉伸效果。
擷取動畫
當使用者擷取到進行中的延展動畫時,EdgeEffect.getDistance()
會傳回 0
。這個狀況表示延展動作必須受到觸控動作操作。在大多數容器中,系統會在 onInterceptTouchEvent()
中偵測到 catch,如以下程式碼片段所示:
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: ... isBeingDragged = EdgeEffectCompat.getDistance(edgeEffectBottom) > 0 || EdgeEffectCompat.getDistance(edgeEffectTop) > 0; ... } }
在上例中,當 mIsBeingDragged
為 true
時,onInterceptTouchEvent()
會傳回 true
,因此在子項有機會使用事件之前,您可以先使用事件。
釋出過度捲動效果
請務必在捲動前釋放延展效果,以免延展效果套用至捲動內容。以下程式碼範例會套用這項最佳做法:
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 - lastMotionY 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 - lastMotionY; float pullDistance = deltaY / getHeight(); float displacement = x / getWidth(); if (deltaY < 0 && EdgeEffectCompat.getDistance(edgeEffectTop) > 0) { deltaY -= getHeight() * EdgeEffectCompat.onPullDistance(edgeEffectTop, pullDistance, displacement); } if (deltaY > 0 && EdgeEffectCompat.getDistance(edgeEffectBottom) > 0) { deltaY += getHeight() * EdgeEffectCompat.onPullDistance(edgeEffectBottom, -pullDistance, 1 - displacement); } ...
當使用者拖曳時,請先使用 EdgeEffect
拉動距離,再將觸控事件傳遞至巢狀捲動容器或拖曳捲動畫面。在上一個程式碼範例中,當邊緣效果顯示時,getDistance()
會傳回正值,並可透過動作釋放。當觸控事件釋放伸展動作時,會先由 EdgeEffect
取用,使它在顯示其他效果之前完全釋放,例如巢狀捲動。您可以使用 getDistance()
瞭解釋放目前效果所需的拉動距離。
與 onPull()
不同的是,onPullDistance()
會傳回已用盡的差異值。從 Android 12 開始,如果 onPull()
或 onPullDistance()
在 getDistance()
為 0
時傳遞負值 deltaDistance
,則不會變更延展效果。在 Android 11 和更早版本中,onPull()
會讓總距離的負值顯示發光效果。
停用過度捲動
您可以在版面配置檔案中或以程式設計方式選擇停用過度捲動功能。
如要選擇不採用版面配置檔案,請依以下範例設定 android:overScrollMode
:
<MyCustomView android:overScrollMode="never"> ... </MyCustomView>
如要透過程式輔助方式停用,請使用以下程式碼:
Kotlin
customView.overScrollMode = View.OVER_SCROLL_NEVER
Java
customView.setOverScrollMode(View.OVER_SCROLL_NEVER);
其他資源
請參閱下列相關資源: