본문 바로가기

Android/Trouble Shoot

[Jetpack Compose] Column 속 LazyColumn 의 OverScroll 을 막는 방법

Unsplash, Jason Corey.

Nested Scroll

Jetpack Compose 에서는 자손 Composable 의 터치 및 스크롤 이벤트가 조상 Composable 로 전파됩니다. DND 8기로 활동하면서 개발 중인 앱에는 Column 내에 LazyColumn 이 포함되어 있는 형태의 화면이 있습니다.

 

시, 분, 오전/오후 설정 부분이 LazyColumn.

 

해당 스크린 UI 개발을 마치고 커밋하기 전, 기기에 빌드하고 실행해봤습니다. 문제 없이 모든 부분 정상 작동하여 커밋하려고 했는데, LazyColumn 에서 스크롤을 Over 하게 수행하니 화면 전체에 해당하는 Column 이 스크롤되고 있었습니다. 기능상 심각한 문제는 아니었지만, 해결하지 않으면 유저가 예상하는 UX 와는 거리가 멀어질 것이라 판단했습니다.

 

LazyColumn 의 스크롤이 끝나면 Column 의 스크롤이 진행됨

 

01시나 00분을 설정하기 위해 스크롤을 올리면 전체 스크린의 스크롤이 올라가고, 12시나 59분을 설정하기 위해 (물론 59분을 설정할 일은 없겠지만) 스크롤을 내리면 전체 스크롤이 내려가기 때문에, 테스트하는 과정에서부터 굉장히 불편했습니다.

 

이와 같은 문제는 Column 내에 LazyColumn. 즉 Scrollable Composable 내에 Scrollable Composable 이 있기 때문에 발생하는 문제입니다. 이러한 경우, 조상 Composable 과 자손 Composable 에 각각 스크롤이 존재한다고 인식하기 보다는 조상 Composable 의 스크롤을 하나로 보되, 자손 Composable 의 스크롤을 선수적으로 소모되어야 하는 값이라고 인식하면 이해하기 편한 것 같습니다.

 


Modifier.nestedScroll()

Modifier 의 확장함수인 nestedScroll() 은 자손 Composable 에서 발생한 스크롤에 대해 해당 Composable 에서 수행할 NestedScroll 액션에 대해 정의할 수 있도록 합니다.

 

해당 메서드는 NestedScrollConnection 과 NestedScrollDispatcher 를 파라미터로 받습니다. NestedScro llConnection 은 NestedScroll 과 관련된 기능에 접근하기 위한 Interface 입니다. 자손의 스크롤 이벤트를 조상이 받아와 어떠한 액션을 취할지에 대한 기능을 구현할 수 있습니다. 

 

NestedScrollDispatcher 는 NestedScroll 을 직접 컨트롤 할 수 있는 기능을 가집니다. dispatch~ 로 시작하는 메서드들을 사용할 수 있는데, 파라미터로 값들 (대체로 Velocitiy, Offset) 을 전달하여 원하는 만큼 NestedScrollSystem 에 ScrollAmount 를 보낼 수 있습니다.

🤔 Velocity 와 Offset 은 각각 무엇을 의미하나요?

Velocity 는 속도입니다. Velocity 는 Fling 과 관련된 메서드에 사용되는데, Fling 은 약진, 돌진 등을 의미하므로, Fling 에서의 Velocity 는 유저의 터치가 디바이스에 처음 닿아 떼어질 때까지 측정된 스크롤의 속도를 의미합니다.

 

Offset 은 UI 를 많이 다뤄본 분이시라면 잘 아시겠지만, 실질적인 거리를 의미합니다. 길이라고 생각하셔도 무방합니다만, 해당 경우엔 거리가 맞겠지요. Velocity 값이 커지면 자연스럽게 이동 가능한 Offset 값도 커집니다.

 


 

내부 소스 코드를 확인 해보면, NestedScroll 이 수행될 때마다 NestedScrollDispatcher 의 메서드들이 호출됩니다. NestedScrollDispatcher 에서의 dispatchPostScroll() 과 dispatchPostFling() 에서는 조상 Composable 이 있는 경우, 내부적으로 계산된 값을 넘겨주도록 구현되어 있습니다.

 

fun dispatchPostScroll(
    consumed: Offset,
    available: Offset,
    source: NestedScrollSource
): Offset {
    return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
}
suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity {
    return parent?.onPostFling(consumed, available) ?: Velocity.Zero
}

 

NestedScrollDispatcher 를 상속 받아 적절한 메서드들을 재정의하면 문제를 해결할 수 있겠다고 판단했으나, NestedScrollDispatcher 는 open class 가 아니라서 이를 상속 받을 수 없기 때문에, 해결 방법으로는 적절하지 않았습니다.

 


NestedScrollConnection

NestedScrollDispatcher 를 활용한 해결이 불가능할 것 같으니, NestedScrollConnection 을 살펴 봅니다.

 

NestedScrollConnection 을 익명 클래스로 구현하면 총 4개의 메서드를 재정의할 수 있습니다. 각 메서드들은 모두 리턴값이 정해져있기 때문에, 4개 중 원하는 메서드만 재정의 하면 됩니다.

 

onPreFling(), onPreScroll() 메서드는 실제로 NestedScroll 을 구현할 때 조상 Composable 에서 자손 Composable 보다 먼저 Velocity 나 Offset 을 소비하고 싶을 때 재정의하면 됩니다. 초기 반환 값은 모두 Zero 이므로, 자손 Composable 에서 스크롤하여도 조상 Composable 에서 소비되지 않고 자손에서 그대로 소비됩니다.

 

onPostFling(), onPostScroll() 메서드는, Scrollable Composable 이 또 다른 Scrollable Composable 내에 있는 경우, 자손 Composable 에서 수행하는 스크롤이 모두 수행되고 남은 Velocity 와 Offset 을 조상 Composable 에서 소비하게 되는데, Velocity 또는 Offset 값에 수정이 요구 되거나 추가적 액션이 필요할 때 override 하면 되겠습니다.

 

 

Column 내의 LazyColumn 에서 스크롤을 수행하였을 때의 콘솔입니다. LazyColumn 의 끝에 도달하지 않으면, onPostScroll 과 onPostFling 은 계속 0.0, -0.0 입니다. 그 이유는 간단한데, LazyColumn 에서 시작된 스크롤을 LazyColumn 내부에서 모두 소비했기 때문에, 스크린 Column 에 Dispatch 된 ScrollAmount(Available Velocity, Offset) 는 0.0 일 수 밖에 없습니다.

 

 

이번엔 위와 다르게 LazyColumn 의 끝에 도달한 상태에서 스크롤을 수행하였을 때의 콘솔입니다. 음의 값으로 찍히는데, 콘솔에 available.y 를 찍었기 때문입니다. LazyColumn 에서 모든 스크롤을 수행하고(끝에 도달하고) 남은 ScrollAmount 가 있을 것이며, 해당 값은 가용값이 됩니다. 가용값이 스크린 Column 에 전달 되었을 때 음의 값이 전달된다는 것은, Scroll Offset 이 남았지만 이가 소비되지 못하고 LazyColumn 에서 낭비되고 있음을 의미합니다. 반대로, LazyColumn 에서 모든 스크롤을 위쪽으로 수행하면 양의 값이 콘솔에 표시됩니다.

 

그렇다면, 주제의 문제를 해결하기 위해서는 간단하게 onPostFling() 과 onPostScroll() 을 재정의하여 반환값에 0 을 설정해주면 될 것 같습니다. 그러나, 그렇게 구현하면 NestedScrollSystem 에 적용되는 ScrollAmount 에 별다른 감소가 없기 때문에, 재정의하지 않는 것과 같은 액션을 보여줍니다.

🤔 전달해주는 값이 0 인데 왜 그대로인가요? 스크롤이 안 되어야 하는 것 아닌가요?

위 서술하였듯, NestedScrollSystem 에 적용된 스크롤은 스크롤 다운(양의 값)으로 그대로이고 자손 스크롤은 선수적으로 소모되는 것일 뿐이므로, 전체 스크롤 값이 감소되는 양은 없기 때문에 그대로인 것입니다.

 


본격적인 트러블 슈팅

간단한 방법입니다. onPostFling() 과 onPostScroll() 이 가용값을 그대로 반환하도록 구현해주면 됩니다.

 

.nestedScroll(connection = object : NestedScrollConnection {
    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return Velocity(0f, available.y)
    }

    override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
        return Offset(0f, available.y)
    }
})

 

위와 같이 구현하면, LazyColumn 에서 스크롤 다운을 수행하고 남은 스크롤 Offset 은 양의 값인데 반하여, onPostScroll() 을 통해 넘어오는 값은 LazyColumn 스크롤 내에서 낭비된, Offset 의 값과 같은 양의 음의 값을 Column 으로 넘겨주기 때문에 서로 상충되어 최종적인 값은 0.0 이 되면서, Scrollable Composable 내의 Scrollable Composable 의 OverScroll 에 대한 액션이 사라지게 됩니다.

 

 


 

스크롤의 경우 플랫폼 자체적으로 제공하는 메서드나 필드가 다양하기도 하고, 딱히 크게 신경 쓸 일이 없는 부분이라 자세히 알기 쉽지 않은데, 이번 이슈를 통해 스크롤에 대해 학습할 수 있게 되어 오히려 좋았습니다.