以动画方式显示滚动手势

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

您可以使用滚动条(ScrollerOverScroller)收集响应轻触事件来生成滚动动画所需的数据。二者很相似,但 OverScroller 包含相应方法来向用户指示其在执行平移或滑动手势后已到达内容边缘。InteractiveChart 示例使用 EdgeEffect 类(实际上是 EdgeEffectCompat 类)在用户到达内容边缘时显示“发光”效果。

注意:我们建议您使用 OverScroller(而不是 Scroller)实现滚动动画。OverScroller 可针对旧设备提供最佳的向后兼容性。
另请注意,您通常只有在亲自实现滚动时才需要使用滚动条。如果您将布局嵌入 ScrollViewHorizontalScrollView,它们会为您完成所有这些操作。

滚动条用于使用平台标准滚动物理特性(摩擦力、速度等)对一段时间内的滚动设置动画效果。滚动条本身实际上并不会绘制任何内容。滚动条会为您跟踪一段时间内的滚动偏移量,但它们不会自动将这些位置应用于视图。您将负责以让滚动动画顺畅显示的速度获取和应用新坐标。

请参阅以下相关资源:

了解滚动术语

“滚动”一词在 Android 中可能具有不同的含义,具体取决于上下文。

滚动是移动视口(即您正在查看的内容“窗口”)的一般过程。如果同时在 x 轴和 y 轴上滚动,称为“平移”。这节课中提供的示例应用 InteractiveChart 展示了两种不同的滚动,即拖动和滑动:

  • 拖动是指用户在触摸屏上拖动手指时发生的一种滚动。简单的拖动通常是通过替换 GestureDetector.OnGestureListener 中的 onScroll() 实现的。有关拖动的详细讨论,请参阅拖动和缩放
  • 滑动是用户快速拖动并抬起手指时发生的一种滚动。在用户抬起手指后,您通常需要继续滚动(移动视口),但要减速,直到视口停止移动为止。滑动可以通过替换 GestureDetector.OnGestureListener 中的 onFling() 以及使用滚动条对象来实现。此用例是这节课的主题。

滚动条对象通常与滑动手势结合使用,但它们几乎可以在您想让界面显示滚动以响应轻触事件的任何上下文中使用。例如,您可以替换 onTouchEvent() 以直接处理轻触事件,并生成滚动效果或“对准页面”动画来响应相关轻触事件。

实现基于轻触的滚动

本部分将介绍如何使用滚动条。下面显示的代码段来自这节课中提供的 InteractiveChart 示例。它使用的是 GestureDetector,并替换了 GestureDetector.SimpleOnGestureListener 方法 onFling()。它使用 OverScroller 跟踪滑动手势。如果用户在执行滑动手势后到达内容边缘,应用会显示“发光”效果。

注意InteractiveChart 示例应用会显示一个图表,您可以对此图表执行缩放、平移、滚动等操作。在以下代码段中,mContentRect 表示视图内要用于绘制图表的矩形坐标区域。在任何给定时间,总图表的一部分都在绘制到此矩形区域中。mCurrentViewport 表示当前在屏幕中显示的图表部分。由于像素偏移通常被视为整数,因此 mContentRect 的类型为 Rect。由于图表的区域和范围是小数值/浮点值,因此 mCurrentViewport 的类型为 RectF

代码段的第一部分展示了 onFling() 的实现:

Kotlin

    // The current viewport. This rectangle represents the currently visible
    // chart domain and range. The viewport is the part of the app that the
    // user manipulates via touch gestures.
    private val mCurrentViewport = 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 should be drawn.
    private lateinit var mContentRect: Rect

    private lateinit var mScroller: OverScroller
    private lateinit var mScrollerStartViewport: RectF
    ...
    private val mGestureListener = object : GestureDetector.SimpleOnGestureListener() {

        override fun onDown(e: MotionEvent): Boolean {
            // Initiates the decay phase of any active edge effects.
            releaseEdgeEffects()
            mScrollerStartViewport.set(mCurrentViewport)
            // Aborts any active scroll animations and invalidates.
            mScroller.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.
        releaseEdgeEffects()
        // Flings use math in pixels (as opposed to math based on the viewport).
        val surfaceSize: Point = computeScrollSurfaceSize()
        val (startX: Int, startY: Int) = mScrollerStartViewport.run {
            set(mCurrentViewport)
            (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, aborts the current animation.
        mScroller.forceFinished(true)
        // Begins the animation
        mScroller.fling(
                // Current scroll position
                startX,
                startY,
                velocityX,
                velocityY,
                /*
                 * Minimum and maximum scroll positions. The minimum scroll
                 * position is generally zero 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 should be 800 pixels.
                 */
                0, surfaceSize.x - mContentRect.width(),
                0, surfaceSize.y - mContentRect.height(),
                // The edges of the content. This comes into play when using
                // the EdgeEffect class to draw "glow" overlays.
                mContentRect.width() / 2,
                mContentRect.height() / 2
        )
        // Invalidates to trigger computeScroll()
        ViewCompat.postInvalidateOnAnimation(this)
    }
    

Java

    // The current viewport. This rectangle represents the currently visible
    // chart domain and range. The viewport is the part of the app that the
    // user manipulates via touch gestures.
    private RectF mCurrentViewport =
            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 should be drawn.
    private Rect mContentRect;

    private OverScroller mScroller;
    private RectF mScrollerStartViewport;
    ...
    private final GestureDetector.SimpleOnGestureListener mGestureListener
            = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onDown(MotionEvent e) {
            // Initiates the decay phase of any active edge effects.
            releaseEdgeEffects();
            mScrollerStartViewport.set(mCurrentViewport);
            // Aborts any active scroll animations and invalidates.
            mScroller.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.
        releaseEdgeEffects();
        // Flings use math in pixels (as opposed to math based on the viewport).
        Point surfaceSize = computeScrollSurfaceSize();
        mScrollerStartViewport.set(mCurrentViewport);
        int startX = (int) (surfaceSize.x * (mScrollerStartViewport.left -
                AXIS_X_MIN) / (
                AXIS_X_MAX - AXIS_X_MIN));
        int startY = (int) (surfaceSize.y * (AXIS_Y_MAX -
                mScrollerStartViewport.bottom) / (
                AXIS_Y_MAX - AXIS_Y_MIN));
        // Before flinging, aborts the current animation.
        mScroller.forceFinished(true);
        // Begins the animation
        mScroller.fling(
                // Current scroll position
                startX,
                startY,
                velocityX,
                velocityY,
                /*
                 * Minimum and maximum scroll positions. The minimum scroll
                 * position is generally zero 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 should be 800 pixels.
                 */
                0, surfaceSize.x - mContentRect.width(),
                0, surfaceSize.y - mContentRect.height(),
                // The edges of the content. This comes into play when using
                // the EdgeEffect class to draw "glow" overlays.
                mContentRect.width() / 2,
                mContentRect.height() / 2);
        // Invalidates to trigger computeScroll()
        ViewCompat.postInvalidateOnAnimation(this);
    }
    

onFling() 调用 postInvalidateOnAnimation() 时,它会触发 computeScroll() 以更新 x 和 y 的值。这通常是在子视图使用滚动条对象为滚动设置动画效果时完成的,如此示例中所示。

大多数视图会将滚动条对象的 x 和 y 位置直接传递给 scrollTo()computeScroll() 的以下实现采用了一种不同的方法,即调用 computeScrollOffset() 以获取当前的 x 和 y 位置。当满足显示滚动“发光”边缘效果的条件时(显示屏放大,x 或 y 超出边框,并且应用尚未显示滚动效果),代码会设置滚动发光效果并调用 postInvalidateOnAnimation() 来对视图触发作废机制:

Kotlin

    // Edge effect / overscroll tracking objects.
    private lateinit var mEdgeEffectTop: EdgeEffect
    private lateinit var mEdgeEffectBottom: EdgeEffect
    private lateinit var mEdgeEffectLeft: EdgeEffect
    private lateinit var mEdgeEffectRight: EdgeEffect

    private var mEdgeEffectTopActive: Boolean = false
    private var mEdgeEffectBottomActive: Boolean = false
    private var mEdgeEffectLeftActive: Boolean = false
    private var mEdgeEffectRightActive: Boolean = false

    override fun computeScroll() {
        super.computeScroll()

        var needsInvalidate = false

        // The scroller isn't finished, meaning a fling or programmatic pan
        // operation is currently active.
        if (mScroller.computeScrollOffset()) {
            val surfaceSize: Point = computeScrollSurfaceSize()
            val currX: Int = mScroller.currX
            val currY: Int = mScroller.currY

            val (canScrollX: Boolean, canScrollY: Boolean) = mCurrentViewport.run {
                (left > AXIS_X_MIN || right < AXIS_X_MAX) to (top > AXIS_Y_MIN || bottom < AXIS_Y_MAX)
            }

            /*
             * If you are zoomed in and currX or currY is
             * outside of bounds and you are not already
             * showing overscroll, then render the overscroll
             * glow edge effect.
             */
            if (canScrollX
                    && currX < 0
                    && mEdgeEffectLeft.isFinished
                    && !mEdgeEffectLeftActive) {
                mEdgeEffectLeft.onAbsorb(mScroller.currVelocity.toInt())
                mEdgeEffectLeftActive = true
                needsInvalidate = true
            } else if (canScrollX
                    && currX > surfaceSize.x - mContentRect.width()
                    && mEdgeEffectRight.isFinished
                    && !mEdgeEffectRightActive) {
                mEdgeEffectRight.onAbsorb(mScroller.currVelocity.toInt())
                mEdgeEffectRightActive = true
                needsInvalidate = true
            }

            if (canScrollY
                    && currY < 0
                    && mEdgeEffectTop.isFinished
                    && !mEdgeEffectTopActive) {
                mEdgeEffectTop.onAbsorb(mScroller.currVelocity.toInt())
                mEdgeEffectTopActive = true
                needsInvalidate = true
            } else if (canScrollY
                    && currY > surfaceSize.y - mContentRect.height()
                    && mEdgeEffectBottom.isFinished
                    && !mEdgeEffectBottomActive) {
                mEdgeEffectBottom.onAbsorb(mScroller.currVelocity.toInt())
                mEdgeEffectBottomActive = true
                needsInvalidate = true
            }
            ...
        }
    }
    

Java

    // Edge effect / overscroll tracking objects.
    private EdgeEffectCompat mEdgeEffectTop;
    private EdgeEffectCompat mEdgeEffectBottom;
    private EdgeEffectCompat mEdgeEffectLeft;
    private EdgeEffectCompat mEdgeEffectRight;

    private boolean mEdgeEffectTopActive;
    private boolean mEdgeEffectBottomActive;
    private boolean mEdgeEffectLeftActive;
    private boolean mEdgeEffectRightActive;

    @Override
    public void computeScroll() {
        super.computeScroll();

        boolean needsInvalidate = false;

        // The scroller isn't finished, meaning a fling or programmatic pan
        // operation is currently active.
        if (mScroller.computeScrollOffset()) {
            Point surfaceSize = computeScrollSurfaceSize();
            int currX = mScroller.getCurrX();
            int currY = mScroller.getCurrY();

            boolean canScrollX = (mCurrentViewport.left > AXIS_X_MIN
                    || mCurrentViewport.right < AXIS_X_MAX);
            boolean canScrollY = (mCurrentViewport.top > AXIS_Y_MIN
                    || mCurrentViewport.bottom < AXIS_Y_MAX);

            /*
             * If you are zoomed in and currX or currY is
             * outside of bounds and you are not already
             * showing overscroll, then render the overscroll
             * glow edge effect.
             */
            if (canScrollX
                    && currX < 0
                    && mEdgeEffectLeft.isFinished()
                    && !mEdgeEffectLeftActive) {
                mEdgeEffectLeft.onAbsorb((int)mScroller.getCurrVelocity());
                mEdgeEffectLeftActive = true;
                needsInvalidate = true;
            } else if (canScrollX
                    && currX > (surfaceSize.x - mContentRect.width())
                    && mEdgeEffectRight.isFinished()
                    && !mEdgeEffectRightActive) {
                mEdgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity());
                mEdgeEffectRightActive = true;
                needsInvalidate = true;
            }

            if (canScrollY
                    && currY < 0
                    && mEdgeEffectTop.isFinished()
                    && !mEdgeEffectTopActive) {
                mEdgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity());
                mEdgeEffectTopActive = true;
                needsInvalidate = true;
            } else if (canScrollY
                    && currY > (surfaceSize.y - mContentRect.height())
                    && mEdgeEffectBottom.isFinished()
                    && !mEdgeEffectBottomActive) {
                mEdgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity());
                mEdgeEffectBottomActive = true;
                needsInvalidate = true;
            }
            ...
        }
    

以下是执行实际缩放的代码部分:

Kotlin

    lateinit var mZoomer: Zoomer
    val mZoomFocalPoint = PointF()
    ...

    // If a zoom is in progress (either programmatically or via double
    // touch), performs the zoom.
    if (mZoomer.computeZoom()) {
        val newWidth: Float = (1f - mZoomer.currZoom) * mScrollerStartViewport.width()
        val newHeight: Float = (1f - mZoomer.currZoom) * mScrollerStartViewport.height()
        val pointWithinViewportX: Float =
                (mZoomFocalPoint.x - mScrollerStartViewport.left) / mScrollerStartViewport.width()
        val pointWithinViewportY: Float =
                (mZoomFocalPoint.y - mScrollerStartViewport.top) / mScrollerStartViewport.height()
        mCurrentViewport.set(
                mZoomFocalPoint.x - newWidth * pointWithinViewportX,
                mZoomFocalPoint.y - newHeight * pointWithinViewportY,
                mZoomFocalPoint.x + newWidth * (1 - pointWithinViewportX),
                mZoomFocalPoint.y + newHeight * (1 - pointWithinViewportY)
        )
        constrainViewport()
        needsInvalidate = true
    }
    if (needsInvalidate) {
        ViewCompat.postInvalidateOnAnimation(this)
    }
    

Java

    // Custom object that is functionally similar to Scroller
    Zoomer mZoomer;
    private PointF mZoomFocalPoint = new PointF();
    ...

    // If a zoom is in progress (either programmatically or via double
    // touch), performs the zoom.
    if (mZoomer.computeZoom()) {
        float newWidth = (1f - mZoomer.getCurrZoom()) *
                mScrollerStartViewport.width();
        float newHeight = (1f - mZoomer.getCurrZoom()) *
                mScrollerStartViewport.height();
        float pointWithinViewportX = (mZoomFocalPoint.x -
                mScrollerStartViewport.left)
                / mScrollerStartViewport.width();
        float pointWithinViewportY = (mZoomFocalPoint.y -
                mScrollerStartViewport.top)
                / mScrollerStartViewport.height();
        mCurrentViewport.set(
                mZoomFocalPoint.x - newWidth * pointWithinViewportX,
                mZoomFocalPoint.y - newHeight * pointWithinViewportY,
                mZoomFocalPoint.x + newWidth * (1 - pointWithinViewportX),
                mZoomFocalPoint.y + newHeight * (1 - pointWithinViewportY));
        constrainViewport();
        needsInvalidate = true;
    }
    if (needsInvalidate) {
        ViewCompat.postInvalidateOnAnimation(this);
    }
    

这是在以上代码段中调用的 computeScrollSurfaceSize() 方法。此方法可以计算当前的可滚动 surface 大小(以像素为单位)。例如,如果整个图表区域都是可见的,该大小就是 mContentRect 的当前大小。如果图表在两个方向上均放大到 200%,则返回的大小在横向和纵向上均为原来的两倍。

Kotlin

    private fun computeScrollSurfaceSize(): Point {
        return Point(
                (mContentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / mCurrentViewport.width()).toInt(),
                (mContentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / mCurrentViewport.height()).toInt()
        )
    }
    

Java

    private Point computeScrollSurfaceSize() {
        return new Point(
                (int) (mContentRect.width() * (AXIS_X_MAX - AXIS_X_MIN)
                        / mCurrentViewport.width()),
                (int) (mContentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN)
                        / mCurrentViewport.height()));
    }
    

如需查看滚动条用法的其他示例,请参阅 ViewPager 类的源代码。此代码会通过滚动来响应滑动,并使用滚动来实现“对准页面”动画。