Nested Scroll 삽질기 - Jetpack Compose

안드로이드 앱을 개발할 때 항상 좋다고 느끼는 건 공식 가이드 문서가 아주 잘 되어 있다는 것이다. 근데 유독 자주 사용될 것 같은데 문서화는 잘 안 되어 있다고 느낀 게 Nested Scroll과 관련된 설명이었다. 구글링해서 블로그 글을 찾아봐도 별로 정보가 나오지 않았고 말이다. 사실 간단하다고 하면 간단한 부분이라서 그런 건지도 모르겠지만, 나는 조금 헤맸던 적이 있는 부분이라서 나와 같은 고민을 하고 있는 분들이나 이번에 배운 걸 까먹은 미래의 나에게 도움을 주고자 글을 쓰려고 한다.

근데 사실 그 헤매고 있었던 부분은 삽질 끝에 Jetpack Compose가 문제라는 걸 알아내면서 이걸 왜 이렇게 해뒀지?? 하는 생각이 들었다. 이 이야기는 마지막에 풀어뒀다.

본론

Jetpack Compose는 기본적으로 터치 이벤트가 자식에서 부모로 전파된다 (PointerEventPass.Main 대신 Initial을 사용하면 부모에서 자식으로 가는 흐름을 잡을 수 있기는 하다.) 그렇기 때문에 스크롤과 관련된 이벤트도 자식이 먼저 먹어버리면 부모는 그 스크롤을 먹을 수가 없게 되는데, Nested Scroll을 사용하면 부모도 그 스크롤을 같이 먹을 수 있도록 해준다.

Jetpack Compose에서 레이아웃 노드가 Nested Scroll에 관여하는 방법은 두 가지가 있다. NestedScrollDispatcher로 부모에게 스크롤을 전파하거나 NestedScrollConnection 이용해 올라오는 스크롤을 얻어내는 것이다. 이 글에서는 후자에 관해서만 다룰 것이다. NestedScrollDispatcher에 대해서 궁금하다면  Modifier.scrollable()의 코드를 보면 어떤 식으로 활용하는지 확인할 수 있다.

먼저 중요하게 기억해야 하는 것은 NestedScrollConnection은 자식이 받은 스크롤을 부모가 뺏어먹을 수 있도록 하는 녀석이라는 것이다. 그래서 뺏어먹는 부모쪽의 레이아웃 노드에 Modifier.nestedScroll()으로 설정을 하면 된다.

Layout(
    modifier = Modifier
        .nestedScroll(object: NestedScrollConnection {
            ...
        })
) { ... }

NestedScrollConnection에서 오버라이딩이 가능한 함수는 onPreScroll(), onPostScroll(), onPreFling(), onPostFling()가 있다. 여기서 onPre~~은 자식이 스크롤이나 플링을 먹기 전에 부모가 먼저 먹을 수 있도록 호출되는 함수이고, onPost~~은 자식이 먹을 수 있는 스크롤을 모두 먹은 후 남은 것을 부모가 먹을 수 있도록 호출되는 함수이다.

onPreScroll()은 다음과 같이 정의되어 있다:

/**
 * Pre scroll event chain. Called by children to allow parents to consume a portion of a drag
 * event beforehand
 *
 * @param available the delta available to consume for pre scroll
 * @param source the source of the scroll event
 *
 * @see NestedScrollSource
 *
 * @return the amount this connection consumed
 */
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero

onPreScroll()은 두 가지 상황에서 호출될 수 있다. 1) 사용자가 손가락을 드래그하고 있는 상태와 2) 손가락을 움직이다 떼서 플링하고 있는 상태이다. 각각 인자로 제공되는 NestedScrollSource가 Drag, Fling으로 구분된다. 여기서 이런 의문이 들 수 있다.

"Fling할 때도 onPreScroll() 함수가 호출된다면 onPreFling()은 왜 있는 거지?"

답은 간단하다. onPreFling()은 사용자가 손가락을 뗀 직후1번만 호출된다. 그래서 이 함수에는 좌표 변화 대신 속도가 주어진다. 이걸로 decay 같은 것들을 이용해서 알아서 fling 동작을 구현하면 된다.

onPreScroll()onPreFling()은 함수 형태에서 큰 차이점이 있다. 아래는 정의 부분이니 한 번 봐보자:

/**
 * Pre fling event chain. Called by children when they are about to perform fling to
 * allow parents to intercept and consume part of the initial velocity
 *
 * @param available the velocity which is available to pre consume and with which the child
 * is about to fling
 *
 * @return the amount this connection wants to consume and take from the child
 */
suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
onPreFling()의 정의

onPreScroll()과의 차이점이 보이는가? 가장 눈에 띄는 차이점은 이 함수는 suspend 함수라는 점이다. 즉 다시 말하면, 이 함수는 부모의 fling이 끝날 때까지 자식이 fling을 시작하지 못하게 막을 수 있다! 부모가 자신이 원하는 만큼 fling 애니메이션을 진행하다가 이게 모두 끝나면 남은 속도를 자식에게 넘겨주는 것이 가능하다는 것이다.

override suspend fun onPreFling(available: Velocity): Velocity {
    // doFling()은 suspend 함수; 리턴할 때까지 얼마나 오래 걸릴지 모름
    val consumed = doFling(available)
    
    // 자식은 남은 available - consumed만큼의 속도를 가지고 fling
    return consumed
}

정리하자면 NestedScrollConnection의 메서드가 호출되는 순서는 다음과 같다:

사용자가 드래그 할 때는 onPreScroll()onPostScroll()NestedScrollSource.Drag로 호출되다가, 손을 떼는 순간 onPreFling()이 호출된 후 onPreScroll()onPostScroll()NestedScrollSource.Fling으로 플링이 진행되는 동안 반복되어 호출된다. 참고로 자식 플링 단계에서 자식의 스크롤이 모두 소모되면  onPreScroll(..., Fling)onPostScroll(..., Fling)은  멈추고 onPostFling()이 호출된다.

스크롤 버그 토벌 작전 (약간의 hack과 함께)

Nested Scroll에 대해서 파고 들기 시작한 계기는 Jetpack Compose를 위한 CollapsingToolbarLayout을 구현하기 위해서였다. 꽤 자주 사용되는 레이아웃 같은데 신기하게 아직까지 구현된 게 없어서 내가 만들어봤다. 혹시 사용하기를 원한다면 여기에 구현체를 올려뒀다:

https://github.com/onebone/compose-collapsing-toolbar/

문제는 제대로 onXxxFling()을 제대로 구현했음에도 fling이 내가 스크롤한 방향의 반대 방향으로 되거나 속도가 비정상적으로 느리다는 것이었다. Jetpack Compose의 소스코드를 뒤져가며 얻어낸 결론은 결국 "내 잘못은 아닌 것 같다!"였다. 자식 뷰가 스크롤 됨에 따라 이동하는 경우 나와 비슷한 현상을 경험하는 분들이 있었을 것이다.

onPreFling()에서 제공되는 속도를 가지고 fling 처리

문제의 근원은 자식 레이아웃에서 스크롤 속도를 측정하는 데 상대(relative) 좌표를 사용한다는 것이었다. 스크롤을 할 때 자식의 위치가 변하면 당연하게도 터치한 곳의 상대 좌표는 바뀌게 되는데, 이런 상태에서 자식이 1:1 비율로 스크롤을 따라가게 했다면 상대 좌표가 변하지 않으므로 속도가 0이 된다. 더 큰 문제는 대부분의 실제 환경에서는 터치에 대한 반응이 느리거나 좌표에 오차가 있어서 사용자가 스크롤한 반대 방향으로 속도가 계산될 수도 있다. 그 결과가 위 영상과 같은 이상한 동작이다.

이와 관련된 이슈가 구글 이슈 트래커에 등록되어 있기는 했었는데 무슨 이유에서인지 이슈가 닫히거나 관련된 커밋이 되돌려지는 등 꽤 오랫동안 해결되지를 않았다. 그렇다고 이게 버그가 아니라고는 할 수 없는 게 기존의 안드로이드 뷰 시스템에서는 fling 속도를 측정할 때 자식의 화면에서의 위치가 바뀌면 상대 좌표를 보정해서 속도가 제대로 나올 수 있도록 한다.

다행히도 해결 방법이 아예 없는 건 아니다. 약간의 hack이 들어가기는 하지만 일단 임시적인 해결 방안이라도 있는 것에 감사하고 있다.

해결 방법의 실마리는 onPreScroll()에 제공되는 스크롤 델타는 화면을 기준으로 한 좌표를 기준으로 계산이 되기 때문에 이걸 이용해서 속도를 부모에서 측정을 하면 제대로 된 값을 얻을 수 있다는 것이다. 여기서는 화면을 기준으로 한 변화를 잘 주는데 왜 속도 계산은 상대 좌표로 하냐고?? 나도 잘 모르겠다...... 위화감 덩어리인데 뭔가 아무도 관심을 안 가져주는 것 같다.

아무튼 이렇게 얻은 속도 값을 onPreFling()에서 직접 사용한 후, 자식이 부모가 측정한 속도를 사용하게 하면 된다. 신기하게도(?) onPreFling()에서 반환된 값(= onPreFling()에서 먹은 속도)이 이용 가능한 스크롤 속도보다 커도 오류가 나지는 않기 때문에 속도의 부호까지 바꿀 수 있다.

override suspend fun onPreFling(available: Velocity): Velocity {
    val parentComputedVelocity = velocityTracker.calculateVelocity()
    velocityTracker.resetTracking()

    // parentComputedVelocity를 사용하고 남은 remainingVelocity
    var remainingVelocity: Float = doFling(parentComputedVelocity)

    // 부모가 직접 측정한 속도로 덮어씌움
    // 원래 available이었던 속도를 remainingVelocity로 변경함
    // -> 자식이 사용할 수 있는 속도가 remainingVelocity로 수정됨
    return Velocity(0f, available.y - remainingVelocity)
}

속도를 두 번이나 계산하기는 하지만 적어도 사용자 경험에 있어서의 문제는 사라진다.

onPreFling()에 제공되는 속도를 무시하고 직접 계산

설명만 보고는 글 내용을 믿을 수가 없어서 직접 Compose의 코드를 봐야겠다! 하는 분은 여기를 참고하면 좋다.