Tạo ảnh động cho một cử chỉ cuộn

Thử dùng Compose
Jetpack Compose là bộ công cụ giao diện người dùng được đề xuất cho Android. Tìm hiểu cách sử dụng thao tác chạm và nhập trong Compose.

Trong Android, thao tác cuộn thường được thực hiện bằng cách sử dụng lớp ScrollView. Lồng bất kỳ bố cục tiêu chuẩn nào có thể mở rộng ra ngoài ranh giới của vùng chứa trong ScrollView để cung cấp một khung hiển thị có thể cuộn do khung quản lý. Bạn chỉ cần triển khai một trình cuộn tuỳ chỉnh cho các trường hợp đặc biệt. Tài liệu này mô tả cách hiển thị hiệu ứng cuộn để phản hồi các cử chỉ chạm bằng cách sử dụng scroller.

Ứng dụng của bạn có thể sử dụng các trình cuộn (Scroller hoặc OverScroller) để thu thập dữ liệu cần thiết nhằm tạo một ảnh động cuộn để phản hồi sự kiện chạm. Hai lớp này tương tự nhau, nhưng OverScroller cũng bao gồm các phương thức cho biết cho người dùng khi họ chạm đến các cạnh nội dung sau khi thực hiện cử chỉ di chuyển hoặc hất.

  • Kể từ Android 12 (API cấp 31), các phần tử trực quan sẽ kéo giãn và bật lại khi có sự kiện kéo, đồng thời hất và bật lại khi có sự kiện hất.
  • Trên Android 11 (API cấp 30) trở xuống, các ranh giới sẽ hiển thị hiệu ứng "phát sáng" sau khi thực hiện cử chỉ kéo hoặc hất đến cạnh.

Mẫu InteractiveChart trong tài liệu này sử dụng lớp EdgeEffect để hiển thị các hiệu ứng cuộn quá mức này.

Bạn có thể dùng một thành phần cuộn để tạo hiệu ứng cho hoạt động cuộn theo thời gian, bằng cách sử dụng các đặc tính cuộn tiêu chuẩn của nền tảng, chẳng hạn như lực ma sát, vận tốc và các đặc tính khác. Bản thân trình cuộn không vẽ bất cứ nội dung nào. Trình cuộn theo dõi độ lệch cuộn cho bạn theo thời gian, nhưng chúng không tự động áp dụng các vị trí đó cho khung hiển thị của bạn. Bạn phải lấy và áp dụng toạ độ mới với tốc độ giúp ảnh động cuộn trông mượt mà.

Tìm hiểu thuật ngữ về thao tác cuộn

Thao tác cuộn là một từ có thể mang nhiều nghĩa trong Android, tuỳ thuộc vào ngữ cảnh.

Thao tác cuộn là quy trình chung để di chuyển khung hiển thị, tức là "cửa sổ" nội dung mà bạn đang xem. Khi thao tác cuộn diễn ra ở cả trục xy, thao tác này được gọi là di chuyển. Ứng dụng mẫu InteractiveChart trong tài liệu này minh hoạ 2 loại thao tác di chuyển, kéo và hất khác nhau:

  • Kéo: đây là loại thao tác di chuyển xảy ra khi người dùng kéo ngón tay trên màn hình cảm ứng. Bạn có thể triển khai thao tác kéo bằng cách ghi đè onScroll() trong GestureDetector.OnGestureListener. Để biết thêm thông tin về thao tác kéo, hãy xem phần Kéo và thu phóng.
  • Hành động hất: đây là loại thao tác di chuyển xảy ra khi người dùng kéo và nhấc ngón tay lên nhanh chóng. Sau khi người dùng nhấc ngón tay lên, bạn thường muốn tiếp tục di chuyển khung hiển thị, nhưng giảm tốc độ cho đến khi khung hiển thị dừng di chuyển. Bạn có thể triển khai thao tác hất bằng cách ghi đè onFling() trong GestureDetector.OnGestureListener và sử dụng một đối tượng trình cuộn.
  • Di chuyển: thao tác di chuyển đồng thời dọc theo cả trục xy được gọi là thao tác di chuyển.

Bạn thường dùng các đối tượng trình cuộn cùng với cử chỉ hất, nhưng bạn có thể dùng chúng trong mọi ngữ cảnh mà bạn muốn giao diện người dùng hiển thị thao tác cuộn để phản hồi một sự kiện chạm. Ví dụ: bạn có thể ghi đè onTouchEvent() để xử lý trực tiếp các sự kiện chạm và tạo hiệu ứng cuộn hoặc ảnh động "chuyển nhanh đến trang" để phản hồi những sự kiện chạm đó.

Các thành phần có chứa các chế độ triển khai tính năng cuộn tích hợp

Các thành phần Android sau đây có sẵn chức năng hỗ trợ cho hành vi cuộn và cuộn quá mức:

Nếu ứng dụng của bạn cần hỗ trợ thao tác cuộn và cuộn quá mức trong một thành phần khác, hãy hoàn tất các bước sau:

  1. Tạo một chế độ triển khai tuỳ chỉnh dựa trên thao tác chạm để di chuyển.
  2. Để hỗ trợ các thiết bị chạy Android 12 trở lên, hãy triển khai hiệu ứng kéo giãn khi cuộn quá mức.

Tạo một chế độ triển khai tuỳ chỉnh dựa trên thao tác chạm để cuộn

Phần này mô tả cách tạo trình cuộn của riêng bạn nếu ứng dụng dùng một thành phần không hỗ trợ sẵn tính năng cuộn và cuộn quá mức.

Đoạn mã sau đây lấy từ InteractiveChart mẫu. Phương thức này sử dụng một GestureDetector và ghi đè phương thức GestureDetector.SimpleOnGestureListener onFling(). Thao tác này dùng OverScroller để theo dõi cử chỉ hất. Nếu người dùng chạm đến các cạnh của nội dung sau khi thực hiện cử chỉ hất, vùng chứa sẽ cho biết thời điểm người dùng chạm đến cuối nội dung. Thông tin cho biết này tuỳ thuộc vào phiên bản Android mà thiết bị chạy:

  • Trên Android 12 trở lên, các phần tử trực quan sẽ kéo giãn và bật trở lại.
  • Trên Android 11 trở xuống, các phần tử trực quan sẽ hiển thị hiệu ứng phát sáng.

Phần đầu tiên của đoạn mã sau đây cho thấy cách triển khai 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);
}

Khi onFling() gọi postInvalidateOnAnimation(), thao tác này sẽ kích hoạt computeScroll() để cập nhật các giá trị cho xy. Thao tác này thường được thực hiện khi một thành phần hiển thị con đang tạo ảnh động cho thao tác cuộn bằng cách sử dụng một đối tượng trình cuộn, như minh hoạ trong ví dụ trước.

Hầu hết các khung hiển thị đều truyền trực tiếp vị trí xy của đối tượng trình cuộn đến scrollTo(). Việc triển khai computeScroll() sau đây sẽ áp dụng một phương pháp khác: phương pháp này gọi computeScrollOffset() để lấy vị trí hiện tại của xy. Khi đáp ứng các tiêu chí để hiển thị hiệu ứng cạnh "phát sáng" khi cuộn quá mức (tức là màn hình được phóng to, x hoặc y nằm ngoài phạm vi và ứng dụng chưa hiển thị hiệu ứng cuộn quá mức), mã sẽ thiết lập hiệu ứng phát sáng khi cuộn quá mức và gọi postInvalidateOnAnimation() để kích hoạt một thao tác làm mới trên khung hiển thị.

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;
        }
        ...
    }

Dưới đây là phần mã thực hiện thao tác thu phóng thực tế:

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);
}

Đây là phương thức computeScrollSurfaceSize() được gọi trong đoạn mã trước đó. Phương thức này tính toán kích thước bề mặt có thể cuộn hiện tại theo pixel. Ví dụ: nếu toàn bộ vùng biểu đồ đều hiển thị, thì đây là kích thước hiện tại của mContentRect. Nếu biểu đồ được phóng to 200% theo cả hai hướng, kích thước trả về sẽ lớn gấp đôi theo chiều ngang và chiều dọc.

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()));
}

Để xem một ví dụ khác về cách sử dụng trình cuộn, hãy xem mã nguồn cho lớp ViewPager. Thành phần này cuộn để phản hồi các thao tác vuốt nhanh và sử dụng thao tác cuộn để triển khai ảnh động "snap-to-page".

Triển khai hiệu ứng kéo giãn khi cuộn quá mức

Bắt đầu từ Android 12, EdgeEffect sẽ thêm các API sau để triển khai hiệu ứng kéo giãn khi cuộn quá mức:

  • getDistance()
  • onPullDistance()

Để mang lại trải nghiệm tốt nhất cho người dùng khi sử dụng tính năng kéo giãn khi cuộn quá mức, hãy làm như sau:

  1. Khi ảnh động kéo giãn có hiệu lực khi người dùng chạm vào nội dung, hãy đăng ký thao tác chạm dưới dạng "bắt". Người dùng dừng ảnh động và bắt đầu thao tác kéo giãn lại.
  2. Khi người dùng di chuyển ngón tay theo hướng ngược lại với hướng kéo, hãy thả thao tác kéo cho đến khi thao tác này biến mất hoàn toàn, rồi bắt đầu cuộn.
  3. Khi người dùng hất trong lúc kéo giãn, hãy hất EdgeEffect để tăng hiệu ứng kéo giãn.

Bắt ảnh động

Khi người dùng bắt được một ảnh động kéo giãn đang hoạt động, EdgeEffect.getDistance() sẽ trả về 0. Điều kiện này cho biết rằng bạn phải thao tác với thao tác kéo bằng cử chỉ chạm. Trong hầu hết các vùng chứa, lỗi sẽ được phát hiện trong onInterceptTouchEvent(), như minh hoạ trong đoạn mã sau:

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;
      ...
  }
}

Trong ví dụ trước, onInterceptTouchEvent() trả về true khi mIsBeingDraggedtrue, vì vậy, bạn chỉ cần sử dụng sự kiện trước khi thành phần con có cơ hội sử dụng sự kiện đó.

Phát hành hiệu ứng cuộn quá mức

Bạn cần giải phóng hiệu ứng kéo giãn trước khi cuộn để ngăn hiệu ứng kéo giãn được áp dụng cho nội dung cuộn. Mẫu mã sau đây áp dụng phương pháp hay nhất này:

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);
      }
            ...

Khi người dùng đang kéo, hãy sử dụng khoảng cách kéo EdgeEffect trước khi bạn truyền sự kiện chạm đến một vùng chứa cuộn lồng nhau hoặc kéo thao tác cuộn. Trong mẫu mã nêu trên, getDistance() trả về một giá trị dương khi hiệu ứng cạnh đang hiển thị và có thể được giải phóng bằng chuyển động. Khi sự kiện chạm giải phóng thao tác kéo, sự kiện này sẽ được EdgeEffect sử dụng trước tiên để được giải phóng hoàn toàn trước khi các hiệu ứng khác (chẳng hạn như thao tác cuộn lồng nhau) xuất hiện. Bạn có thể sử dụng getDistance() để biết cần kéo bao xa để giải phóng hiệu ứng hiện tại.

Không giống như onPull(), onPullDistance() trả về lượng delta đã dùng của delta được truyền. Kể từ Android 12, nếu onPull() hoặc onPullDistance() được truyền các giá trị âm deltaDistance khi getDistance()0, thì hiệu ứng kéo giãn sẽ không thay đổi. Trên Android 11 trở xuống, onPull() cho phép các giá trị âm cho tổng khoảng cách hiển thị hiệu ứng phát sáng.

Chọn không sử dụng tính năng cuộn xuống cuối cùng

Bạn có thể chọn không sử dụng tính năng cuộn quá mức trong tệp bố cục hoặc theo phương thức lập trình.

Để chọn không sử dụng trong tệp bố cục, hãy đặt android:overScrollMode như trong ví dụ sau:

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

Để chọn không tham gia theo phương thức lập trình, hãy sử dụng mã như sau:

Kotlin

customView.overScrollMode = View.OVER_SCROLL_NEVER

Java

customView.setOverScrollMode(View.OVER_SCROLL_NEVER);

Tài nguyên khác

Hãy tham khảo các tài nguyên liên quan sau: