Анимация жеста прокрутки

Попробуйте способ создания
Jetpack Compose — рекомендуемый набор инструментов пользовательского интерфейса для Android. Узнайте, как использовать сенсорный ввод и ввод в Compose.

В 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 содержат встроенную поддержку прокрутки и чрезмерной прокрутки:

Если ваше приложение должно поддерживать прокрутку и чрезмерную прокрутку внутри другого компонента, выполните следующие шаги:

  1. Создайте собственную реализацию сенсорной прокрутки .
  2. Для поддержки устройств под управлением 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 . Обычно это делается, когда дочерний элемент представления анимирует прокрутку с помощью объекта прокрутки, как показано в предыдущем примере.

Большинство представлений передают положение 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()

Чтобы обеспечить максимальное удобство использования растянутой прокрутки, выполните следующие действия:

  1. Когда анимация растяжения действует, когда пользователь касается содержимого, зарегистрируйте касание как «захват». Пользователь останавливает анимацию и снова начинает манипулировать растяжением.
  2. Когда пользователь переместит палец в направлении, противоположном растягиванию, отпустите его до тех пор, пока оно не исчезнет полностью, а затем начните прокрутку.
  3. Когда пользователь бросается во время растягивания, используйте 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);

Дополнительные ресурсы

Обратитесь к следующим соответствующим ресурсам: