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

Compose 사용해 보기
Jetpack Compose는 Android에 권장되는 UI 도구 키트입니다. Compose에서 터치 및 입력을 사용하는 방법을 알아봅니다.

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

앱은 스크롤러(Scroller 또는 OverScroller)를 사용하여 터치 이벤트에 응답하여 스크롤 애니메이션을 생성하는 데 필요한 데이터를 수집할 수 있습니다. 이러한 스크롤러는 비슷하지만, OverScroller에는 화면 이동 동작이나 살짝 튕기기 동작 이후 콘텐츠 가장자리에 도달했음을 사용자에게 알리는 메서드도 포함되어 있습니다.

  • Android 12 (API 수준 31)부터 시각적 요소가 드래그 이벤트에 늘어났다가 다시 돌아오고 플링 이벤트에 플링되었다가 다시 돌아옵니다.
  • Android 11 (API 수준 30) 이하에서는 가장자리로 드래그하거나 살짝 튕기기 동작을 한 후 경계가 '발광 효과'를 표시합니다.

이 문서의 InteractiveChart 샘플은 EdgeEffect 클래스를 사용하여 이러한 오버스크롤 효과를 표시합니다.

스크롤러를 사용하여 마찰, 속도, 기타 품질과 같은 플랫폼 표준 스크롤 물리 특성을 활용하여 시간 경과에 따른 스크롤을 애니메이션 처리할 수 있습니다. 스크롤러 자체는 아무것도 그리지 않습니다. 스크롤러는 시간 경과에 따라 스크롤 오프셋을 자동으로 추적하지만 스크롤 오프셋 위치를 뷰에 자동으로 적용하지는 않습니다. 스크롤 애니메이션을 부드럽게 표현하는 속도로 새 좌표를 가져와 적용해야 합니다.

스크롤 용어 이해하기

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

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

  • 드래그: 사용자가 터치 스크린에서 손가락을 드래그할 때 발생하는 스크롤 유형입니다. GestureDetector.OnGestureListener에서 onScroll()를 재정의하여 드래그를 구현할 수 있습니다. 드래그에 관한 자세한 내용은 드래그 및 크기 조정을 참고하세요.
  • 살짝 튕기기: 사용자가 재빨리 드래그한 후 손가락을 뗄 때 발생하는 스크롤 유형입니다. 일반적으로 사용자가 손가락을 뗀 후에도 뷰포트 이동이 계속되지만 속도가 느려진 후 뷰포트 이동이 멈추기를 원합니다. GestureDetector.OnGestureListener에서 onFling()을 재정의하고 스크롤러 객체를 사용하여 살짝 튕기기를 구현할 수 있습니다.
  • 화면 이동: x축과 y축을 동시에 스크롤하는 것을 화면 이동이라고 합니다.

스크롤러 객체는 살짝 튕기기 동작과 함께 사용하는 것이 일반적이지만 터치 이벤트에 응답해 UI에 스크롤을 표시하려는 모든 컨텍스트에서 사용할 수 있습니다. 예를 들어 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)
}

자바

// 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의 현재 위치를 가져옵니다. 오버스크롤 '발광' 가장자리 효과를 표시하기 위한 기준(즉, 디스플레이가 확대되고 x 또는 y가 범위를 벗어나며 앱이 아직 오버스크롤을 표시하지 않음)이 충족되면 코드는 오버스크롤 발광 효과를 설정하고 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
        }
        ...
    }
}

자바

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

자바

// 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% 확대된다면 가로와 세로로 2배의 크기가 반환됩니다.

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

자바

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
}

자바

@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()mIsBeingDraggedtrue일 때 true를 반환하므로 하위 요소에 사용할 기회가 생기기 전에 이벤트를 사용하기에 충분합니다.

오버스크롤 효과 해제

스크롤 전에 스트레치 효과를 해제하여 스트레치가 스크롤 콘텐츠에 적용되지 않도록 하는 것이 중요합니다. 다음 코드 샘플은 이 권장사항을 적용합니다.

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

자바

@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 pull 거리를 사용합니다. 앞의 코드 샘플에서 getDistance()는 가장자리 효과가 표시되고 모션으로 해제될 수 있을 때 양수 값을 반환합니다. 터치 이벤트에서 스트레치를 해제하면 먼저 EdgeEffect에서 사용하므로 중첩된 스크롤과 같은 다른 효과가 표시되기 전에 완전히 해제됩니다. getDistance()를 사용하여 현재 효과를 해제하는 데 필요한 pull 거리를 알 수 있습니다.

onPull()와 달리 onPullDistance()은 전달된 델타의 사용량을 반환합니다. Android 12부터 getDistance()0일 때 onPull() 또는 onPullDistance()에 음수 deltaDistance 값이 전달되면 스트레치 효과가 변경되지 않습니다. Android 11 이하에서는 onPull()를 사용하면 총 거리의 음수 값으로 발광 효과를 표시할 수 있습니다.

오버스크롤 선택 해제

레이아웃 파일에서 또는 프로그래매틱 방식으로 오버스크롤을 선택 해제할 수 있습니다.

레이아웃 파일에서 선택 해제하려면 다음 예와 같이 android:overScrollMode를 설정합니다.

<MyCustomView android:overScrollMode="never">
    ...
</MyCustomView>

프로그래매틱 방식으로 선택 해제하려면 다음과 같은 코드를 사용하세요.

Kotlin

customView.overScrollMode = View.OVER_SCROLL_NEVER

Java

customView.setOverScrollMode(View.OVER_SCROLL_NEVER);

추가 리소스

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