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 de arrastre. En el ejemplo de InteractiveChart
, se utiliza la clase EdgeEffect
(en realidad, la clase EdgeEffectCompat
) a fin de mostrar un efecto de "resplandor" cuando los usuarios llegan al límite del contenido.
Nota: Te recomendamos utilizar OverScroller
en lugar de Scroller
para las animaciones de desplazamiento.
La clase OverScroller
proporciona mejor compatibilidad con dispositivos antiguos.
Ten en cuenta también que, por lo general, solo necesitas usar desplazadores cuando implementas el desplazamiento tú mismo. Las clases ScrollView
y HorizontalScrollView
hacen todo esto por ti si anidas el diseño dentro de ellas.
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:
Cómo interpretar 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 el eje x como en el 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()
enGestureDetector.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 de manera rápida. 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()
enGestureDetector.OnGestureListener
y usando un objeto de 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.
Usa un GestureDetector
y anula el método GestureDetector.SimpleOnGestureListener
de 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 ejemplo InteractiveChart
muestra 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 toda 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".