Arrastre y ajuste de tamaño

En esta lección, se describe cómo usar gestos táctiles con el objetivo de arrastrar y ajustar el tamaño de los objetos en pantalla con onTouchEvent() para interceptar eventos táctiles.

Consulta los siguientes recursos relacionados:

Cómo arrastrar un objeto

Si orientas tus anuncios a Android 3.0 o versiones posteriores, puedes usar los elementos incorporados para arrastrar y soltar objetos de escucha de eventos con View.OnDragListener, como se describe en Cómo arrastrar y soltar.

Una operación común para un gesto táctil es usarlo con el objetivo de arrastrar un objeto por la pantalla. El siguiente fragmento, le permite al usuario arrastrar una imagen en pantalla. Ten en cuenta lo siguiente:

  • En una operación de arrastre (o desplazamiento), la app debe realizar un seguimiento del puntero original (dedo), incluso si se colocan dedos adicionales en la pantalla. Por ejemplo, imagina que mientras arrastras la imagen, el usuario coloca un segundo dedo en la pantalla táctil y levanta el primer dedo. Si tu app solo rastrea punteros individuales, considerará el segundo puntero como predeterminado y moverá la imagen a esa ubicación.
  • Para evitar que esto suceda, tu app debe distinguir entre el puntero original y los demás punteros. Para ello, rastrea los eventos ACTION_POINTER_DOWN y ACTION_POINTER_UP descritos en Cómo administrar gestos multitáctiles. ACTION_POINTER_DOWN y ACTION_POINTER_UP se pasan a la devolución de llamada onTouchEvent() cada vez que un puntero secundario baja o sube.
  • En el caso de ACTION_POINTER_UP, el ejemplo extrae este índice y garantiza que el ID del puntero activo no se refiera a un puntero que ya no toca la pantalla. Si es así, la app selecciona un puntero diferente para que esté activo y guarda su posición Y y X actual. Dado que esta posición guardada se usa en el caso de ACTION_MOVE a fin de calcular la distancia para mover el objeto en pantalla, la app siempre calculará la distancia de movimiento con los datos del puntero correcto.

El siguiente fragmento permite a un usuario arrastrar un objeto por la pantalla. Registra la posición inicial del puntero activo, calcula la distancia recorrida por el puntero y mueve el objeto a la nueva posición. Gestiona correctamente la posibilidad de punteros adicionales, como se describió más arriba.

Ten en cuenta que el fragmento usa el método getActionMasked(). Siempre debes usar este método (o mejor aún, la versión de compatibilidad MotionEventCompat.getActionMasked()) para recuperar la acción de un MotionEvent. A diferencia del método getAction() anterior, getActionMasked() está diseñado para funcionar con varios punteros. Muestra la acción enmascarada que se realiza, sin incluir los bits de índice del puntero.

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

Cómo arrastrar para desplazamiento lateral

En la sección anterior, se mostraba un ejemplo de cómo arrastrar un objeto por la pantalla. Otro escenario común es el desplazamiento lateral, que es cuando el movimiento de arrastre de un usuario provoca el desplazamiento en los ejes "y" y "x". El fragmento anterior interceptaba directamente las acciones de MotionEvent para implementar el arrastre. En el fragmento de esta sección, se aprovecha la compatibilidad integrada de la plataforma para gestos comunes. Anula onScroll() en GestureDetector.SimpleOnGestureListener.

A fin de proporcionar un poco más de contexto, se invoca onScroll() cuando un usuario arrastra su dedo para desplazar el contenido lateralmente. onScroll() solo se invoca cuando un dedo está hacia abajo; en cuanto se levanta el dedo de la pantalla, el gesto finaliza o se inicia un gesto (si el dedo se movía con cierta velocidad justo antes de levantarlo). Si deseas obtener más información sobre las diferencias entre desplazar y arrastrar y soltar, consulta Cómo animar el gesto para desplazar.

Este es el fragmento de 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;
    }
    

La implementación de onScroll() desplaza el viewport en respuesta al gesto táctil:

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

Cómo usar el tacto para realizar ajustes de tamaño

Como se explicó en Cómo detectar gestos comunes, GestureDetector ayuda a detectar los gestos comunes que usa Android, como desplazarse, arrastrar y soltar y mantener presionado. Para el escalamiento, Android proporciona ScaleGestureDetector. GestureDetector y ScaleGestureDetector se pueden usar juntos cuando quieres que una vista reconozca gestos adicionales.

Para informar eventos de gestos detectados, los detectores de gestos utilizan objetos de escucha que pasan a sus constructores. ScaleGestureDetector usa ScaleGestureDetector.OnScaleGestureListener. Android proporciona ScaleGestureDetector.SimpleOnScaleGestureListener como una clase auxiliar que puedes extender si no te interesan todos los eventos informados.

Ejemplo de ajuste de tamaño básico

A continuación, puedes ver un fragmento que ilustra los ingredientes básicos involucrados en el ajuste de tamaño.

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

Ejemplo de ajuste de tamaño más complejo

A continuación, se muestra un ejemplo más complejo de la muestra de InteractiveChart proporcionada con esta clase. La muestra de InteractiveChart admite el desplazamiento (desplazamiento lateral) y el escalamiento con varios dedos, mediante las funciones de "intervalo" de ScaleGestureDetector (getCurrentSpanX/Y) y "enfoque" (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;
        }
    };