Menarik dan menskalakan

Tutorial ini menjelaskan cara menggunakan gestur sentuh untuk menarik dan menskalakan objek di layar, menggunakan onTouchEvent() untuk mengintersep peristiwa sentuhan.

Lihat referensi terkait berikut:

Menarik objek

Jika Anda menargetkan Android 3.0 atau versi lebih tinggi, Anda dapat menggunakan pemroses peristiwa tarik-lalu-lepas bawaan dengan View.OnDragListener, seperti yang dijelaskan dalam Tarik lalu Lepas.

Operasi umum untuk gestur sentuh adalah menggunakannya untuk menarik objek ke layar. Cuplikan berikut memungkinkan pengguna menarik gambar di layar. Perhatikan hal berikut:

  • Dalam operasi tarik (atau scroll), aplikasi harus melacak pointer asli (jari), meskipun jari lain berada di atas layar. Contohnya, bayangkan ketika menarik gambar, pengguna meletakkan jari kedua di layar sentuh, lalu mengangkat jari pertama. Jika aplikasi Anda hanya melacak satu pointer, aplikasi akan menganggap pointer kedua sebagai defaultnya dan memindahkan gambar ke lokasi tersebut.
  • Untuk mencegah hal ini, aplikasi Anda perlu membedakan antara pointer asli dan pointer berikutnya. Untuk melakukannya, aplikasi akan melacak peristiwa ACTION_POINTER_DOWN dan ACTION_POINTER_UP yang dijelaskan dalam Menangani Gestur Multi-Sentuh. ACTION_POINTER_DOWN dan ACTION_POINTER_UP diteruskan ke callback onTouchEvent() setiap kali pointer sekunder turun atau naik.
  • Dalam kasus ACTION_POINTER_UP, contoh tersebut akan mengekstrak indeks ini dan memastikan bahwa ID pointer aktif tidak merujuk pada pointer yang tidak lagi menyentuh layar. Jika demikian, aplikasi akan memilih pointer lain untuk tetap aktif dan menyimpan posisi X dan Y saat ini. Karena posisi yang disimpan ini digunakan dalam kasus ACTION_MOVE untuk menghitung jarak guna memindahkan objek di layar, aplikasi akan selalu menghitung jarak untuk bergerak menggunakan data dari pointer yang benar.

Cuplikan berikut memungkinkan pengguna untuk menarik objek di layar. Ini akan mencatat posisi awal pointer aktif, menghitung jarak tempuh pointer, dan memindahkan objek ke posisi baru. Cara ini akan mengelola kemungkinan adanya pointer tambahan dengan benar, seperti yang dijelaskan di atas.

Perhatikan bahwa cuplikan tersebut menggunakan metode getActionMasked(). Anda harus selalu menggunakan metode ini (atau lebih baik lagi, versi kompatibilitas MotionEventCompat.getActionMasked()) untuk mengambil tindakan MotionEvent. Berbeda dengan metode getAction() yang lama, getActionMasked() dirancang untuk bekerja dengan beberapa pointer. Metode ini akan mengembalikan tindakan samaran yang dilakukan, tanpa menyertakan bit indeks pointer.

Kotlin

    // The ‘active pointer’ is the one currently moving our object.
    private var mActivePointerId = INVALID_POINTER_ID

    override fun onTouchEvent(ev: MotionEvent): Boolean {
        // Let the ScaleGestureDetector inspect all events.
        mScaleDetector.onTouchEvent(ev)

        val action = MotionEventCompat.getActionMasked(ev)

        when (action) {
            MotionEvent.ACTION_DOWN -> {
                MotionEventCompat.getActionIndex(ev).also { pointerIndex ->
                    // Remember where we started (for dragging)
                    mLastTouchX = MotionEventCompat.getX(ev, pointerIndex)
                    mLastTouchY = MotionEventCompat.getY(ev, pointerIndex)
                }

                // Save the ID of this pointer (for dragging)
                mActivePointerId = MotionEventCompat.getPointerId(ev, 0)
            }

            MotionEvent.ACTION_MOVE -> {
                // Find the index of the active pointer and fetch its position
                val (x: Float, y: Float) =
                        MotionEventCompat.findPointerIndex(ev, mActivePointerId).let { pointerIndex ->
                            // Calculate the distance moved
                            MotionEventCompat.getX(ev, pointerIndex) to
                                    MotionEventCompat.getY(ev, pointerIndex)
                        }

                mPosX += x - mLastTouchX
                mPosY += y - mLastTouchY

                invalidate()

                // Remember this touch position for the next move event
                mLastTouchX = x
                mLastTouchY = y
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                mActivePointerId = INVALID_POINTER_ID
            }
            MotionEvent.ACTION_POINTER_UP -> {

                MotionEventCompat.getActionIndex(ev).also { pointerIndex ->
                    MotionEventCompat.getPointerId(ev, pointerIndex)
                            .takeIf { it == mActivePointerId }
                            ?.run {
                                // This was our active pointer going up. Choose a new
                                // active pointer and adjust accordingly.
                                val newPointerIndex = if (pointerIndex == 0) 1 else 0
                                mLastTouchX = MotionEventCompat.getX(ev, newPointerIndex)
                                mLastTouchY = MotionEventCompat.getY(ev, newPointerIndex)
                                mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex)
                            }
                }
            }
        }
        return true
    }
    

Java

    // The ‘active pointer’ is the one currently moving our object.
    private int mActivePointerId = INVALID_POINTER_ID;

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        // Let the ScaleGestureDetector inspect all events.
        mScaleDetector.onTouchEvent(ev);

        final int action = MotionEventCompat.getActionMasked(ev);

        switch (action) {
        case MotionEvent.ACTION_DOWN: {
            final int pointerIndex = MotionEventCompat.getActionIndex(ev);
            final float x = MotionEventCompat.getX(ev, pointerIndex);
            final float y = MotionEventCompat.getY(ev, pointerIndex);

            // Remember where we started (for dragging)
            mLastTouchX = x;
            mLastTouchY = y;
            // Save the ID of this pointer (for dragging)
            mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
            break;
        }

        case MotionEvent.ACTION_MOVE: {
            // Find the index of the active pointer and fetch its position
            final int pointerIndex =
                    MotionEventCompat.findPointerIndex(ev, mActivePointerId);

            final float x = MotionEventCompat.getX(ev, pointerIndex);
            final float y = MotionEventCompat.getY(ev, pointerIndex);

            // Calculate the distance moved
            final float dx = x - mLastTouchX;
            final float dy = y - mLastTouchY;

            mPosX += dx;
            mPosY += dy;

            invalidate();

            // Remember this touch position for the next move event
            mLastTouchX = x;
            mLastTouchY = y;

            break;
        }

        case MotionEvent.ACTION_UP: {
            mActivePointerId = INVALID_POINTER_ID;
            break;
        }

        case MotionEvent.ACTION_CANCEL: {
            mActivePointerId = INVALID_POINTER_ID;
            break;
        }

        case MotionEvent.ACTION_POINTER_UP: {

            final int pointerIndex = MotionEventCompat.getActionIndex(ev);
            final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);

            if (pointerId == mActivePointerId) {
                // This was our active pointer going up. Choose a new
                // active pointer and adjust accordingly.
                final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
                mLastTouchX = MotionEventCompat.getX(ev, newPointerIndex);
                mLastTouchY = MotionEventCompat.getY(ev, newPointerIndex);
                mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
            }
            break;
        }
        }
        return true;
    }
    

Tarik untuk menggeser

Bagian sebelumnya menunjukkan contoh penarikan objek di layar. Skenario umum lainnya adalah menggeser, yaitu ketika gerakan tarik pengguna menyebabkan scrolling pada sumbu x dan y. Cuplikan di atas secara langsung mengintersep tindakan MotionEvent untuk menerapkan penarikan. Cuplikan di bagian ini memanfaatkan dukungan bawaan platform untuk gestur umum. Ini mengganti onScroll() di GestureDetector.SimpleOnGestureListener.

Guna memberikan konteks lebih, onScroll() akan dipanggil saat pengguna menarik jarinya untuk menggeser konten. onScroll() hanya akan dipanggil saat jari turun; segera setelah jari diangkat dari layar, gestur tersebut akan berakhir, atau gestur lempar dimulai (jika jari tersebut bergerak dengan kecepatan sesaat sebelum diangkat). Untuk pembahasan lebih lanjut tentang scrolling vs melempar (flinging), lihat Animasi Gestur Scroll.

Berikut adalah cuplikan untuk onScroll():

Kotlin

    // The current viewport. This rectangle represents the currently visible
    // chart domain and range.
    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 val mContentRect: Rect? = null

    private val mGestureListener = object : GestureDetector.SimpleOnGestureListener() {
        ...
        override fun onScroll(
                e1: MotionEvent,
                e2: MotionEvent,
                distanceX: Float,
                distanceY: Float
        ): Boolean {
            // Scrolling uses math based on the viewport (as opposed to math using pixels).

            mContentRect?.apply {
                // Pixel offset is the offset in screen pixels, while viewport offset is the
                // offset within the current viewport.
                val viewportOffsetX = distanceX * mCurrentViewport.width() / width()
                val viewportOffsetY = -distanceY * mCurrentViewport.height() / height()

                // Updates the viewport, refreshes the display.
                setViewportBottomLeft(
                        mCurrentViewport.left + viewportOffsetX,
                        mCurrentViewport.bottom + viewportOffsetY
                )
            }

            return true
        }
    }
    

Java

    // The current viewport. This rectangle represents the currently visible
    // chart domain and range.
    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 final GestureDetector.SimpleOnGestureListener mGestureListener
                = new GestureDetector.SimpleOnGestureListener() {
    ...

    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2,
                float distanceX, float distanceY) {
        // Scrolling uses math based on the viewport (as opposed to math using pixels).

        // Pixel offset is the offset in screen pixels, while viewport offset is the
        // offset within the current viewport.
        float viewportOffsetX = distanceX * mCurrentViewport.width()
                / mContentRect.width();
        float viewportOffsetY = -distanceY * mCurrentViewport.height()
                / mContentRect.height();
        ...
        // Updates the viewport, refreshes the display.
        setViewportBottomLeft(
                mCurrentViewport.left + viewportOffsetX,
                mCurrentViewport.bottom + viewportOffsetY);
        ...
        return true;
    }
    

Penerapan onScroll() melakukan scrolling di area pandang sebagai respons terhadap gestur sentuh:

Kotlin

    /**
     * Sets the current viewport (defined by mCurrentViewport) to the given
     * X and Y positions. Note that the Y value represents the topmost pixel position,
     * and thus the bottom of the mCurrentViewport rectangle.
     */
    private fun setViewportBottomLeft(x: Float, y: Float) {
        /*
         * Constrains within the scroll range. The scroll range is simply the viewport
         * extremes (AXIS_X_MAX, etc.) minus the viewport size. For example, if the
         * extremes were 0 and 10, and the viewport size was 2, the scroll range would
         * be 0 to 8.
         */

        val curWidth: Float = mCurrentViewport.width()
        val curHeight: Float = mCurrentViewport.height()
        val newX: Float = Math.max(AXIS_X_MIN, Math.min(x, AXIS_X_MAX - curWidth))
        val newY: Float = Math.max(AXIS_Y_MIN + curHeight, Math.min(y, AXIS_Y_MAX))

        mCurrentViewport.set(newX, newY - curHeight, newX + curWidth, newY)

        // Invalidates the View to update the display.
        ViewCompat.postInvalidateOnAnimation(this)
    }
    

Java

    /**
     * Sets the current viewport (defined by mCurrentViewport) to the given
     * X and Y positions. Note that the Y value represents the topmost pixel position,
     * and thus the bottom of the mCurrentViewport rectangle.
     */
    private void setViewportBottomLeft(float x, float y) {
        /*
         * Constrains within the scroll range. The scroll range is simply the viewport
         * extremes (AXIS_X_MAX, etc.) minus the viewport size. For example, if the
         * extremes were 0 and 10, and the viewport size was 2, the scroll range would
         * be 0 to 8.
         */

        float curWidth = mCurrentViewport.width();
        float curHeight = mCurrentViewport.height();
        x = Math.max(AXIS_X_MIN, Math.min(x, AXIS_X_MAX - curWidth));
        y = Math.max(AXIS_Y_MIN + curHeight, Math.min(y, AXIS_Y_MAX));

        mCurrentViewport.set(x, y - curHeight, x + curWidth, y);

        // Invalidates the View to update the display.
        ViewCompat.postInvalidateOnAnimation(this);
    }
    

Menggunakan sentuhan untuk melakukan penskalaan

Seperti yang dibahas dalam Mendeteksi Gestur Umum, GestureDetector membantu Anda mendeteksi gestur umum yang digunakan oleh Android seperti scrolling, lempar, dan tekan lama. Untuk penskalaan, Android menyediakan ScaleGestureDetector. GestureDetector dan ScaleGestureDetector dapat digunakan bersama saat Anda menginginkan tampilan untuk mengenali gestur tambahan.

Untuk melaporkan peristiwa gestur yang terdeteksi, pendeteksi gestur menggunakan objek pemroses yang diteruskan ke konstruktornya. ScaleGestureDetector menggunakan ScaleGestureDetector.OnScaleGestureListener. Android menyediakan ScaleGestureDetector.SimpleOnScaleGestureListener sebagai class bantuan yang dapat Anda perluas jika semua peristiwa yang dilaporkan diabaikan.

Contoh penskalaan dasar

Berikut adalah cuplikan yang menggambarkan bahan dasar yang tercakup dalam penskalaan.

Kotlin

    private var mScaleFactor = 1f

    private val scaleListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {

        override fun onScale(detector: ScaleGestureDetector): Boolean {
            mScaleFactor *= detector.scaleFactor

            // Don't let the object get too small or too large.
            mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f))

            invalidate()
            return true
        }
    }

    private val mScaleDetector = ScaleGestureDetector(context, scaleListener)

    override fun onTouchEvent(ev: MotionEvent): Boolean {
        // Let the ScaleGestureDetector inspect all events.
        mScaleDetector.onTouchEvent(ev)
        return true
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        canvas?.apply {
            save()
            scale(mScaleFactor, mScaleFactor)
            // onDraw() code goes here
            restore()
        }
    }
    

Java

    private ScaleGestureDetector mScaleDetector;
    private float mScaleFactor = 1.f;

    public MyCustomView(Context mContext){
        ...
        // View code goes here
        ...
        mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        // Let the ScaleGestureDetector inspect all events.
        mScaleDetector.onTouchEvent(ev);
        return true;
    }

    @Override
    public void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        canvas.save();
        canvas.scale(mScaleFactor, mScaleFactor);
        ...
        // onDraw() code goes here
        ...
        canvas.restore();
    }

    private class ScaleListener
            extends ScaleGestureDetector.SimpleOnScaleGestureListener {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            mScaleFactor *= detector.getScaleFactor();

            // Don't let the object get too small or too large.
            mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));

            invalidate();
            return true;
        }
    }
    

Contoh penskalaan yang lebih kompleks

Berikut adalah contoh yang lebih kompleks dari contoh InteractiveChart yang disediakan dengan class ini. Contoh InteractiveChart mendukung scrolling (panning) dan penskalaan dengan beberapa jari, menggunakan fitur "span" ScaleGestureDetector (getCurrentSpanX/Y) dan "fokus" (getFocusX/Y):

Kotlin

    private val mCurrentViewport = RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX)
    private val mContentRect: Rect? = null
    ...
    override fun onTouchEvent(event: MotionEvent): Boolean {
        return mScaleGestureDetector.onTouchEvent(event)
                || mGestureDetector.onTouchEvent(event)
                || super.onTouchEvent(event)
    }

    /**
     * The scale listener, used for handling multi-finger scale gestures.
     */
    private val mScaleGestureListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {

        /**
         * This is the active focal point in terms of the viewport. Could be a local
         * variable but kept here to minimize per-frame allocations.
         */
        private val viewportFocus = PointF()
        private var lastSpanX: Float = 0f
        private var lastSpanY: Float = 0f

        // Detects that new pointers are going down.
        override fun onScaleBegin(scaleGestureDetector: ScaleGestureDetector): Boolean {
            lastSpanX = scaleGestureDetector.currentSpanX
            lastSpanY = scaleGestureDetector.currentSpanY
            return true
        }

        override fun onScale(scaleGestureDetector: ScaleGestureDetector): Boolean {
            val spanX: Float = scaleGestureDetector.currentSpanX
            val spanY: Float = scaleGestureDetector.currentSpanY

            val newWidth: Float = lastSpanX / spanX * mCurrentViewport.width()
            val newHeight: Float = lastSpanY / spanY * mCurrentViewport.height()

            val focusX: Float = scaleGestureDetector.focusX
            val focusY: Float = scaleGestureDetector.focusY
            // Makes sure that the chart point is within the chart region.
            // See the sample for the implementation of hitTest().
            hitTest(focusX, focusY, viewportFocus)

            mContentRect?.apply {
                mCurrentViewport.set(
                        viewportFocus.x - newWidth * (focusX - left) / width(),
                        viewportFocus.y - newHeight * (bottom - focusY) / height(),
                        0f,
                        0f
                )
            }
            mCurrentViewport.right = mCurrentViewport.left + newWidth
            mCurrentViewport.bottom = mCurrentViewport.top + newHeight
            // Invalidates the View to update the display.
            ViewCompat.postInvalidateOnAnimation(this@InteractiveLineGraphView)

            lastSpanX = spanX
            lastSpanY = spanY
            return true
        }
    }
    

Java

    private RectF mCurrentViewport =
            new RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX);
    private Rect mContentRect;
    private ScaleGestureDetector mScaleGestureDetector;
    ...
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        boolean retVal = mScaleGestureDetector.onTouchEvent(event);
        retVal = mGestureDetector.onTouchEvent(event) || retVal;
        return retVal || super.onTouchEvent(event);
    }

    /**
     * The scale listener, used for handling multi-finger scale gestures.
     */
    private final ScaleGestureDetector.OnScaleGestureListener mScaleGestureListener
            = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
        /**
         * This is the active focal point in terms of the viewport. Could be a local
         * variable but kept here to minimize per-frame allocations.
         */
        private PointF viewportFocus = new PointF();
        private float lastSpanX;
        private float lastSpanY;

        // Detects that new pointers are going down.
        @Override
        public boolean onScaleBegin(ScaleGestureDetector scaleGestureDetector) {
            lastSpanX = ScaleGestureDetectorCompat.
                    getCurrentSpanX(scaleGestureDetector);
            lastSpanY = ScaleGestureDetectorCompat.
                    getCurrentSpanY(scaleGestureDetector);
            return true;
        }

        @Override
        public boolean onScale(ScaleGestureDetector scaleGestureDetector) {

            float spanX = ScaleGestureDetectorCompat.
                    getCurrentSpanX(scaleGestureDetector);
            float spanY = ScaleGestureDetectorCompat.
                    getCurrentSpanY(scaleGestureDetector);

            float newWidth = lastSpanX / spanX * mCurrentViewport.width();
            float newHeight = lastSpanY / spanY * mCurrentViewport.height();

            float focusX = scaleGestureDetector.getFocusX();
            float focusY = scaleGestureDetector.getFocusY();
            // Makes sure that the chart point is within the chart region.
            // See the sample for the implementation of hitTest().
            hitTest(scaleGestureDetector.getFocusX(),
                    scaleGestureDetector.getFocusY(),
                    viewportFocus);

            mCurrentViewport.set(
                    viewportFocus.x
                            - newWidth * (focusX - mContentRect.left)
                            / mContentRect.width(),
                    viewportFocus.y
                            - newHeight * (mContentRect.bottom - focusY)
                            / mContentRect.height(),
                    0,
                    0);
            mCurrentViewport.right = mCurrentViewport.left + newWidth;
            mCurrentViewport.bottom = mCurrentViewport.top + newHeight;
            ...
            // Invalidates the View to update the display.
            ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);

            lastSpanX = spanX;
            lastSpanY = spanY;
            return true;
        }
    };