Cómo animar el gesto para desplazar

En Android, el desplazamiento generalmente se logra con la clase ScrollView. Cualquier diseño estándar que pueda extenderse más allá de los límites de su contenedor debe estar anidado en un objeto ScrollView para proporcionar una vista desplazable administrada por el marco de trabajo. La implementación de un desplazador personalizado solo debería ser necesaria en escenarios especiales. En esta lección, se describe ese escenario: se muestra un efecto de desplazamiento en respuesta a los gestos táctiles mediante desplazadores.

Puedes usar desplazadores (Scroller o OverScroller) con el fin de recopilar los datos necesarios para producir una animación de desplazamiento en respuesta a un evento táctil. Son similares, pero OverScroller incluye métodos para indicar a los usuarios que llegaron al límite del contenido después de un gesto de desplazamiento lateral o arrastre. En el ejemplo de InteractiveChart, se utiliza la clase EdgeEffect (en realidad, EdgeEffectCompat ) a fin de mostrar un efecto de "resplandor" cuando los usuarios llegan al límite del contenido.

Nota: Te recomendamos usar OverScroller en lugar de Scroller para desplazar animaciones. OverScroller proporciona mejor compatibilidad con dispositivos antiguos.
Ten en cuenta también que, por lo general, solo necesitas usar desplazadores al implementar el desplazamiento tú mismo. ScrollView y HorizontalScrollView hacen el trabajo por ti si anidas el diseño dentro de ellos.

Un desplazador se usa para animar el desplazamiento del tiempo mediante la física de desplazamiento estándar de la plataforma (fricción, velocidad, etc.). En sí, el desplazador no hace ningún dibujo. Los desplazadores registran los descentramientos de desplazamiento del tiempo, pero no aplican esas posiciones automáticamente a tu vista. Es tu responsabilidad obtener y aplicar nuevas coordenadas a una velocidad que haga que la animación de desplazamiento se vea uniforme.

Consulta los siguientes recursos relacionados:

Comprende la terminología de desplazamiento

"Desplazamiento" es una palabra que puede adoptar diferentes significados en Android, según el contexto.

El desplazamiento es el proceso general de mover el viewport (es decir, la "ventana" del contenido que estás viendo). Cuando el desplazamiento se realiza tanto en los ejes x como y, se llama desplazamiento lateral. La aplicación de muestra proporcionada con esta clase, InteractiveChart, ilustra dos tipos diferentes de desplazamiento, arrastrar y arrastrar y soltar.

  • Arrastrar es el tipo de desplazamiento que ocurre cuando un usuario arrastra el dedo por la pantalla táctil. El arrastre simple suele implementarse anulando onScroll() en GestureDetector.OnGestureListener. Para obtener más información sobre el arrastre, consulta Arrastre y escalamiento.
  • Arrastrar y soltar es el tipo de desplazamiento que ocurre cuando un usuario arrastra y levanta el dedo rápidamente. Después de que el usuario levanta el dedo, generalmente desea seguir desplazándose (moviendo el viewport), pero desacelerar hasta que el viewport deje de moverse. Arrastrar y soltar se puede implementar anulando onFling() en GestureDetector.OnGestureListener, y mediante el uso de un objeto desplazamiento. Este es el caso práctico de esta lección.

Es común usar objetos de desplazamiento junto con un gesto de arrastrar y soltar, pero se pueden usar en prácticamente cualquier contexto en el que desee que la IU muestre desplazamiento en respuesta a un evento táctil. Por ejemplo, puedes anular onTouchEvent() para procesar eventos táctiles directamente y producir un efecto de desplazamiento o una animación de "ajuste a la página" en respuesta a esos eventos táctiles.

Cómo implementar desplazamiento táctil

En esta sección, se describe cómo usar un desplazador. El fragmento que se muestra a continuación proviene del ejemplo de InteractiveChart proporcionado con esta clase. Utiliza un objeto GestureDetector y anula el método GestureDetector.SimpleOnGestureListener onFling(). Utiliza OverScroller para hacer un seguimiento del gesto de arrastrar y soltar. Si el usuario llega al límite del contenido después del gesto de arrastrar y soltar, la app muestra un efecto de "resplandor".

Nota: La app de muestra InteractiveChart contiene un gráfico que puedes ampliar, desplazar lateralmente, desplazar, etc. En el siguiente fragmento, mContentRect representa las coordenadas del rectángulo dentro de la vista en la que se dibujará el gráfico. En un momento determinado, se dibuja un subconjunto del dominio y el rango del gráfico total en esta área rectangular. mCurrentViewport representa la parte del gráfico que está visible en la pantalla. Debido a que, por lo general, los desplazamientos de píxeles se tratan como enteros, mContentRect es del tipo Rect. Como el dominio y el rango del gráfico son valores decimales/flotantes, mCurrentViewport es del tipo RectF.

La primera parte del siguiente fragmento muestra la implementación de 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);
    }
    

Cuando onFling() llama a postInvalidateOnAnimation(), activa computeScroll() para actualizar los valores de x e y. Por lo general, esto se hace cuando un elemento secundario de vista está animando un desplazamiento con un objeto de desplazamiento, como en este ejemplo.

La mayoría de las vistas pasan la posición x e y del objeto de desplazamiento directamente a scrollTo(). La siguiente implementación de computeScroll() adopta un enfoque diferente: llama a computeScrollOffset() para obtener la ubicación actual de x e y. Cuando se cumplen los criterios para mostrar un efecto de "resplandor" en el límite al desplazar (se amplía la pantalla, x o y está fuera del límite, y la app no muestra un desplazamiento), el código configura el efecto de resplandor y llama a postInvalidateOnAnimation() para activar una invalidación en la vista:

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

La siguiente es la sección del código que realiza el zoom real:

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

Este es el método computeScrollSurfaceSize() al que se llama en el fragmento anterior. Calcula el tamaño actual de la superficie desplazable, en píxeles. Por ejemplo, si todo el área del gráfico es visible, este es el tamaño actual de mContentRect. Si el gráfico se amplía en un 200% en ambas direcciones, el tamaño que se muestre será el doble de horizontal y vertical.

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

Para ver otro ejemplo de uso de desplazamiento, consulta el código fuente de la clase ViewPager. Se desplaza en respuesta a los gestos de arrastrar y soltar, y utiliza el desplazamiento para implementar la animación de "ajuste a la página".