스크롤 이벤트 intercept

매일 수정하는 GNOSS LV5·2022년 2월 15일
1

AndroidStudio

목록 보기
46/83
post-custom-banner

보통 스크롤 뷰 안에 스크롤 뷰를 넣을때 사용합니다.
부모의 스크롤 뷰에게 이벤트를 뺏기지 않고 자식의 스크롤 뷰에서 이벤트를 소비하고 싶을때 사용하며
parent.requestDisallowInterceptTouchEvent(true)로 주어 이벤트를 뺏기지 않게 막을 수 있습니다.
해당 메서드를 실행하면 자식 스크롤 뷰 이전까지 dispatchTouchEvent() = false로 설정되며 이벤트가 내려오게 됩니다.

이 후의 글은 이를 적용한 예시로써 NestedScrollableHost에 대한 설명입니다.


views-widgets-samples/NestedScrollableHost.kt at master · android/views-widgets-samples

구글에서 제공하는 NestedScrollableHost라는 클래스가 있습니다.

ViewPager내부에서 또 다른 ViewPager를 이용할 시 부모의 ViewPager가 자손의 스크롤 이벤트를 대신 소비하기 때문에 스크롤이 안되는 경우를 대비해 만들어졌습니다.

자식의ViewPager를 NestedScrollableHost 내부에 넣어주면 설정은 끝납니다.
(NestedScrollableHost 내부에는 자식 뷰페이저 하나만 들어가있어야 합니다.


용어 정리

TouchSlop

해당 뷰는 처음 생성되면 touchSlop라는 변수를 초기화합니다.
이 변수는 ViewConfiguration 클래스를 사용하여 안드로이드에 사용되는 거리,속도,시간에 액세스하고 사용자가 화면상의 요소를 터치하였는지 스크롤 하였는지에 대한 기준을 정의해줍니다.

init{
		touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}

X, Y

  • initialX, initialY ⇒ Touch순간의 x,y좌표입니다.

예를 들면 단위는 픽셀이며 해당 배너의 크기는 (360dp, 180dp) 이기때문에 픽셀로 변환했을경우 ( 1440, 720)입니다 
  • ex, ey ⇒ Touch가 끝나는 시점의 x,y좌표입니다.
  • dx, dy ⇒ 터치가 끝나는 시점에서 시작점( e.x, e.y - initialx, initialy ) 이며 스크롤의 양을 픽셀로 나타낸 것입니다.
val dx = e.x - initialX
val dy = e.y - initialY

orientation

parent ViewPager의 orientation 입니다. HORIZONTAL = 0 , VERTICAL = 1로 내려옵니다.

이 클래스에서는 isVpHorizontal : Boolean 값으로 저장하며 만약 parent의 orientation이 수평스크롤이라면 true, 수직스크롤이라면 false를 반환합니다.

val orientation = parentViewPager?.orientation
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL

scaledDx, scaledDy

dx,dy값의 절대값입니다. 만약 수평 스크롤이면 dx의절대값의 절반, 수직 스크롤이라면 dy의 절대값의 절반입니다.

val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f

추후에 touchSlop과 비교하여 해당 이벤트가 스크롤인지 터치인지를 구분해줍니다.


fun canChildScroll(orientation : Int , delta : Float) : Boolean

childView가 scroll이 가능한지 아닌지를 판단합니다.그리고 해당 스크롤 이벤트가 자식 뷰의 스크롤의 끝에 다달았는지를 확인합니다.
orientation 0 : 수평 스크롤, 1 : 수직 스크롤

private fun canChildScroll(orientation: Int, delta: Float): Boolean {
        val direction = -delta.sign.toInt()
        return when (orientation) {
            0 -> child?.canScrollHorizontally(direction) ?: false
            1 -> child?.canScrollVertically(direction) ?: false
            else -> throw IllegalArgumentException()
        }
    }

부모 뷰의 스크롤이 수평일때 canScrollHorizontally(direction)메서드를 실행합니다.

public boolean canScrollHorizontally(int direction) {
        final int offset = computeHorizontalScrollOffset();
        final int range = computeHorizontalScrollRange() - computeHorizontalScrollExtent();
        if (range == 0) return false;
        if (direction < 0) {
            return offset > 0;
        } else {
            return offset < range - 1;
        }
    }

위의 메서드는 child의 스크롤이 끝에 다달았는지를 체크합니다. range == 0의 의미는 더이상 스크롤 될것이 없다는것을 의미하고 이때 false를 반환합니다.


동작 원리

class NestedScrollableHost : FrameLayout {
    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

    private var touchSlop = 0
    private var initialX = 0f
    private var initialY = 0f
    private val parentViewPager: ViewPager2?
        get() {
            var v: View? = parent as? View
            while (v != null && v !is ViewPager2) {
                v = v.parent as? View
            }
            return v as? ViewPager2
        }

    private val child: View? get() = if (childCount > 0) getChildAt(0) else null

    init {
        touchSlop = ViewConfiguration.get(context).scaledTouchSlop
    }

    private fun canChildScroll(orientation: Int, delta: Float): Boolean {
        val direction = -delta.sign.toInt()
        return when (orientation) {
            0 -> child?.canScrollHorizontally(direction) ?: false
            1 -> child?.canScrollVertically(direction) ?: false
            else -> throw IllegalArgumentException()
        }
    }

    override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
        handleInterceptTouchEvent(e)
        return super.onInterceptTouchEvent(e)
    }

    private fun handleInterceptTouchEvent(e: MotionEvent) {
        val orientation = parentViewPager?.orientation ?: return

        // Early return if child can't scroll in same direction as parent
        if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
            return
        }

        if (e.action == MotionEvent.ACTION_DOWN) {
            initialX = e.x
            initialY = e.y
            parent.requestDisallowInterceptTouchEvent(true)
        } else if (e.action == MotionEvent.ACTION_MOVE) {
            val dx = e.x - initialX
            val dy = e.y - initialY
            val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL

            // assuming ViewPager2 touch-slop is 2x touch-slop of child
            val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
            val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f

            if (scaledDx > touchSlop || scaledDy > touchSlop) {
                if (isVpHorizontal == (scaledDy > scaledDx)) {
                    // Gesture is perpendicular, allow all parents to intercept
                    parent.requestDisallowInterceptTouchEvent(false)
                } else {
                    // Gesture is parallel, query child if movement in that direction is possible
                    if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
                        // Child can scroll, disallow all parents to intercept
                        parent.requestDisallowInterceptTouchEvent(true)
                    } else {
                        // Child cannot scroll, allow all parents to intercept
                        parent.requestDisallowInterceptTouchEvent(false)
                    }
                }
            }
        }
    }
}
  • touchSlop값을 세팅합니다.
  • event가 들어올 시 handlleInterceptTouchEvent 메서드를 실행합니다.
  • 해당 메서드에서는 MotionEvent를 파라미터로 받습니다.
  • 만약 MotionEvent가 ACTION_DOWN 이라면 ( 터치 이벤트 ) parent.requestDisallowInterceptTouchEvent(true)를 실행시키고 해당 클래스에서 이벤트를 소비합니다.
  • MOTION_MOVE가 들어온다면(스크롤) 다음과 같습니다.
				else if (e.action == MotionEvent.ACTION_MOVE) {
//dx, dy값을 설정합니다.
            val dx = e.x - initialX
            val dy = e.y - initialY
//isVpHorizontal 을 설정합니다. 수평 스크롤시 true 수직 스크롤시 false
            val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL

            // assuming ViewPager2 touch-slop is 2x touch-slop of child

//touchSlop와 비교할 scaledDx,scaledDy를 설정합니다.

            val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
            val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f

//touchSlop 와 비교하여 스크롤이 발생했다고 판단이 될 경우
            if (scaledDx > touchSlop || scaledDy > touchSlop) {

//수평 스크롤 뷰인데 수직으로 이벤트를 준다면 requestDisallowInterceptTouchEvent(false)
                if (isVpHorizontal == (scaledDy > scaledDx)) {
                    // Gesture is perpendicular, allow all parents to intercept
                    parent.requestDisallowInterceptTouchEvent(false)
                } 
//수평 스크롤 뷰인데 수평으로 주었을때
									else {
//canChildScroll 을 통해 해당 뷰의 스크롤이 끝에 도달했는지를 판단
//끝에 다달았다면 requestDisallowInterceptTouchEvent(false)
//끝이 아니라면 requestDisallowInterceptTouchEvent(true)를 반환
                    if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
                        // Child can scroll, disallow all parents to intercept
                        parent.requestDisallowInterceptTouchEvent(true)
                    } else {
                        // Child cannot scroll, allow all parents to intercept
                        parent.requestDisallowInterceptTouchEvent(false)
                    }
                }
            }

profile
러닝커브를 따라서 등반중입니다.
post-custom-banner

0개의 댓글