В Android прокрутка обычно достигается с помощью класса ScrollView
. Вложите любой стандартный макет, который может выходить за пределы своего контейнера, в ScrollView
, чтобы обеспечить прокручиваемое представление, управляемое фреймворком. Реализация пользовательского скроллера необходима только для особых сценариев. В этом документе описывается, как отображать эффект прокрутки в ответ на сенсорные жесты с помощью скроллеров .
Ваше приложение может использовать скроллеры Scroller
или OverScroller
— для сбора данных, необходимых для создания анимации прокрутки в ответ на событие касания. Они похожи, но OverScroller
также включает методы для указания пользователям, когда они достигают краев контента после жеста панорамирования или броска.
- Начиная с Android 12 (уровень API 31) визуальные элементы растягиваются и отскакивают назад при событии перетаскивания, а также подбрасываются и отскакивают назад при событии бросания.
- В Android 11 (уровень API 30) и более ранних версиях границы отображают эффект «свечения» после перетаскивания или броска к краю.
Пример InteractiveChart
в этом документе использует класс EdgeEffect
для отображения этих эффектов прокрутки.
Вы можете использовать скроллер для анимации прокрутки с течением времени, используя стандартную для платформы физику прокрутки, такую как трение, скорость и другие качества. Сам скроллер ничего не рисует. Скроллеры отслеживают смещения прокрутки для вас с течением времени, но они не применяют эти позиции автоматически к вашему виду. Вы должны получать и применять новые координаты со скоростью, которая делает анимацию прокрутки плавной.
Понимать терминологию прокрутки
Прокрутка — это слово, которое в Android может иметь разное значение в зависимости от контекста.
Прокрутка — это общий процесс перемещения области просмотра, то есть «окна» контента, на который вы смотрите. Когда прокрутка происходит по осям x и y , это называется панорамированием . Пример приложения InteractiveChart
в этом документе иллюстрирует два разных типа прокрутки: перетаскивание и бросание:
- Перетаскивание: это тип прокрутки, который происходит, когда пользователь проводит пальцем по сенсорному экрану. Вы можете реализовать перетаскивание, переопределив
onScroll()
вGestureDetector.OnGestureListener
. Для получения дополнительной информации о перетаскивании см. Перетаскивание и масштабирование . - Flinging: это тип прокрутки, который происходит, когда пользователь быстро перетаскивает и поднимает палец. После того, как пользователь поднимает палец, вы обычно хотите продолжать перемещать область просмотра, но замедляться до тех пор, пока область просмотра не перестанет двигаться. Вы можете реализовать flinging, переопределив
onFling()
вGestureDetector.OnGestureListener
и используя объект scroller. - Панорамирование: одновременная прокрутка по осям x и y называется панорамированием .
Обычно объекты scroller используются в сочетании с жестом рывка, но вы можете использовать их в любом контексте, где вы хотите, чтобы пользовательский интерфейс отображал прокрутку в ответ на событие касания. Например, вы можете переопределить onTouchEvent()
для непосредственной обработки событий касания и создания эффекта прокрутки или анимации «привязки к странице» в ответ на эти события касания.
Компоненты, содержащие встроенные реализации прокрутки
Следующие компоненты Android содержат встроенную поддержку прокрутки и прокрутки сверху:
-
GridView
-
HorizontalScrollView
-
ListView
-
NestedScrollView
-
RecyclerView
-
ScrollView
-
ViewPager
-
ViewPager2
Если вашему приложению необходимо поддерживать прокрутку и перепрокрутку внутри другого компонента, выполните следующие действия:
- Создайте собственную реализацию сенсорной прокрутки .
- Для поддержки устройств под управлением Android 12 и более поздних версий реализуйте эффект растягивания прокрутки .
Создайте собственную реализацию сенсорной прокрутки
В этом разделе описывается, как создать собственный скроллер, если ваше приложение использует компонент, который не содержит встроенной поддержки прокрутки и перепрокрутки.
Следующий фрагмент взят из примера InteractiveChart
. Он использует GestureDetector
и переопределяет метод GestureDetector.SimpleOnGestureListener
onFling()
. Он использует OverScroller
для отслеживания жеста броска. Если пользователь достигает краев контента после выполнения жеста броска, контейнер указывает, когда пользователь достигает конца контента. Индикация зависит от версии Android, на которой работает устройство:
- На Android 12 и более поздних версиях визуальные элементы растягиваются и возвращаются в исходное положение.
- В Android 11 и более ранних версиях визуальные элементы отображают эффект свечения.
Первая часть следующего фрагмента демонстрирует реализацию onFling()
:
Котлин
// 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) }
Ява
// 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); }
Когда onFling()
вызывает postInvalidateOnAnimation()
, он запускает computeScroll()
для обновления значений x и y . Обычно это делается, когда дочерний элемент представления анимирует прокрутку с помощью объекта scroller, как показано в предыдущем примере.
Большинство представлений передают позицию объекта прокрутки x и y непосредственно в scrollTo()
. Следующая реализация computeScroll()
использует другой подход: она вызывает computeScrollOffset()
для получения текущего положения x и y . Когда выполняются критерии для отображения эффекта «свечения» края прокрутки — то есть, отображение увеличено, x или y выходит за пределы, а приложение еще не показывает прокрутку — код устанавливает эффект свечения прокрутки и вызывает postInvalidateOnAnimation()
для инициирования недействительности представления.
Котлин
// 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 } ... } }
Ява
// 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; } ... }
Вот часть кода, которая выполняет фактическое масштабирование:
Котлин
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) }
Ява
// 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); }
Это метод computeScrollSurfaceSize()
, который вызывается в предыдущем фрагменте. Он вычисляет текущий размер прокручиваемой поверхности в пикселях. Например, если видна вся область диаграммы, это текущий размер mContentRect
. Если диаграмма увеличена на 200% в обоих направлениях, возвращаемый размер будет в два раза больше по горизонтали и вертикали.
Котлин
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() ) }
Ява
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())); }
Другой пример использования скроллера см. в исходном коде класса ViewPager
. Он прокручивается в ответ на броски и использует прокрутку для реализации анимации «привязки к странице».
Реализовать эффект растягивания прокрутки
Начиная с Android 12, EdgeEffect
добавляет следующие API для реализации эффекта растяжения прокрутки:
-
getDistance()
-
onPullDistance()
Чтобы обеспечить наилучший пользовательский опыт при использовании функции растягивания прокрутки, выполните следующие действия:
- Если анимация растяжения активна, когда пользователь касается содержимого, регистрируйте касание как «захват». Пользователь останавливает анимацию и снова начинает управлять растяжением.
- Когда пользователь перемещает палец в направлении, противоположном растягиванию, отпустите его до тех пор, пока оно полностью не исчезнет, а затем начните прокрутку.
- Когда пользователь делает бросок во время растяжки, бросьте
EdgeEffect
, чтобы усилить эффект растяжки.
Поймать анимацию
Когда пользователь ловит активную анимацию растяжения, EdgeEffect.getDistance()
возвращает 0
. Это условие указывает, что растяжение должно быть обработано сенсорным движением. В большинстве контейнеров ловля обнаруживается в onInterceptTouchEvent()
, как показано в следующем фрагменте кода:
Котлин
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 }
Ява
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { ... switch (action & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: ... isBeingDragged = EdgeEffectCompat.getDistance(edgeEffectBottom) > 0 || EdgeEffectCompat.getDistance(edgeEffectTop) > 0; ... } }
В предыдущем примере onInterceptTouchEvent()
возвращает true
, когда mIsBeingDragged
имеет true
, поэтому достаточно использовать событие до того, как дочерний элемент получит возможность его использовать.
Отпустите эффект прокрутки
Важно снять эффект растяжения перед прокруткой, чтобы предотвратить его применение к прокручиваемому контенту. Следующий пример кода применяет эту передовую практику:
Котлин
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); } ... }
Ява
@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); } ...
Когда пользователь перетаскивает, используйте расстояние перетаскивания EdgeEffect
, прежде чем передавать событие касания вложенному контейнеру прокрутки или перетаскивать прокрутку. В предыдущем примере кода getDistance()
возвращает положительное значение, когда отображается эффект края и может быть снят с движением. Когда событие касания снимает растяжение, оно сначала потребляется EdgeEffect
, чтобы полностью снять его до того, как будут отображены другие эффекты, такие как вложенная прокрутка. Вы можете использовать getDistance()
чтобы узнать, какое расстояние перетаскивания требуется для снятия текущего эффекта.
В отличие от onPull()
, onPullDistance()
возвращает потребленное количество переданной дельты. Начиная с Android 12, если onPull()
или onPullDistance()
передаются отрицательные значения deltaDistance
, когда getDistance()
равен 0
, эффект растяжения не меняется. В Android 11 и более ранних версиях onPull()
позволяет отрицательным значениям для общего расстояния показывать эффекты свечения.
Отказаться от прокрутки
Вы можете отключить прокрутку в файле макета или программно.
Чтобы отключить эту функцию в файле макета, установите android:overScrollMode
, как показано в следующем примере:
<MyCustomView android:overScrollMode="never"> ... </MyCustomView>
Чтобы отказаться программно, используйте следующий код:
Котлин
customView.overScrollMode = View.OVER_SCROLL_NEVER
Ява
customView.setOverScrollMode(View.OVER_SCROLL_NEVER);
Дополнительные ресурсы
См. следующие сопутствующие ресурсы: