No Android, a rolagem é normalmente realizada usando a classe ScrollView
. Todo layout padrão que possa ser estendido além dos limites do próprio contêiner precisa ser aninhado em uma ScrollView
para oferecer uma visualização rolável gerenciada pelo framework. A implementação de um controle de rolagem personalizado só deve ser necessária em situações especiais. Esta lição descreve esse tipo de situação: a exibição de um efeito de rolagem em resposta a gestos de toque usando controles de rolagem.
Você pode usar controles de rolagem (Scroller
ou OverScroller
) para coletar os dados necessários para produzir uma animação de rolagem em resposta a um evento de toque. Eles são semelhantes, mas OverScroller
inclui métodos para indicar que o usuário atingiu as bordas do conteúdo depois de um gesto de movimentação ou rolagem rápida. A amostra InteractiveChart
usa a classe EdgeEffect
(na verdade, a classe EdgeEffectCompat
) para exibir um efeito de "brilho" quando o usuário atinge as bordas do conteúdo.
Observação: recomendamos que você use OverScroller
em vez de Scroller
para rolar animações.
OverScroller
oferece a melhor compatibilidade com dispositivos mais antigos.
Geralmente, você só precisa usar controles de rolagem ao implementar a rolagem por conta própria. ScrollView
e HorizontalScrollView
farão tudo isso se você aninhar seu layout dentro deles.
Um controle de rolagem é usado para animar a rolagem ao longo do tempo, usando a física de rolagem padrão da plataforma (atrito, velocidade etc.). O próprio controle de rolagem não desenha nada. Esses controles rastreiam deslocamentos de rolagem ao longo do tempo, mas não aplicam essas posições automaticamente à sua visualização. Cabe a você conseguir novas coordenadas e aplicá-las a uma velocidade que torne a animação de rolagem fluida.
Confira os seguintes recursos relacionados:
Compreender a terminologia de rolagem
"Rolagem" é uma palavra que pode ter diferentes significados no Android, dependendo do contexto.
A rolagem é o processo geral de mover a janela de visualização, ou seja, a "janela" de conteúdo que você vê. Quando a rolagem ocorre nos eixos x e y, ela é chamada de movimentação. O aplicativo de amostra disponibilizado com essa classe, InteractiveChart
, ilustra dois tipos diferentes de rolagem, que são o arrasto e a rolagem rápida:
- Arrasto é o tipo de rolagem que ocorre quando o usuário arrasta o dedo na tela. O arrasto simples é geralmente implementado substituindo
onScroll()
noGestureDetector.OnGestureListener
. Para ver mais discussões sobre o gesto de arrastar, consulte Arrastar e dimensionar. - Rolagem rápida é o tipo de rolagem que ocorre quando o usuário arrasta e levanta o dedo rapidamente da tela. Em geral, depois que o usuário levanta o dedo, a intenção é continuar rolando (movendo a janela de visualização), mas desacelerar até que a janela de visualização pare completamente.
A rolagem rápida pode ser implementada substituindo
onFling()
noGestureDetector.OnGestureListener
e usando um objeto de rolagem. Esse caso de uso é o tema desta lição.
É comum usar objetos de rolagem junto com um gesto de rolagem rápida, mas eles podem ser usados em praticamente qualquer contexto em que você queira que a IU exiba rolagem em resposta a um evento de toque. Por exemplo, é possível substituir onTouchEvent()
para processar eventos de toque de forma direta e produzir um efeito de rolagem ou uma animação de "ajuste à página" em resposta a esses eventos de toque.
Implementar rolagem baseada em toque
Esta seção descreve como usar um controle de rolagem.
O snippet abaixo vem da amostra InteractiveChart
disponibilizado com essa classe.
Ele usa um GestureDetector
e substitui o método GestureDetector.SimpleOnGestureListener
, onFling()
.
Ele usa OverScroller
para rastrear o gesto de rolagem rápida.
Se o usuário atingir as bordas do conteúdo depois do gesto de rolagem rápida, o app exibirá um efeito de "brilho".
Observação: o app de amostra InteractiveChart
exibe um gráfico que você pode ampliar, movimentar, rolar etc. No snippet a seguir, mContentRect
representa as coordenadas do retângulo na visualização em que o gráfico será desenhado. A qualquer momento, um subconjunto do domínio e do intervalo totais do gráfico é desenhado nessa área retangular.
mCurrentViewport
representa a parte do gráfico atualmente visível na tela. Como os deslocamentos de pixel geralmente são tratados como números inteiros, mContentRect
é do tipo Rect
. Como o domínio e o intervalo do gráfico são valores decimais/flutuantes, mCurrentViewport
é do tipo RectF
.
A primeira parte do snippet mostra a implementação 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); }
Quando onFling()
chama postInvalidateOnAnimation()
, o computeScroll()
é acionado para atualizar os valores de x e y.
Isso geralmente é feito quando uma visualização filha está animando uma rolagem usando um objeto de rolagem, como nesse exemplo.
A maioria das visualizações transmite a posição x e y do objeto de rolagem diretamente para scrollTo()
.
A implementação de computeScroll()
abaixo adota uma abordagem diferente, chamando computeScrollOffset()
para receber o local atual de x e y. Quando os critérios para a exibição de um efeito de borda de "brilho" com rolagem são cumpridos, ou seja, a exibição é ampliada, x ou y está fora dos limites e o app ainda não exibe uma rolagem, o código configura o efeito de brilho de rolagem e chama postInvalidateOnAnimation()
para acionar uma invalidação na visualização:
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; } ... }
Esta é a seção do código que executa o 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 é o método computeScrollSurfaceSize()
chamado no snippet acima. Ele calcula o tamanho atual da superfície rolável em pixels. Por exemplo, se toda a área do gráfico estiver visível, esse será o tamanho atual de mContentRect
. Se o gráfico for ampliado em 200% nas duas direções, o tamanho retornado será duas vezes maior horizontal e verticalmente.
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 outros exemplos de uso de controles de rolagem, consulte o código-fonte da classe ViewPager
. Ele rola em resposta a gestos de rolagem rápida e usa a rolagem para implementar a animação de "ajuste à página".