В Android прокрутка обычно реализуется с помощью класса ScrollView. Вложите любой стандартный макет, который может выходить за границы своего контейнера, в ScrollView, чтобы обеспечить прокручиваемый вид, управляемый фреймворком. Реализация пользовательского скроллера необходима только для специальных сценариев. В этом документе описывается, как с помощью скроллеров показывать эффект прокрутки в ответ на жесты касания.
Для сбора данных, необходимых для создания анимации прокрутки в ответ на событие касания, в приложении можно использовать скроллеры Scroller или OverScroller. Они похожи, но OverScroller также включает методы, позволяющие сообщать пользователям, когда они достигают краев контента после жеста панорамирования или пролистывания.
- Начиная с Android 12 (уровень API 31), визуальные элементы растягиваются и возвращаются в исходное положение при перетаскивании, а также отскакивают и возвращаются в исходное положение при пролистывании.
- В Android 11 (уровень API 30) и более ранних версиях границы подсвечиваются после перетаскивания или пролистывания до края.
В примере InteractiveChart в этом документе используется класс EdgeEffect, чтобы показать эти эффекты достижения конца прокрутки.
Вы можете использовать скроллер для анимации прокрутки с течением времени, используя стандартную физику прокрутки платформы, такую как трение, скорость и другие качества. Сам скроллер ничего не рисует. Scroller отслеживает смещение прокрутки, но не применяет эти позиции к представлению автоматически. Вы должны получать и применять новые координаты с такой частотой, чтобы анимация прокрутки выглядела плавной.
Терминология прокрутки
Прокрутка – это слово, которое в Android может означать разные вещи в зависимости от контекста.
Прокрутка – это перемещение области просмотра, то есть "окна" с контентом, который вы видите. Прокрутка по осям X и Y называется панорамированием. В примере приложения InteractiveChart, который рассматривается в этой статье, показаны два разных типа прокрутки, перетаскивания и пролистывания:
- Перетаскивание. Этот тип прокрутки происходит, когда пользователь проводит пальцем по сенсорному экрану. Чтобы реализовать перетаскивание, переопределите метод
onScroll()в классеGestureDetector.OnGestureListener. Подробнее о перетаскивании и масштабировании… - Быстрая прокрутка. Происходит, когда пользователь быстро проводит пальцем по экрану и убирает его. После того как пользователь уберет палец, область просмотра должна продолжить движение, но с замедлением, пока не остановится. Чтобы реализовать прокрутку, переопределите метод
onFling()в классеGestureDetector.OnGestureListenerи используйте объект Scroller. - Панорамирование. Одновременная прокрутка по осям X и Y называется панорамированием.
Обычно объекты прокрутки используются в сочетании с жестом пролистывания, но вы можете использовать их в любом контексте, когда хотите, чтобы пользовательский интерфейс отображал прокрутку в ответ на событие касания. Например, вы можете переопределить onTouchEvent(), чтобы обрабатывать события касания напрямую и создавать эффект прокрутки или анимацию привязки к странице в ответ на эти события.
Компоненты, содержащие встроенные реализации прокрутки
Встроенная поддержка прокрутки и выхода за границы прокрутки есть в следующих компонентах Android:
GridViewHorizontalScrollViewListViewNestedScrollViewRecyclerViewScrollViewViewPagerViewPager2
Если вашему приложению необходимо поддерживать прокрутку и чрезмерную прокрутку внутри другого компонента, выполните следующие действия:
- Создайте собственную реализацию прокрутки с помощью сенсорного экрана.
- Чтобы обеспечить поддержку устройств с Android 12 и более поздними версиями, реализуйте эффект растягивания при прокрутке.
Создайте собственную реализацию прокрутки на основе касаний
В этом разделе описывается, как создать собственный скроллер, если в вашем приложении используется компонент, который не содержит встроенной поддержки прокрутки и оверскроллинга.
Приведенный ниже фрагмент кода взят из примера InteractiveChart. Он использует GestureDetector и переопределяет метод onFling() класса GestureDetector.SimpleOnGestureListener. Для отслеживания жеста пролистывания используется OverScroller. Если пользователь достигает краев контента после выполнения жеста пролистывания, контейнер указывает, когда пользователь достигает конца контента. Индикация зависит от версии Android, установленной на устройстве:
- В Android 12 и более поздних версиях визуальные элементы растягиваются и возвращаются в исходное состояние.
- В Android 11 и более ранних версиях визуальные элементы светятся.
В первой части фрагмента кода ниже показана реализация 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); }
Когда onFling() вызывает postInvalidateOnAnimation(), запускается computeScroll(), чтобы обновить значения x и y. Обычно это делается, когда дочерний элемент View анимирует прокрутку с помощью объекта Scroller, как показано в предыдущем примере.
Большинство представлений передают позиции x и y объекта прокрутки непосредственно в scrollTo().
В следующем примере computeScroll() используется другой подход: вызывается computeScrollOffset(), чтобы получить текущее местоположение x и y. Когда выполняются условия для отображения эффекта свечения при прокрутке за пределы экрана (то есть масштаб увеличен, x или y находится за пределами границ, а приложение ещё не показывает прокрутку за пределы экрана), код настраивает эффект свечения и вызывает postInvalidateOnAnimation(), чтобы запустить недействительность в представлении.
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; } ... }
Вот раздел кода, который выполняет фактическое масштабирование:
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); }
Это метод computeScrollSurfaceSize(), который вызывается в предыдущем фрагменте кода. Она вычисляет текущий размер прокручиваемой области в пикселях. Например, если видна вся область диаграммы, то это текущий размер mContentRect. Если диаграмма увеличена на 200% в обоих направлениях, возвращаемый размер будет в два раза больше по горизонтали и вертикали.
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())); }
Другой пример использования прокрутки можно найти в исходном коде класса ViewPager. Он прокручивается в ответ на броски и использует прокрутку для реализации анимации привязки к странице.
Реализуйте эффект растягивания при достижении конца прокрутки
Начиная с Android 12, в EdgeEffect добавлены следующие API для реализации эффекта растяжения при прокрутке за край:
getDistance()onPullDistance()
Чтобы обеспечить наилучшее удобство использования при достижении конца прокрутки, сделайте следующее:
- Если пользователь касается контента во время анимации растягивания, зарегистрируйте касание как "захват". Пользователь останавливает анимацию и снова начинает растягивать изображение.
- Когда пользователь перемещает палец в направлении, противоположном растяжению, отпустите растяжение, пока оно полностью не исчезнет, а затем начните прокрутку.
- Если пользователь выполняет жест пролистывания во время растягивания, пролистайте
EdgeEffect, чтобы усилить эффект растягивания.
Поймайте анимацию
Когда пользователь прерывает активную анимацию растягивания, EdgeEffect.getDistance() возвращает 0. Это условие указывает, что растяжение должно управляться движением касания. В большинстве контейнеров перехват обнаруживается в onInterceptTouchEvent(), как показано в следующем фрагменте кода:
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; ... } }
В примере выше onInterceptTouchEvent() возвращает true, когда mIsBeingDragged равен true, поэтому достаточно обработать событие до того, как дочерний элемент сможет его обработать.
Отключение эффекта достижения конца прокрутки
Важно отменить эффект растягивания до прокрутки, чтобы он не применялся к прокручиваемому контенту. В примере кода ниже показано, как это сделать.
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); } ...
Когда пользователь перетаскивает элемент, используйте расстояние перетаскивания EdgeEffect, прежде чем передавать событие касания вложенному контейнеру прокрутки или перетаскивать полосу прокрутки. В приведенном выше примере кода getDistance() возвращает положительное значение, когда отображается краевой эффект и его можно убрать с помощью движения. Когда событие касания отпускает растяжение, оно сначала используется EdgeEffect, чтобы полностью освободиться перед отображением других эффектов, таких как вложенная прокрутка. С помощью getDistance() можно узнать, на какое расстояние нужно потянуть, чтобы запустить текущий эффект.
В отличие от onPull(), onPullDistance() возвращает использованное количество переданной дельты. Начиная с Android 12, если в onPull() или onPullDistance() передаются отрицательные значения deltaDistance, когда getDistance() – это 0, эффект растягивания не меняется. На устройствах с Android 11 и более ранними версиями ОС onPull() позволяет использовать отрицательные значения для общего расстояния, чтобы показывать эффекты свечения.
Отключить эффект достижения конца прокрутки
Вы можете отказаться от эффекта достижения конца прокрутки в файле макета или программно.
Чтобы отключить эту функцию в файле макета, задайте для android:overScrollMode значение, как показано в примере ниже.
<MyCustomView android:overScrollMode="never"> ... </MyCustomView>
Чтобы отказаться от этой функции программно, используйте код, подобный приведенному ниже.
Kotlin
customView.overScrollMode = View.OVER_SCROLL_NEVER
Java
customView.setOverScrollMode(View.OVER_SCROLL_NEVER);
Дополнительные ресурсы
Ознакомьтесь со следующей информацией по теме: