В Android прокрутка обычно осуществляется с помощью класса ScrollView . Вложите любой стандартный макет, который может выходить за пределы своего контейнера, в ScrollView , чтобы обеспечить прокручиваемое представление, управляемое фреймворком. Реализация пользовательского скроллера необходима только в особых случаях. В этом документе описывается, как отображать эффект прокрутки в ответ на жесты касания с помощью скроллеров .
Ваше приложение может использовать скроллеры — Scroller или OverScroller — для сбора данных, необходимых для создания анимации прокрутки в ответ на событие касания. Они похожи, но OverScroller также включает методы для оповещения пользователей о достижении ими краев контента после жеста панорамирования или прокрутки.
- Начиная с Android 12 (уровень API 31), визуальные элементы растягиваются и отскакивают при перетаскивании, а также отскакивают и отскакивают при отскоке.
- На Android 11 (уровень API 30) и более ранних версиях границы отображаются с эффектом «свечения» после перетаскивания или броска к краю.
В примере InteractiveChart , представленном в этом документе, для отображения эффектов прокрутки поверх других объектов используется класс EdgeEffect .
Для анимации прокрутки во времени можно использовать скроллер, применяя стандартные для платформы физические параметры прокрутки, такие как трение, скорость и другие характеристики. Сам скроллер ничего не рисует. Скроллеры отслеживают смещения прокрутки во времени, но не применяют эти позиции автоматически к вашему представлению. Вам необходимо получать и применять новые координаты с такой скоростью, чтобы анимация прокрутки выглядела плавной.
Разберитесь в терминологии, связанной с прокруткой.
Слово «прокрутка» в Android может означать разные вещи в зависимости от контекста.
Прокрутка — это общий процесс перемещения области просмотра, то есть «окна» с содержимым, которое вы просматриваете. Когда прокрутка осуществляется как по оси X , так и по оси Y , это называется панорамированием . Пример приложения InteractiveChart в этом документе иллюстрирует два разных типа прокрутки: перетаскивание и перемещение:
- Перетаскивание: это тип прокрутки, который происходит, когда пользователь проводит пальцем по сенсорному экрану. Вы можете реализовать перетаскивание, переопределив
onScroll()вGestureDetector.OnGestureListener. Для получения дополнительной информации о перетаскивании см. раздел «Перетаскивание и масштабирование» . - «Прокрутка с отрыванием»: это тип прокрутки, который происходит, когда пользователь быстро перетаскивает палец и отпускает его. После того, как пользователь отпустит палец, обычно желательно продолжать движение области просмотра, но с замедлением до тех пор, пока область просмотра не остановится. «Прокрутку с отрыванием» можно реализовать, переопределив
onFling()вGestureDetector.OnGestureListenerи используя объект скроллера. - Панорамирование: одновременная прокрутка вдоль осей x и y называется панорамированием .
Обычно объекты скроллера используются в сочетании с жестом «прокрутки», но вы можете использовать их в любом контексте, где вам нужно, чтобы пользовательский интерфейс отображал прокрутку в ответ на событие касания. Например, вы можете переопределить onTouchEvent() для прямой обработки событий касания и создания эффекта прокрутки или анимации «привязки к странице» в ответ на эти события касания.
Компоненты, содержащие встроенные реализации прокрутки.
Следующие компоненты Android содержат встроенную поддержку прокрутки и перепрокрутки:
-
GridView -
HorizontalScrollView -
ListView -
NestedScrollView -
RecyclerView -
ScrollView -
ViewPager -
ViewPager2
Если вашему приложению необходима поддержка прокрутки и перепрокрутки внутри другого компонента, выполните следующие шаги:
- Создайте собственную реализацию прокрутки на основе сенсорного управления .
- Для поддержки устройств под управлением Android 12 и более поздних версий реализуйте эффект растягивания при прокрутке .
Создайте собственную реализацию прокрутки на основе сенсорного управления.
В этом разделе описывается, как создать собственный скроллер, если ваше приложение использует компонент, не имеющий встроенной поддержки прокрутки и перепрокрутки.
Следующий фрагмент кода взят из примера InteractiveChart . Он использует GestureDetector и переопределяет метод onFling() класса GestureDetector.SimpleOnGestureListener . Для отслеживания жеста прокрутки используется 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) }
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 . Обычно это делается, когда дочерний элемент представления анимирует прокрутку с помощью объекта скроллера, как показано в предыдущем примере.
В большинстве случаев координаты x и y объекта скроллера передаются непосредственно в scrollTo() . Следующая реализация метода computeScroll() использует другой подход: она вызывает computeScrollOffset() для получения текущего положения x и y . Когда выполняются критерии для отображения эффекта «свечения» при перепрокрутке — то есть, изображение увеличено, координаты x или y выходят за пределы допустимого диапазона, и приложение еще не отображает эффект перепрокрутки — код настраивает эффект «свечения» при перепрокрутке и вызывает postInvalidateOnAnimation() для запуска операции invalidate для представления.
Котлин
// 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; } ... }
Вот фрагмент кода, отвечающий за фактическое масштабирование:
Котлин
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% в обоих направлениях, возвращаемый размер будет в два раза больше по горизонтали и вертикали.
Котлин
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() , как показано в следующем фрагменте кода:
Котлин
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 , поэтому достаточно обработать событие до того, как дочерний элемент получит возможность его обработать.
Отключите эффект перепрокрутки
Важно снять эффект растяжения перед прокруткой, чтобы предотвратить его применение к прокручиваемому содержимому. В следующем примере кода демонстрируется эта рекомендация:
Котлин
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, и может быть снят при движении. Когда событие касания снимает растяжение, оно сначала используется эффектом 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
Java
customView.setOverScrollMode(View.OVER_SCROLL_NEVER);
Дополнительные ресурсы
Обратитесь к следующим соответствующим ресурсам:
