스크롤 동작 애니메이션 처리

Android에서 스크롤은 일반적으로 ScrollView 클래스를 사용하여 구현합니다. 이 클래스의 컨테이너 경계를 넘어 확장될 수 있는 표준 레이아웃은 ScrollView에 중첩하여 프레임워크에서 관리되는 스크롤 가능한 뷰를 제공하게 해야 합니다. 맞춤 스크롤러를 구현하는 것은 특수한 시나리오에만 필요합니다. 이 과정에서는 이런 특수 시나리오에 관해 설명합니다. 즉, 터치 동작에 응답하여 스크롤러를 사용해 스크롤 효과를 표시하는 방법입니다.

스크롤러(Scroller 또는 OverScroller)를 사용하여 터치 이벤트에 응답해 스크롤 애니메이션을 생성하는 데 필요한 데이터를 수집할 수 있습니다. 이들 스크롤러는 비슷하지만, OverScroller에는 화면 이동 동작이나 살짝 튕기기 동작 이후 콘텐츠 가장자리에 도달했음을 사용자에게 알리는 메서드가 포함되어 있습니다. InteractiveChart 샘플에서는 EdgeEffect 클래스(실제로는 EdgeEffectCompat 클래스)를 사용하여 사용자가 콘텐츠 가장자리에 도달했을 때 '발광 효과'를 표시합니다.

참고: 스크롤 애니메이션에 Scroller가 아니라 OverScroller를 사용하는 것이 좋습니다. OverScroller는 이전 버전 기기와의 호환성을 가장 효율적으로 제공합니다.
일반적으로 개발자가 직접 스크롤을 구현할 때만 스크롤러를 사용해야 합니다. ScrollViewHorizontalScrollView 내부에 레이아웃을 중첩하면 스크롤이 자동으로 구현됩니다.

스크롤러는 마찰, 속도 등의 플랫폼 표준 스크롤 물리 특성을 활용하여 시간 경과에 따른 스크롤을 애니메이션 처리하는 데 사용됩니다. 스크롤러 자체는 실제로 무언가를 그리지 않습니다. 스크롤러는 시간 경과에 따라 스크롤 오프셋을 자동으로 추적하지만 스크롤 오프셋 위치를 뷰에 자동으로 적용하지는 않습니다. 스크롤 애니메이션을 부드럽게 표현하는 속도로 새 좌표를 가져와 적용하는 것은 개발자의 책임입니다.

다음 관련 리소스를 참조하세요.

스크롤 용어 이해하기

'스크롤'은 Android에서 컨텍스트에 따라 서로 다른 의미를 가질 수 있는 단어입니다.

스크롤은 표시 영역(즉, 보고 있는 콘텐츠 '창')을 이동하는 일반적인 프로세스입니다. 스크롤이 x축과 y축 모두를 따라 이루어질 때 화면 이동이라고 합니다. 이 클래스(InteractiveChart)와 함께 제공된 샘플 애플리케이션에서는 두 가지 유형의 스크롤, 즉 드래그와 살짝 튕기기를 보여줍니다.

  • 드래그는 사용자가 터치 스크린에서 손가락을 드래그할 때 발생하는 스크롤 유형입니다. 단순 드래그는 종종 onScroll()(위치: GestureDetector.OnGestureListener)을 재정의하여 구현합니다. 드래그에 관해 자세히 알아보려면 드래그 및 크기 조정을 참고하세요.
  • 살짝 튕기기는 사용자가 재빨리 드래그한 후 손가락을 뗄 때 발생하는 스크롤 유형입니다. 일반적으로 사용자가 손가락을 뗀 후에도 스크롤이 계속(표시 영역 이동)되지만 속도가 느려진 후 표시 영역 이동이 멈추기를 원합니다. 살짝 튕기기는 onFling()(위치: GestureDetector.OnGestureListener)을 재정의하고 스크롤러 개체를 사용하는 방법으로 구현할 수 있습니다. 이 사용 사례를 이 과정에서 설명합니다.

스크롤러 개체는 살짝 튕기기 동작과 함께 사용하는 것이 일반적이지만 터치 이벤트에 응답해 UI에서 스크롤을 표시할 모든 컨텍스트에 사용할 수 있습니다. 예를 들어 터치 이벤트를 직접 처리하고 이 터치 이벤트에 응답하여 '페이지에 맞추기' 애니메이션의 스크롤 효과를 내도록 onTouchEvent()를 재정의할 수 있습니다.

터치 기반 스크롤 구현하기

이 섹션에서는 스크롤러를 사용하는 방법을 설명합니다. 아래의 스니펫은 이 클래스와 함께 제공되는 InteractiveChart 샘플에서 가져온 것으로, GestureDetector를 사용하며 GestureDetector.SimpleOnGestureListener 메서드 onFling()을 재정의합니다. 또한 OverScroller를 사용하여 살짝 튕기기 동작을 추적합니다. 사용자가 살짝 튕기기 동작 이후 콘텐츠 가장자리에 도달하는 경우 앱은 '발광 효과'를 표시합니다.

참고: InteractiveChart 샘플 앱은 확대/축소, 이동, 스크롤할 수 있는 차트를 표시합니다. 다음 스니펫에서 mContentRect는 차트를 그려 넣을 뷰 안의 직사각형 좌표를 나타냅니다. 특정 시점에 전체 차트 도메인 및 범위의 하위 집합이 이 직사각형 영역 안에 그려집니다. mCurrentViewport는 차트에서 화면에 현재 표시된 부분을 나타냅니다. 픽셀 오프셋은 일반적으로 정수로 취급되므로 mContentRectRect 유형입니다. 그래프 도메인 및 범위는 십진수 값/부동 소수점 값이므로 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)
    }
    

자바

    // 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
            }
            ...
        }
    }
    

자바

    // 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)
    }
    

자바

    // 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()
        )
    }
    

자바

    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 클래스의 소스 코드를 참고하세요. 이 코드는 살짝 튕기기에 응답하여 스크롤하고 스크롤을 사용하여 '페이지에 맞추기' 애니메이션을 구현합니다.