No Android, a rolagem é normalmente realizada usando a
classe
ScrollView
. Aninhe qualquer layout padrão que possa ser estendido além dos limites do
contêiner em uma ScrollView
para oferecer uma visualização rolável gerenciada pelo
framework. A implementação de um controle de rolagem personalizado só é necessária em situações
especiais. Este documento descreve como mostrar um efeito de rolagem em resposta
a gestos de toque usando controles de rolagem.
O app 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
também inclui métodos para
indicar aos usuários quando eles atingem as bordas do conteúdo após um gesto de movimentação
ou rolagem rápida.
- A partir do Android 12 (nível 31 da API), os elementos visuais se esticam e voltam ao normal em um evento de arrastar e deslizam e voltam ao normal em um evento de deslizamento rápido.
- No Android 11 (nível 30 da API) e versões anteriores, os limites mostram um efeito de "brilho" após um gesto de arrastar ou de rolagem rápida até a borda.
O exemplo InteractiveChart
neste documento usa a classe
EdgeEffect
para mostrar esses efeitos de rolagem excessiva.
É possível usar um controle de rolagem para animar a rolagem ao longo do tempo, usando a física de rolagem padrão da plataforma, como atrito, velocidade e outras qualidades. O 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 à visualização. Você precisa receber e aplicar novas coordenadas a uma taxa que torne a animação de rolagem suave.
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ê está vendo. Quando a rolagem ocorre nos eixos
x e y, ela é chamada de movimentação. O
app de exemplo InteractiveChart
neste documento ilustra dois
tipos diferentes de rolagem, arrasto e rolagem rápida:
- Arrasto:é o tipo de rolagem que ocorre quando o usuário
arrasta o dedo na tela. É possível implementar o arrasto
substituindo
onScroll()
noGestureDetector.OnGestureListener
. Para mais informaçõ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. Depois que o usuário levanta o dedo, geralmente é necessário continuar movendo a janela de visualização, mas desacelerar até que ela
pare de se mover. É possível implementar a rolagem rápida substituindo
onFling()
noGestureDetector.OnGestureListener
e usando um objeto de rolagem. - Movimentação:a rolagem simultânea nos eixos x e y é chamada de movimentação.
É comum usar objetos de rolagem com um gesto de rolagem rápida, mas
é possível usá-los em 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 diretamente e produzir um efeito de rolagem ou uma
animação de "ajuste à página" em resposta a esses eventos de toque.
Componentes que contêm implementações de rolagem integradas
Os seguintes componentes do Android contêm suporte integrado para rolagem e comportamento de rolagem excessiva:
GridView
HorizontalScrollView
ListView
NestedScrollView
RecyclerView
ScrollView
ViewPager
ViewPager2
Se o app precisar oferecer suporte à rolagem e à rolagem excessiva em um componente diferente, siga estas etapas:
- Criar uma implementação personalizada de rolagem baseada em toque.
- Para oferecer suporte a dispositivos com o Android 12 e versões mais recentes, implemente o efeito de rolagem esticada.
Criar uma implementação personalizada de rolagem baseada em toque
Esta seção descreve como criar seu próprio controle de rolagem se o app usar um componente que não oferece suporte integrado para rolagem e rolagem excessiva.
O snippet a seguir vem da
amostra
InteractiveChart
. 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 chegar às bordas do conteúdo depois de realizar o
gesto de rolagem rápida, o contêiner indicará quando o usuário chegar ao final do
conteúdo. A indicação depende da versão do Android usada pelo dispositivo:
- No Android 12 e versões mais recentes, os elementos visuais se esticam e voltam ao normal.
- No Android 11 e versões anteriores, os elementos visuais mostram um efeito de brilho.
A primeira parte do snippet a seguir mostra a implementação de
onFling()
:
Kotlin
// Viewport extremes. See currentViewport for a discussion of the viewport. private val AXIS_X_MIN = -1f private val AXIS_X_MAX = 1f private val AXIS_Y_MIN = -1f private val AXIS_Y_MAX = 1f // The current viewport. This rectangle represents the visible chart // domain and range. The viewport is the part of the app that the // user manipulates via touch gestures. private val currentViewport = 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 must be drawn. private lateinit var contentRect: Rect private lateinit var scroller: OverScroller private lateinit var scrollerStartViewport: RectF ... private val gestureListener = object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { // Initiates the decay phase of any active edge effects. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects() } scrollerStartViewport.set(currentViewport) // Aborts any active scroll animations and invalidates. scroller.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. // On Android 12 and later, the edge effect (stretch) must // continue. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects() } // Flings use math in pixels, as opposed to math based on the viewport. val surfaceSize: Point = computeScrollSurfaceSize() val (startX: Int, startY: Int) = scrollerStartViewport.run { set(currentViewport) (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, stops the current animation. scroller.forceFinished(true) // Begins the animation. scroller.fling( // Current scroll position. startX, startY, velocityX, velocityY, /* * Minimum and maximum scroll positions. The minimum scroll * position is generally 0 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 is 800 pixels. */ 0, surfaceSize.x - contentRect.width(), 0, surfaceSize.y - contentRect.height(), // The edges of the content. This comes into play when using // the EdgeEffect class to draw "glow" overlays. contentRect.width() / 2, contentRect.height() / 2 ) // Invalidates to trigger computeScroll(). ViewCompat.postInvalidateOnAnimation(this) }
Java
// Viewport extremes. See currentViewport for a discussion of the viewport. private static final float AXIS_X_MIN = -1f; private static final float AXIS_X_MAX = 1f; private static final float AXIS_Y_MIN = -1f; private static final float AXIS_Y_MAX = 1f; // The current viewport. This rectangle represents the visible chart // domain and range. The viewport is the part of the app that the // user manipulates via touch gestures. private RectF currentViewport = 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 must be drawn. private final Rect contentRect = new Rect(); private final OverScroller scroller; private final RectF scrollerStartViewport = new RectF(); // Used only for zooms and flings. ... private final GestureDetector.SimpleOnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() { @Override public boolean onDown(MotionEvent e) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects(); } scrollerStartViewport.set(currentViewport); scroller.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. // On Android 12 and later, the edge effect (stretch) must // continue. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects(); } // Flings use math in pixels, as opposed to math based on the viewport. Point surfaceSize = computeScrollSurfaceSize(); scrollerStartViewport.set(currentViewport); int startX = (int) (surfaceSize.x * (scrollerStartViewport.left - AXIS_X_MIN) / ( AXIS_X_MAX - AXIS_X_MIN)); int startY = (int) (surfaceSize.y * (AXIS_Y_MAX - scrollerStartViewport.bottom) / ( AXIS_Y_MAX - AXIS_Y_MIN)); // Before flinging, stops the current animation. scroller.forceFinished(true); // Begins the animation. scroller.fling( // Current scroll position. startX, startY, velocityX, velocityY, /* * Minimum and maximum scroll positions. The minimum scroll * position is generally 0 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 is 800 pixels. */ 0, surfaceSize.x - contentRect.width(), 0, surfaceSize.y - contentRect.height(), // The edges of the content. This comes into play when using // the EdgeEffect class to draw "glow" overlays. contentRect.width() / 2, contentRect.height() / 2); // Invalidates to trigger computeScroll(). ViewCompat.postInvalidateOnAnimation(this); }
Quando onFling()
chama
postInvalidateOnAnimation()
,
ele aciona
computeScroll()
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, conforme mostrado no exemplo
anterior.
A maioria das visualizações transmite a posição x e y do objeto de rolagem diretamente
para
scrollTo()
.
A implementação de computeScroll()
a seguir adota uma abordagem
diferente: ela chama
computeScrollOffset()
para receber a localização atual de x e y. Quando os critérios para
exibir um efeito de borda "glow" de rolagem excessiva são atendidos, ou seja, a tela
está ampliada, x ou y está fora dos limites e o app ainda não
está mostrando uma rolagem excessiva, o código configura o efeito de brilho de rolagem excessiva e
chama postInvalidateOnAnimation()
para acionar uma invalidação na
visualização.
Kotlin
// Edge effect/overscroll tracking objects. private lateinit var edgeEffectTop: EdgeEffect private lateinit var edgeEffectBottom: EdgeEffect private lateinit var edgeEffectLeft: EdgeEffect private lateinit var edgeEffectRight: EdgeEffect private var edgeEffectTopActive: Boolean = false private var edgeEffectBottomActive: Boolean = false private var edgeEffectLeftActive: Boolean = false private var edgeEffectRightActive: Boolean = false override fun computeScroll() { super.computeScroll() var needsInvalidate = false // The scroller isn't finished, meaning a fling or // programmatic pan operation is active. if (scroller.computeScrollOffset()) { val surfaceSize: Point = computeScrollSurfaceSize() val currX: Int = scroller.currX val currY: Int = scroller.currY val (canScrollX: Boolean, canScrollY: Boolean) = currentViewport.run { (left > AXIS_X_MIN || right < AXIS_X_MAX) to (top > AXIS_Y_MIN || bottom < AXIS_Y_MAX) } /* * If you are zoomed in, currX or currY is * outside of bounds, and you aren't already * showing overscroll, then render the overscroll * glow edge effect. */ if (canScrollX && currX < 0 && edgeEffectLeft.isFinished && !edgeEffectLeftActive) { edgeEffectLeft.onAbsorb(scroller.currVelocity.toInt()) edgeEffectLeftActive = true needsInvalidate = true } else if (canScrollX && currX > surfaceSize.x - contentRect.width() && edgeEffectRight.isFinished && !edgeEffectRightActive) { edgeEffectRight.onAbsorb(scroller.currVelocity.toInt()) edgeEffectRightActive = true needsInvalidate = true } if (canScrollY && currY < 0 && edgeEffectTop.isFinished && !edgeEffectTopActive) { edgeEffectTop.onAbsorb(scroller.currVelocity.toInt()) edgeEffectTopActive = true needsInvalidate = true } else if (canScrollY && currY > surfaceSize.y - contentRect.height() && edgeEffectBottom.isFinished && !edgeEffectBottomActive) { edgeEffectBottom.onAbsorb(scroller.currVelocity.toInt()) edgeEffectBottomActive = true needsInvalidate = true } ... } }
Java
// Edge effect/overscroll tracking objects. private EdgeEffectCompat edgeEffectTop; private EdgeEffectCompat edgeEffectBottom; private EdgeEffectCompat edgeEffectLeft; private EdgeEffectCompat edgeEffectRight; private boolean edgeEffectTopActive; private boolean edgeEffectBottomActive; private boolean edgeEffectLeftActive; private boolean edgeEffectRightActive; @Override public void computeScroll() { super.computeScroll(); boolean needsInvalidate = false; // The scroller isn't finished, meaning a fling or // programmatic pan operation is active. if (scroller.computeScrollOffset()) { Point surfaceSize = computeScrollSurfaceSize(); int currX = scroller.getCurrX(); int currY = scroller.getCurrY(); boolean canScrollX = (currentViewport.left > AXIS_X_MIN || currentViewport.right < AXIS_X_MAX); boolean canScrollY = (currentViewport.top > AXIS_Y_MIN || currentViewport.bottom < AXIS_Y_MAX); /* * If you are zoomed in, currX or currY is * outside of bounds, and you aren't already * showing overscroll, then render the overscroll * glow edge effect. */ if (canScrollX && currX < 0 && edgeEffectLeft.isFinished() && !edgeEffectLeftActive) { edgeEffectLeft.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectLeftActive = true; needsInvalidate = true; } else if (canScrollX && currX > (surfaceSize.x - contentRect.width()) && edgeEffectRight.isFinished() && !edgeEffectRightActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectRightActive = true; needsInvalidate = true; } if (canScrollY && currY < 0 && edgeEffectTop.isFinished() && !edgeEffectTopActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectTopActive = true; needsInvalidate = true; } else if (canScrollY && currY > (surfaceSize.y - contentRect.height()) && edgeEffectBottom.isFinished() && !edgeEffectBottomActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectBottomActive = true; needsInvalidate = true; } ... }
Esta é a seção do código que executa o zoom real:
Kotlin
lateinit var zoomer: Zoomer val zoomFocalPoint = PointF() ... // If a zoom is in progress—either programmatically // or through double touch—this performs the zoom. if (zoomer.computeZoom()) { val newWidth: Float = (1f - zoomer.currZoom) * scrollerStartViewport.width() val newHeight: Float = (1f - zoomer.currZoom) * scrollerStartViewport.height() val pointWithinViewportX: Float = (zoomFocalPoint.x - scrollerStartViewport.left) / scrollerStartViewport.width() val pointWithinViewportY: Float = (zoomFocalPoint.y - scrollerStartViewport.top) / scrollerStartViewport.height() currentViewport.set( zoomFocalPoint.x - newWidth * pointWithinViewportX, zoomFocalPoint.y - newHeight * pointWithinViewportY, zoomFocalPoint.x + newWidth * (1 - pointWithinViewportX), zoomFocalPoint.y + newHeight * (1 - pointWithinViewportY) ) constrainViewport() needsInvalidate = true } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this) }
Java
// Custom object that is functionally similar to Scroller. Zoomer zoomer; private PointF zoomFocalPoint = new PointF(); ... // If a zoom is in progress—either programmatically // or through double touch—this performs the zoom. if (zoomer.computeZoom()) { float newWidth = (1f - zoomer.getCurrZoom()) * scrollerStartViewport.width(); float newHeight = (1f - zoomer.getCurrZoom()) * scrollerStartViewport.height(); float pointWithinViewportX = (zoomFocalPoint.x - scrollerStartViewport.left) / scrollerStartViewport.width(); float pointWithinViewportY = (zoomFocalPoint.y - scrollerStartViewport.top) / scrollerStartViewport.height(); currentViewport.set( zoomFocalPoint.x - newWidth * pointWithinViewportX, zoomFocalPoint.y - newHeight * pointWithinViewportY, zoomFocalPoint.x + newWidth * (1 - pointWithinViewportX), zoomFocalPoint.y + newHeight * (1 - pointWithinViewportY)); constrainViewport(); needsInvalidate = true; } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this); }
Este é o método computeScrollSurfaceSize()
chamado no
snippet anterior. 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( (contentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / currentViewport.width()).toInt(), (contentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / currentViewport.height()).toInt() ) }
Java
private Point computeScrollSurfaceSize() { return new Point( (int) (contentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / currentViewport.width()), (int) (contentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / currentViewport.height())); }
Para outro exemplo 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".
Implementar o efeito de rolagem esticada
A partir do Android 12, o EdgeEffect
adiciona as
seguintes APIs para implementar o efeito de rolagem esticada:
getDistance()
onPullDistance()
Para oferecer a melhor experiência do usuário com a rolagem esticada, faça o seguinte:
- Quando a animação de esticamento estiver em vigor quando o usuário tocar no conteúdo, registre o toque como uma "captura". O usuário interrompe a animação e começa a manipular o esticamento novamente.
- Quando o usuário mover o dedo na direção oposta do esticamento, libere o esticamento até que ele desapareça completamente e comece a rolar a tela.
- Quando o usuário deslizar rapidamente durante um alongamento, deslize rapidamente o
EdgeEffect
para melhorar o efeito do alongamento.
Capturar a animação
Quando um usuário captura uma animação de esticamento ativa,
EdgeEffect.getDistance()
retorna 0
. Essa condição
indica que o trecho precisa ser manipulado pelo movimento do toque. Na maioria
dos contêineres, a captura é detectada em onInterceptTouchEvent()
, conforme
mostrado no snippet de código abaixo:
Kotlin
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { ... when (action and MotionEvent.ACTION_MASK) { MotionEvent.ACTION_DOWN -> ... isBeingDragged = EdgeEffectCompat.getDistance(edgeEffectBottom) > 0f || EdgeEffectCompat.getDistance(edgeEffectTop) > 0f ... } return isBeingDragged }
Java
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { ... switch (action & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: ... isBeingDragged = EdgeEffectCompat.getDistance(edgeEffectBottom) > 0 || EdgeEffectCompat.getDistance(edgeEffectTop) > 0; ... } }
No exemplo anterior, onInterceptTouchEvent()
retorna
true
quando mIsBeingDragged
é true
. Portanto, ele é suficiente para consumir o evento antes que o elemento filho tenha a oportunidade de
consumi-lo.
Soltar o efeito de rolagem
É importante soltar o efeito de esticamento antes de iniciar a rolagem para evitar que ele seja aplicado ao conteúdo. O exemplo de código a seguir aplica essa prática recomendada:
Kotlin
override fun onTouchEvent(ev: MotionEvent): Boolean { val activePointerIndex = ev.actionIndex when (ev.getActionMasked()) { MotionEvent.ACTION_MOVE -> val x = ev.getX(activePointerIndex) val y = ev.getY(activePointerIndex) var deltaY = y - lastMotionY val pullDistance = deltaY / height val displacement = x / width if (deltaY < 0f && EdgeEffectCompat.getDistance(edgeEffectTop) > 0f) { deltaY -= height * EdgeEffectCompat.onPullDistance(edgeEffectTop, pullDistance, displacement); } if (deltaY > 0f && EdgeEffectCompat.getDistance(edgeEffectBottom) > 0f) { deltaY += height * EdgeEffectCompat.onPullDistance(edgeEffectBottom, -pullDistance, 1 - displacement); } ... }
Java
@Override public boolean onTouchEvent(MotionEvent ev) { final int actionMasked = ev.getActionMasked(); switch (actionMasked) { case MotionEvent.ACTION_MOVE: final float x = ev.getX(activePointerIndex); final float y = ev.getY(activePointerIndex); float deltaY = y - lastMotionY; float pullDistance = deltaY / getHeight(); float displacement = x / getWidth(); if (deltaY < 0 && EdgeEffectCompat.getDistance(edgeEffectTop) > 0) { deltaY -= getHeight() * EdgeEffectCompat.onPullDistance(edgeEffectTop, pullDistance, displacement); } if (deltaY > 0 && EdgeEffectCompat.getDistance(edgeEffectBottom) > 0) { deltaY += getHeight() * EdgeEffectCompat.onPullDistance(edgeEffectBottom, -pullDistance, 1 - displacement); } ...
Quando o usuário estiver arrastando, consuma a distância de pull do EdgeEffect
antes de transmitir o evento de toque para um contêiner de rolagem aninhado ou arrastar a
rolagem. No exemplo de código anterior, getDistance()
retorna um
valor positivo quando um efeito de borda está sendo exibido e pode ser liberado com
um movimento. Quando o evento de toque libera o esticamento, ele é consumido primeiro pelo
EdgeEffect
para que seja liberado completamente antes que outros efeitos,
como a rolagem aninhada, sejam exibidos. Você pode usar getDistance()
para saber a distância necessária para soltar o efeito atual.
Ao contrário de onPull()
, onPullDistance()
retorna a
quantidade consumida do delta transmitido. A partir do Android 12, se
onPull()
ou onPullDistance()
transmitirem valores negativos
de deltaDistance
quando getDistance()
for
0
, o efeito de alongamento não vai mudar. No Android 11
e versões anteriores, o onPull()
permite valores negativos para a distância total
mostrar efeitos de brilho.
Desativar a rolagem excessiva
Você pode desativar a rolagem no arquivo de layout ou programaticamente.
Para desativar o recurso no arquivo de layout, defina android:overScrollMode
, como
mostrado no exemplo abaixo:
<MyCustomView android:overScrollMode="never"> ... </MyCustomView>
Para desativar programaticamente, use um código como este:
Kotlin
customView.overScrollMode = View.OVER_SCROLL_NEVER
Java
customView.setOverScrollMode(View.OVER_SCROLL_NEVER);
Outros recursos
Confira estes recursos relacionados:
- Visão geral dos eventos de entrada
- Visão geral dos sensores
- Tornar uma visualização personalizada interativa