본문 바로가기

Android/Trouble Shoot

[Jetpack Compose] NestedScroll 로 UX 개선하기

Unsplash, Rosario Fernandes.

 

동기

새롭게 진행하게 된 사이드 프로젝트의 Figma 를 살펴보던 도중, 재미있는 UX 요구 사항이 있었습니다.

메인 화면 하단에 약간 축소된 BottomSheet 가 있고, 그 내부에 Scrollable Column 이 있는 형태인데, 화면을 아래에서 위로 스크롤하면 BottomSheet 가 먼저 정해진 위치까지 확장되고, 그 뒤에 내부 Column 이 스크롤 되어야 한다는 것이었습니다. 반대로는 내부 Column 이 아래로 스크롤 된 이후 BottomSheet 가 축소되어야 하고요.

 

Android View 시스템을 이용해서 이러한 사항을 구현하는 것은 그다지 어렵지 않았던 것으로 기억합니다. 

(CoordinatorLayout, NestedScrollView 를 적절히 조합해서 구현했던 것 같습니다) Jetpack Compose 에서는 이를 구현해본 적이 없었는데, 일전에 NestedScrollConnection 을 재정의해서 비슷한 문제를 해결했던 경험이 있어, 이를 활용하여 문제를 해결하고 이에 대해 기록하고자 합니다.

 

문제 해결에 앞서, 과거에 작성했던 NestedScroll 관련 포스트를 공유하고자 합니다.

 

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

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

blothhundr.tistory.com

위 포스트는 NestedScrollConnection 을 재정의하여, 중첩된 Scrollable Composable 사이의 연결을 끊어내는 내용에 대해 기술하고 있습니다. 이번에는 과거에 했던 것과 반대되는 상황이라고 볼 수도 있겠네요. 


문제 정의

먼저, 기본적인 UI 를 먼저 구현하였습니다. 이는 다음과 같이 동작합니다.

 

영상에 보이는 것처럼, BottomSheet 는 지정된 Bounds 까지 올라갑니다. BottomSheet 내부 Column 에서의 스크롤은 BottomSheet 의 상태를 막론하고 내부 스크롤에만 적용됩니다. 

UX 요구 사항은 다음과 같습니다.

  • BottomSheet 상단을 누르고 Fling 하는 경우, BottomSheet 의 상태만을 변경시킨다.
  • 축소된 BottomSheet 내부 리스트에서 상단으로 Fling 하는 경우, 먼저 BottomSheet 를 확장시키고난 뒤 BottomSheet 내부 리스트를 스크롤한다.
  • 확장된 BottomSheet 내부 리스트에서 하단으로 Fling 하는 경우, 먼저 BottomSheet 내부 리스트를 스크롤한 뒤 BottomSheet 를 축소시킨다.

NestedScrollConnection 을 재정의하여 문제 해결하기

NestedScrollConnection 구현에 앞서, BottomSheet 의 상태를 포함하고, 이 상태를 변경할 수 있는 기능들이 담긴 클래스를 하나 만들었습니다. 포함하는 상태는 BottomSheet 의 최대 높이, 최소 높이, 화면 전체 높이 등 입니다.

 

@Stable
class AnchoredBottomSheetState {
    var statusBarHeight by mutableIntStateOf(0)
    var frameHeight by mutableIntStateOf(0)
    var upperAnchorY by mutableIntStateOf(0)
    var lowerAnchorY by mutableIntStateOf(0)

    private var _sheetOffsetY by mutableFloatStateOf(0f)
    val sheetOffsetY: Float get() = _sheetOffsetY
    
    fun dispatchRawDelta(delta: Float): Float {
        val current = _sheetOffsetY
        val newOffset = current + delta
        val clamped = newOffset.coerceIn(upperAnchorY.toFloat(), lowerAnchorY.toFloat())
        _sheetOffsetY = clamped
        return clamped - current
    }

    ...

    fun isExpandable(available: Offset): Boolean = available.y < 0f && _sheetOffsetY > upperAnchorY
    
    fun isExpandable(available: Velocity): Boolean = available.y < 0f && _sheetOffsetY > upperAnchorY
    
    fun isCollapsable(available: Offset): Boolean = available.y > 0f && _sheetOffsetY < lowerAnchorY
    
    fun isCollapsable(available: Velocity): Boolean = available.y > 0f && _sheetOffsetY < lowerAnchorY
}

 

isExpandable(), isCollapsable() 메서드는 이름과 같이, BottomSheet 의 확장 및 축소 가능 여부를 판단하기 위한 메서드입니다. 확장 및 축소 시 스크롤의 양이 Offset 또는 Velocity 로 전달되면, 현재 BottomSheet 의 상단 위치와 최대, 최소 높이 간 관계를 비교하여 확장이 가능한지, 축소가 가능한지 반환합니다. 

 

NestedScrollConnection 에서 재정의할 수 있는 메서드는 4개가 있으며, 다음과 같습니다.

  • onPreScroll()
    • 스크롤 진행 전, NestedScrollConnection 이 적용된 Composable 에 스크롤을 소비하기 전에 다른 곳에서 먼저 소비할 수 있도록 합니다BottomSheet 내부에서 스크롤하기 전에, BottomSheet 자체를 확장하는 데에 사용합니다.
  • onPostScroll()
    • 스크롤 발생 직후, 남은 스크롤을 어떻게 소비할지 결정합니다. onPreScroll 과 함께 스크롤하는 동안 번갈아가며 매우 높은 빈도로 호출됩니다. BottomSheet 내부 스크롤이 끝나고 BottomSheet 자체를 축소시키는 데에 사용합니다.
  • onPreFling()
    • 플링 진행 전, NestedScrollConnection 이 적용된 Composable 에서 모멘텀을 소비하기 전에 다른 곳에서 먼저 소비할 수 있도록 합니다. 플링은 스크롤을 매끄럽게 하는 데에 의의가 있으므로, onPreFling 에서 먼저 BottomSheet 를 열고 나머지 모멘텀을 Column 에 전달합니다. 
    • onPreFling() 메서드에 BottomSheet 를 축소시키는 코드를 작성하면 내부 스크롤이 상단에 닿기 전에 BottomSheet 가 축소됩니다.
  • onPostFling()
    • 플링이 끝난 뒤, 남은 모멘텀을 어떻게 소비할지 결정합니다. onPreFling 과 함께 스크롤이 끝난 뒤 단 한 번만 호출됩니다. 포스트의 예시에서는 스크롤이 끝난 뒤 남은 모멘텀을 BottomSheet 를 축소시키는 데 사용합니다.
    • onPostFling() 메서드에 BottomSheet 를 확장시키는 코드를 작성하면 모멘텀이 부족할 때에는 BottomSheet 가 완전히 펼쳐지지 않습니다.

확장의 프로세스를 도식화하면 다음과 같습니다.

 

이를 바탕으로 NestedScrollConnection 을 구성하면 다음과 같습니다.

NestedScrollConnection {
   override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        return if (anchoredBottomSheetState.isExpandable(available)) {
            val consumedY = anchoredBottomSheetState.dispatchRawDelta(available.y)
            Offset(0f, consumedY)
        } else {
            Offset.Zero
        }
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        return if (anchoredBottomSheetState.isCollapsable(available)) {
            val consumedY = anchoredBottomSheetState.dispatchRawDelta(available.y)
            Offset(0f, consumedY)
        } else {
            Offset.Zero
        }
    }

    override suspend fun onPreFling(available: Velocity): Velocity {
        return if (anchoredBottomSheetState.isExpandable(available)) {
            anchoredBottomSheetState.animateToUpper()
            available
        } else {
            Velocity.Zero
        }
    }

    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return if (anchoredBottomSheetState.isCollapsable(available)) {
            anchoredBottomSheetState.animateToLower()
            available
        } else {
            Velocity.Zero
        }
    }
}

 

처음 코드를 작성하면서 약간 의아했던 부분은, 반환값이 약간 모호하게 설정되어 있다는 건데요. '반환되는 값을 Composable 이 소비하게 될 것'이라고 생각하기 좋습니다.

 

그러나, 반환되는 모든 값은 NestedScroll 을 통제하는 Dispatcher 에게 전달됩니다. 계산은 이 Dispatcher 가 합니다. 즉, 반환되는 값을 전체 스크롤 availiable 에서 차감하여 NestedScrollConnection 이 적용된 Composable 에게 전달하는 형태입니다. 코드 내부를 살펴보지 않으면 Dispatcher 의 존재를 알 수 없어, 약간은 아쉬운 부분입니다.


결과


과거에 비슷한 문제를 해결한 경험이 있어서 그런지, 생각보다 쉽게 해결했던 것 같습니다. 다만, 각 메서드들이 반환하는 값에 대한 이해가 약간 모호했는데, 다행히도 코드 내부에 이에 대한 주석이 잘 달려있어서 나름 쉽게 이해할 수 있었습니다. 근데 막상 옛날에 작성한 제 포스트를 보니 이래 저래 부족함이 많네요.

 

비슷한 기능을 구현하셔야 한다면, 제 코드를 읽고 사용해보시는 것도 좋을 것 같습니다.

긴 글 읽어주셔서 감사합니다.