以动画方式显示滚动手势

尝试使用 Compose 方式
Jetpack Compose 是推荐用于构建 Android 界面的工具包。了解如何在 Compose 中使用触控和输入。

在 Android 中,滚动通常通过 ScrollView 类来实现。将任何可能超出其 容器边框的标准布局嵌套在 ScrollView 中,以提供由 框架管理的可滚动视图。只有在特殊 情况下才需要实现自定义滚动条。本文档介绍了如何使用滚动条显示响应 轻触手势的滚动效果。

您的应用可以使用 滚动条(ScrollerOverScroller)收集响应轻触 事件来生成滚动动画所需的数据。二者很相似,但 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()的实现:

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() 以更新 xy 的值。这通常是在子视图使用滚动条对象为滚动设置动画效果时完成的,如前面的示例所示。

大多数视图会将滚动条对象的 xy 位置直接 传递给 scrollTo(). computeScroll() 的以下实现采用了一种不同的 方法,即调用 computeScrollOffset() 以获取当前的 xy 位置。当满足显示滚动“发光”边缘效果的条件时(显示屏放大,xy 超出边框,并且应用尚未显示滚动效果),代码会设置滚动发光效果并调用 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() 方法。此方法可以计算当前的可滚动 surface 大小(以 像素为单位)。例如,如果整个图表区域都是可见的,该大小就是当前 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()

如需提供最佳的拉伸滚动用户体验,请执行以下操作:

  1. 当用户轻触内容时,如果拉伸动画正在生效,请将轻触注册为“捕捉”。用户停止动画并 再次开始操控拉伸。
  2. 当用户沿拉伸的相反方向移动手指时, 释放拉伸,直到其完全消失,然后开始滚动。
  3. 当用户在拉伸过程中快速滑动时,快速滑动 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,当 mIsBeingDraggedtrue 时,因此 这对于在子级有机会 消耗事件之前消耗事件已经足够了。

释放滚动效果

务必在滚动之前释放拉伸效果,以防止 将拉伸应用于滚动内容。以下代码 示例应用了此最佳实践:

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);

其他资源

请参阅以下相关资源: