Google berkomitmen untuk mendorong terwujudnya keadilan rasial bagi komunitas Kulit Hitam. Lihat caranya.

Menganimasikan gestur scroll

Di Android, proses scroll biasanya dicapai menggunakan class ScrollView. Setiap tata letak standar yang mungkin melampaui batas penampungnya harus disarangkan di ScrollView untuk memberikan tampilan scroll yang dikelola oleh framework. Penerapan scroller kustom hanya diperlukan untuk skenario khusus. Tutorial ini menjelaskan skenario serupa: menampilkan efek scroll sebagai respons terhadap gestur sentuhan menggunakan scroller.

Anda dapat menggunakan scroller (Scroller atau OverScroller) untuk mengumpulkan data yang dibutuhkan dalam menghasilkan animasi scrolling sebagai respons terhadap peristiwa sentuh. Kedua scroller ini serupa, tetapi OverScroller mencakup metode untuk menunjukkan kepada pengguna bahwa scroller telah mencapai tepi konten setelah gestur geser atau lempar. Contoh InteractiveChart menggunakan class EdgeEffect (sebenarnya class EdgeEffectCompat) untuk menampilkan efek "glow" ketika pengguna mencapai tepi konten.

Catatan: Sebaiknya Anda menggunakan OverScroller daripada Scroller untuk animasi scroll. OverScroller memberikan kompatibilitas mundur terbaik dengan perangkat lama.
Perhatikan juga bahwa umumnya Anda hanya perlu menggunakan scroller ketika menerapkan scrolling sendiri. ScrollView dan HorizontalScrollView melakukan semua ini jika tata letak disarangkan di dalamnya.

Scroller digunakan untuk menganimasikan scrolling dari waktu ke waktu, yang menggunakan prinsip fisika scrolling standar platform (gesekan, kecepatan, dll.). Scroller itu sendiri sebenarnya tidak menggambar apa pun. Scroller melacak offset scroll untuk Anda dari waktu ke waktu, tetapi tidak otomatis menerapkan posisi tersebut pada tampilan Anda. Anda bertanggung jawab untuk mendapatkan dan menerapkan koordinat baru pada kecepatan yang akan membuat animasi scrolling terlihat halus.

Lihat referensi terkait berikut:

Memahami terminologi scrolling

"Scrolling" adalah kata yang dapat memiliki arti berbeda di Android, bergantung pada konteksnya.

Scrolling adalah proses umum untuk menggerakkan area pandang (yaitu, 'jendela' konten yang Anda lihat). Scrolling dalam sumbu x dan y disebut panning. Aplikasi contoh yang disediakan dalam class ini, InteractiveChart, menggambarkan dua jenis scrolling, yaitu seret (dragging) dan lempar (flinging):

  • Menyeret (dragging) adalah jenis scrolling yang terjadi ketika pengguna menyeret jarinya di layar sentuh. Dragging sederhana sering diimplementasikan dengan mengganti onScroll() di GestureDetector.OnGestureListener. Untuk diskusi lebih lanjut tentang menyeret (dragging), lihat Menyeret dan Menskalakan.
  • Melempar (flinging) adalah jenis scrolling yang terjadi ketika pengguna menyeret dan mengangkat jarinya dengan cepat. Setelah pengguna mengangkat jarinya, Anda biasanya perlu terus melakukan scrolling (menggerakkan area pandang), tetapi melambat hingga area pandang berhenti bergerak. Flinging dapat diimplementasikan dengan mengganti onFling() di GestureDetector.OnGestureListener, dan menggunakan objek scroller. Ini adalah kasus penggunaan yang menjadi topik tutorial ini.

Penggunaan objek scroller bersamaan dengan gestur lempar adalah hal umum, tetapi objek scroller dapat digunakan pada hampir semua konteks, di mana Anda ingin UI menampilkan scrolling sebagai respons terhadap peristiwa sentuh. Misalnya, Anda dapat mengganti onTouchEvent() untuk memproses peristiwa sentuh secara langsung, dan menghasilkan efek scrolling atau animasi "mengepaskan ke halaman" sebagai respons terhadap peristiwa sentuh tersebut.

Menerapkan scrolling berbasis sentuhan

Bagian ini menjelaskan cara menggunakan scroller. Cuplikan yang ditunjukkan di bawah ini berasal dari contoh InteractiveChart yang tersedia di class ini. Cuplikan ini menggunakan GestureDetector, dan mengganti metode GestureDetector.SimpleOnGestureListener di onFling(). OverScroller digunakan untuk melacak gestur lempar. Jika pengguna mencapai tepi konten setelah gestur lempar, aplikasi akan menampilkan efek "glow".

Catatan: Aplikasi contoh InteractiveChart akan menampilkan diagram yang dapat Anda perbesar (zoom), geser (pan), scroll, dan sebagainya. Dalam cuplikan berikut, mContentRect berfungsi sebagai koordinat persegi panjang pada tampilan diagram yang akan digambar. Pada waktu tertentu, subset dari total domain dan rentang diagram akan ditarik ke dalam area persegi panjang ini. mCurrentViewport adalah bagian dari diagram yang saat ini terlihat di layar. Karena offset piksel biasanya diperlakukan sebagai bilangan bulat, mContentRect adalah jenis Rect. Karena rentang dan domain grafis adalah nilai desimal/float, mCurrentViewport adalah jenis RectF.

Bagian pertama cuplikan menunjukkan implementasi 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.
            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.
        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.
            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.
        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);
    }
    

Saat onFling() memanggil postInvalidateOnAnimation(), computeScroll() akan terpicu untuk mengupdate nilai x dan y. Hal ini biasanya dilakukan jika turunan tampilan menganimasikan scroll dengan objek scroller, seperti dalam contoh ini.

Sebagian besar tampilan meneruskan posisi x dan y objek scroller langsung ke scrollTo(). Implementasi computeScroll() berikut menunjukkan pendekatan yang berbeda. computeScrollOffset() dipanggil untuk mendapatkan lokasi x dan y saat ini. Jika kriteria menampilkan efek tepi "glow" overscroll terpenuhi (tampilan diperbesar, x atau y di luar batas, dan aplikasi belum menunjukkan overscroll), kode akan menyiapkan efek glow overscroll dan memanggil postInvalidateOnAnimation() untuk memicu invalidate pada tampilan:

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

Berikut adalah bagian dari kode yang melakukan zoom aktual:

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

Ini adalah metode computeScrollSurfaceSize() yang dipanggil dalam cuplikan di atas. Metode ini menghitung ukuran permukaan yang dapat discroll saat ini, dalam piksel. Misalnya, jika seluruh area diagram terlihat, ini hanyalah ukuran mContentRectsaat ini. Jika diagram diperbesar 200% di kedua arah, ukuran yang dikembalikan akan menjadi dua kali lebih besar secara horizontal dan vertikal.

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

Untuk contoh lain tentang penggunaan scroller, lihat kode sumber untuk class ViewPager. Scroller melakukan scrolling sebagai respons terhadap lemparan (fling), dan menggunakan scrolling untuk menerapkan animasi "mengepaskan ke halaman".