אנימציה של תנועת גלילה

אנסה לכתוב
‫Jetpack Compose היא ערכת הכלים המומלצת לבניית ממשק משתמש ל-Android. איך משתמשים במגע ובקלט ב-Compose

ב-Android, בדרך כלל משתמשים במחלקה ScrollView כדי לגלול. כדי לספק תצוגה עם אפשרות גלילה שמנוהלת על ידי המסגרת, אפשר להוסיף כל פריסת ברירת מחדל שעשויה לחרוג מהגבולות של הקונטיינר שלה בתוך תג ScrollView. הטמעה של רכיב גלילה מותאם אישית נדרשת רק בתרחישים מיוחדים. במאמר הזה מוסבר איך להציג אפקט גלילה בתגובה למחוות מגע באמצעות רכיבי גלילה.

האפליקציה יכולה להשתמש ברכיבי גלילה – Scroller או OverScroller – כדי לאסוף את הנתונים שנדרשים ליצירת אנימציה של גלילה בתגובה לאירוע מגע. הן דומות, אבל OverScroller כוללת גם שיטות לציון המקרים שבהם המשתמשים מגיעים לקצוות התוכן אחרי תנועת הזזה או הטלה.

  • החל מ-Android 12 (רמת API 31), הרכיבים החזותיים נמתחים וחוזרים למקומם באירוע גרירה, ומושלכים וחוזרים למקומם באירוע השלכה.
  • ב-Android 11 (רמת API 30) ובגרסאות קודמות, הגבולות מוצגים עם אפקט של 'זוהר' אחרי גרירה או תנועת החלקה לקצה.

בדוגמה InteractiveChart במסמך הזה נעשה שימוש במחלקה EdgeEffect כדי להציג את אפקטי הגלילה האלה.

אפשר להשתמש ברכיב scroller כדי להנפיש גלילה לאורך זמן, באמצעות פיזיקת גלילה סטנדרטית לפלטפורמה, כמו חיכוך, מהירות ותכונות אחרות. רכיב הגלילה עצמו לא מצייר כלום. רכיבי Scroller עוקבים אחרי היסטים של גלילה לאורך זמן, אבל הם לא מחילים את המיקומים האלה על התצוגה באופן אוטומטי. צריך לקבל ולהחיל קואורדינטות חדשות בקצב שגורם לאנימציית הגלילה להיראות חלקה.

הסבר על המונחים שקשורים לגלילה

גלילה היא מילה שיכולות להיות לה משמעויות שונות ב-Android, בהתאם להקשר.

גלילה היא התהליך הכללי של הזזת אזור התצוגה – כלומר, ה'חלון' של התוכן שאתם מסתכלים עליו. כשגוללים גם בציר x וגם בציר y, הפעולה נקראת הזזה. אפליקציית הדוגמה InteractiveChart במסמך הזה ממחישה שני סוגים שונים של גלילה, גרירה והטלה:

  • גרירה: סוג הגלילה שמתרחש כשמשתמש גורר את האצבע על מסך המגע. כדי להטמיע גרירה, צריך לשנות את ההגדרה של onScroll() ב-GestureDetector.OnGestureListener. מידע נוסף על גרירה מופיע במאמר גרירה ושינוי גודל.
  • גלילה מהירה: סוג הגלילה שמתרחש כשמשתמש גורר את האצבע ומסיר אותה במהירות. אחרי שהמשתמש מרים את האצבע, בדרך כלל רוצים להמשיך להזיז את אזור התצוגה, אבל להאט את התנועה עד שאזור התצוגה מפסיק לזוז. אפשר להטמיע גלילה מהירה על ידי החלפה של onFling() ב-GestureDetector.OnGestureListener ושימוש באובייקט של רכיב לגלילה.
  • הזזה: גלילה בו-זמנית לאורך ציר x וציר y נקראת הזזה.

מקובל להשתמש באובייקטים של גלילה בשילוב עם תנועת החלקה, אבל אפשר להשתמש בהם בכל הקשר שבו רוצים שממשק המשתמש יציג גלילה בתגובה לאירוע מגע. לדוגמה, אפשר לבטל את ההגדרה של onTouchEvent() כדי לעבד אירועי מגע ישירות וליצור אפקט גלילה או אנימציה של 'הצמדה לדף' בתגובה לאירועי המגע האלה.

רכיבים שמכילים הטמעות מובנות של גלילה

הרכיבים הבאים של Android מכילים תמיכה מובנית בהתנהגות גלילה וגלילה מעבר לקצה:

אם האפליקציה שלכם צריכה לתמוך בגלילה ובגלילה מעבר לקצה בתוך רכיב אחר, צריך לבצע את השלבים הבאים:

  1. יצירת הטמעה מותאמת אישית של גלילה מבוססת מגע.
  2. כדי לתמוך במכשירים עם Android 12 ואילך, מטמיעים את אפקט הגלילה העודפת עם מתיחה.

יצירת יישום גלילה מותאם אישית שמבוסס על מגע

בקטע הזה מוסבר איך ליצור רכיב גלילה משלכם אם האפליקציה משתמשת ברכיב שלא כולל תמיכה מובנית בגלילה ובגלילת יתר.

קטע הקוד הבא מגיע מהדוגמה InteractiveChart. היא משתמשת ב-GestureDetector ומבטלת את method GestureDetector.SimpleOnGestureListeneronFling(). הוא משתמש ב-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. אם התרשים מוגדל פי 2 בשני הכיוונים, הגודל שמוחזר גדול פי 2 אופקית ואנכית.

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

דוגמה נוספת לשימוש ברכיב scroller מופיעה בקוד המקור של המחלקה ViewPager. הוא גולל בתגובה לתנועות מהירות של האצבע ומשתמש בגלילה כדי להטמיע את האנימציה של 'הצמדה לדף'.

הטמעה של אפקט המתיחה של הגלילה

החל מ-Android 12, ‏ EdgeEffect נוספו ממשקי ה-API הבאים להטמעה של אפקט הגלילה העודפת:

  • getDistance()
  • onPullDistance()

כדי לספק את חוויית המשתמש הטובה ביותר עם גלילה עודפת, צריך לבצע את הפעולות הבאות:

  1. אם אנימציית המתיחה פועלת כשהמשתמש נוגע בתוכן, צריך לרשום את המגע כ'תפיסה'. המשתמש עוצר את האנימציה ומתחיל לשנות את המתיחה שוב.
  2. כשהמשתמש מזיז את האצבע בכיוון ההפוך למתיחה, צריך לשחרר את המתיחה עד שהיא נעלמת לגמרי, ואז להתחיל לגלול.
  3. כשהמשתמש מבצע תנועת החלקה במהלך מתיחה, מחליקים את EdgeEffect כדי לשפר את אפקט המתיחה.

תפיסת האנימציה

כשמשתמש צופה באנימציית מתיחה פעילה, הפונקציה מחזירה 0.EdgeEffect.getDistance() התנאי הזה מציין שצריך לשנות את המתיחה באמצעות תנועת המגע. ברוב מאגרי התגים, התג מזוהה ב-onInterceptTouchEvent(), כמו שמוצג בקטע הקוד הבא:

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

בדוגמה הקודמת, הפונקציה onInterceptTouchEvent() מחזירה true כשהערך של mIsBeingDragged הוא 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);

מקורות מידע נוספים

כדאי לעיין במקורות המידע הבאים שקשורים לנושא: