以動畫方式呈現捲動手勢

試試 Compose 的方式
Jetpack Compose 是 Android 推薦的 UI 工具包。瞭解如何在 Compose 中使用觸控和輸入功能。

在 Android 中,捲動功能通常是透過 ScrollView 類別實現。將任何可能超出容器邊界的標準版面配置嵌套在 ScrollView 中,以提供由架構管理的捲動檢視畫面。只有在特殊情況下,才需要實作自訂捲軸。本文說明如何使用捲軸,在觸控手勢發生時顯示捲動效果。

應用程式可以使用捲軸 (ScrollerOverScroller),收集產生捲動動畫所需的資料,以回應觸控事件。兩者功能相似,但 OverScroller 也包含方法,可在使用者以滑動或甩動手勢操作後,向使用者指出何時會到達內容邊緣。

  • 從 Android 12 (API 級別 31) 開始,視覺元素會在拖曳事件中拉長並彈回,在彈轉事件中彈轉並彈回。
  • 在 Android 11 (API 級別 30) 以下版本中,邊界在使用拖曳或彈指手勢移至邊緣後,會顯示「發光」效果。

本文件中的 InteractiveChart 範例會使用 EdgeEffect 類別顯示這些過度捲動效果。

您可以使用捲軸,以平台標準捲動物理效果 (例如摩擦力、速度和其他品質) 製作捲動動畫。捲軸本身不會繪製任何內容。捲動條會在一段時間內追蹤捲動偏移量,但不會自動將這些位置套用至檢視畫面。您必須以讓捲動動畫看起來流暢的速度,取得及套用新的座標。

瞭解捲動術語

捲動這個字詞在 Android 中的涵義可能因情境而異。

捲動是移動檢視區塊的一般程序,也就是您正在查看的內容「視窗」。如果捲動動作同時發生在 xy 軸上,就稱為「平移」。本文件中的 InteractiveChart 範例應用程式說明瞭兩種不同的捲動、拖曳和滑動方式:

  • 拖曳:使用者在觸控螢幕上拖曳手指時,會發生這類捲動動作。您可以覆寫 GestureDetector.OnGestureListener 中的 onScroll(),實作拖曳功能。如要進一步瞭解拖曳功能,請參閱「拖曳及縮放」。
  • Flinging:這是使用者快速拖曳並放開手指時發生的捲動類型。使用者放開手指後,您通常會希望繼續移動檢視區,但會減速,直到檢視區停止移動為止。您可以透過在 GestureDetector.OnGestureListener 中覆寫 onFling() 並使用捲動器物件,實作快速滑動功能。
  • 平移:同時沿著 xy 軸捲動,稱為「平移」

通常會將捲動器物件與揮動手勢搭配使用,但您也可以在任何需要 UI 在觸控事件發生時顯示捲動效果的情況下使用這些物件。舉例來說,您可以覆寫 onTouchEvent() 來直接處理觸控事件,並產生捲動效果或「貼齊頁面」動畫,以回應這些觸控事件。

包含內建捲動實作的元件

下列 Android 元件內建捲動和超出捲動行為的支援功能:

如果您的應用程式需要支援在不同元件中捲動和超出捲動範圍的操作,請完成下列步驟:

  1. 建立自訂的觸控式捲動實作項目
  2. 如要支援搭載 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() 更新 xy 的值。這項作業通常會在檢視畫面子項使用捲動器物件為捲動畫面製作動畫時執行,如前述範例所示。

大多數檢視畫面會直接將捲軸物件的 xy 位置傳遞至 scrollTo()。以下 computeScroll() 的實作方式採用不同的方法:呼叫 computeScrollOffset() 以取得 xy 的目前位置。當符合顯示超出捲動邊緣效果的「發光」條件 (也就是顯示內容已放大、xy 超出邊界,且應用程式尚未顯示超出捲動) 時,程式碼會設定超出捲動發光效果,並呼叫 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 類別的原始碼。它會根據快速滑動動作進行捲動,並使用捲動功能實作「對齊頁面」動畫。

實作延展過度捲動效果

自 Android 12 起,EdgeEffect 新增了以下 API,可用於實作拉伸捲動邊緣效果:

  • getDistance()
  • onPullDistance()

如要提供最佳的延展超出捲動體驗,請執行下列操作:

  1. 當使用者觸碰內容時,如果拉伸動畫正在播放,請將觸碰事件登錄為「捕捉」。使用者停止動畫,並再次調整拉伸效果。
  2. 當使用者將手指向伸展方向的反方向移動時,請釋放伸展功能,直到完全消失,然後開始捲動。
  3. 當使用者在拉伸期間快速滑動時,請快速滑動 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;
      ...
  }
}

在上例中,當 mIsBeingDraggedtrue 時,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);

其他資源

請參閱下列相關資源: