Android Dev Summit, October 23-24: two days of technical content, directly from the Android team. Sign-up for livestream updates.

スクロール操作のアニメーション化

Android では、スクロールは通常、ScrollView クラスを使用して実現します。コンテナの境界を超えて拡張される可能性のある標準レイアウトは、フレームワークで管理されるスクロール可能なビューを提供するために、ScrollView にネストする必要があります。カスタム スクローラーの実装は、特別なシナリオでのみ必要です。このレッスンではそのようなシナリオとして、スクローラーでのタッチ操作に応じたスクロール効果の表示について説明します。

スクローラー(Scroller または OverScroller)を使用して、タッチイベントに応じてスクロールするアニメーションを生成するために必要なデータを収集できます。これらは似ていますが、OverScroller には、パン操作またはフリング操作の後にコンテンツの端に到達したことをユーザーに示すメソッドが含まれています。InteractiveChart サンプルは、EdgeEffect クラス(実際は EdgeEffectCompat クラス)を使用して、ユーザーがコンテンツの端に到達したときに「グロー」効果を表示します。

注: アニメーションのスクロールには、Scroller ではなく OverScroller を使用することをおすすめします。 OverScroller は、旧式デバイスとの後方互換性に優れています。
自分でスクロールを実装するときは通常、スクローラーを使用するだけでよいことにもご注意ください。ScrollViewHorizontalScrollView の中にレイアウトをネストすると、これがすべて行われます。

スクローラーは、プラット フォーム標準のスクロール物理(摩擦、速度など)を使用して、スクロールを経時的にアニメーション化するために使用します。スクローラー自体は、実際は何も描画しません。スクローラーはスクロール オフセットを経時的にトラックしますが、位置が自動的にビューに適用されるわけではありません。スクロール アニメーションがスムーズに見える速度で新しい座標を取得して適用するのは、各自の責任です。

以下の関連リソースもご覧ください。

スクロールの用語について

「スクロール」は、文脈によって Android でさまざまな意味を持つことがある単語です。

スクロールは、ビューポート(つまり、見ているコンテンツの「ウィンドウ」)を移動する一般的なプロセスです。スクロールが x 軸と y 軸の両方で行われる場合は、パンといいます。このクラスで提供されているサンプル アプリケーション InteractiveChart は、2 種類のスクロール、ドラッグ、フリングを示しています。

  • ドラッグは、ユーザーがタッチ スクリーン上で指をドラッグしたときに発生するスクロールの一種です。多くの場合、単純なドラッグは GestureDetector.OnGestureListeneronScroll() をオーバーライドすることで実装されます。ドラッグの詳細については、ドラッグとスケーリングをご覧ください。
  • フリングは、ユーザーが指をすばやくドラッグして離したときに発生するスクロールの一種です。ユーザーが指を離した後、通常はスクロール(ビューポートの移動)が続けますが、ビューポートの移動が停止するまで減速します。フリングは、GestureDetector.OnGestureListeneronFling() をオーバーライドすることで、またスクローラー オブジェクトを使用することで実装できます。これが、このレッスンのトピックのユースケースです。

スクローラー オブジェクトをフリング操作と組み合わせて使用することは一般的ですが、タッチイベントに応じて UI にスクロールを表示する場合、ほとんどすべてのコンテキストで使用できます。たとえば、onTouchEvent() をオーバーライドしてタッチイベントを直接処理し、これらのタッチイベントに応じてスクロール効果または「ページにスナップする」アニメーションを生成できます。

タッチベースのスクロールの実装

このセクションでは、スクローラーの使用方法について説明します。下記のスニペットは、このクラスで提供される InteractiveChart サンプルからのものです。GestureDetector を使用し、GestureDetector.SimpleOnGestureListener メソッドの onFling() をオーバーライドします。OverScroller を使用し、フリング操作をトラックします。ユーザーがフリング操作の後でコンテンツの端に到達すると、アプリで「グロー」効果が表示されます。

注: InteractiveChart サンプルアプリは、ズーム、パン、スクロールなどが可能なグラフを表示します。次のスニペットで、mContentRect はグラフを描画するビュー内の長方形の座標を表します。いつでも、グラフドメインと範囲の全体のサブセットが、この長方形の領域に描画されます。 mCurrentViewport は、画面に現在表示されているグラフの部分を表します。通常、ピクセル オフセットは整数として扱われるため、mContentRectRect 型です。グラフドメインと範囲は 10 進数 / 浮動小数点値であるため、mCurrentViewportRectF 型です。

スニペットの最初の部分は、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() メソッドです。現在のスクロール可能な表面サイズをピクセル単位で計算します。たとえば、グラフ領域全体が表示されている場合、これは単に mContentRect の現在のサイズです。グラフを両方向に 200% ズームすると、返されるサイズは水平と垂直の 2 倍になります。

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 クラスのソースコードをご覧ください。フリングに応じてスクロールし、スクロールを使用して「ページにスナップする」アニメーションを実装します。