Jetpack Compose에서 복잡한 스크롤 동작을 구현할 때 NestedScrollConnection을 사용하는 경우가 많다.
하지만 필자는 이 API의 동작 순서와 각 함수를 어떻게 호출해서 활용해야하는지 정확히 이해하기는 쉽지가 않았다.
따라서, 이번 글에서는 실시간으로 스크롤 처리 과정을 시각화하여 NestedScroll의 동작 원리를 완벽하게 파악할 수 있는 예제를 구현해 NestedScroll이 어떻게 동작하는지 알아보도록 하자.
우선,
NestedScrollConnection을 사용하는 방식은 다음과 같이 아주 간단하기 때문에 사용 방식은 넘어가도록 하겠다.
Box(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
)
NestedScrollConnection의 핵심은 부모와 자식 Component 간의 스크롤 이벤트 처리 순서라고 볼 수 있다.
우선 해당 interface에서 제공하는 함수를 확인해 보자.

이것과 같은 함수들을 제공하는데, 여기서 중요한 부분은 onPreScroll, onPostScroll이다.
함수의 이름과 같이 전/후에 스크롤될 때 호출되는 함수라고 생각하면 된다.
해당 함수를 기반으로, 스크롤 이벤트 처리 순서를 보면 다음과 같다.
- onPreScroll (부모가 먼저 처리)
- Child Scroll (LazyColumn 등 자식 처리)
- onPostScroll (부모가 나머지 처리)
여기서 부모가 처리한다는 게 어떤 의미인지 이해가 안 갈 수 있다.
NestedScrollConnection 객체를 사용한 NestedScroll의 경우 LazyColumn 등 스크롤이 가능한 Component 보다 부모의 Component에 선언해서 사용하게 된다.
즉,
Box(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(top = toolbarHeight),
modifier = Modifier.fillMaxSize()
) {
// ...
}
// ...
}
이와 같이 사용된다는 소리이다.
LazyColumn에서 스크롤을 하기 전 onPreScroll이 호출되고, 스크롤 이후 onPostScroll이 마지막에 남은 스크롤이 있다면 처리하게 되는 것이다.
현재 예제에서 각 단계별 상세한 역할은 다음과 같다.
onPreScroll (자식 처리 전)
- 자식이 스크롤하기 전에 부모가 우선 처리
- 용도: 헤더 숨기기, 우선 처리가 필요한 UI 효과
- 반환값: 소비한 스크롤 양 (Offset)
Child Scroll (자식 처리)
- LazyColumn, LazyRow 등의 자동 스크롤 처리
- 직접 구현하지 않음
onPostScroll (자식 처리 후)
- 자식이 더 이상 스크롤할 수 없을 때 부모가 처리
- 용도: 헤더 보이기, Over-scroll 효과
- 매개변수: consumed (자식 소비량), available (남은 양)
어떤 식으로 처리되는지 확인을 했으니, 실제 object를 한번 확인해 보자.
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
// ...
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// ...
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
// ...
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
// ...
return Velocity.Zero
}
}
}
여기서 Fling은 스크롤 속도에 따라 자동적으로 관성으로 이동되는 경우 호출되는 부분이다.
해당 객체에서 확인할 수 있는 것처럼 pre, post Scroll 함수를 통해 전/후 스크롤 이벤트를 확인할 수 있으므로 해당 함수에서 필요한 처리에 대한 함수를 구현해 주면 된다.
필자는 예제를 만들면서, 소비되는 스크롤의 크기를 확인하기 위해서 해당 함수들에 UI를 그려주기 위한 작업을 추가하였다.
구현된 Scroll 함수에 대한 내용을 한번 확인해 보자.
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
preScrollCount.intValue++
lastCalledFunction.value = "onPreScroll (자식 처리 전)"
lastScrollDelta.floatValue = available.y
currentScrollDirection.value = if (available.y > 0) "⬇️ 아래로" else "⬆️ 위로"
val delta = available.y
if (delta < 0) {
val preConsume = delta * 0.3f
preScrollConsumed.floatValue = abs(preConsume)
val newOffset = toolbarOffsetHeightPx.floatValue + preConsume
toolbarOffsetHeightPx.floatValue = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset(0f, preConsume)
} else {
preScrollConsumed.floatValue = 0f
return Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
postScrollCount.intValue++
lastCalledFunction.value = "onPostScroll (자식 처리 후)"
val childConsumed = consumed.y
childScrollConsumed.floatValue = abs(childConsumed)
if (available.y > 0) {
val postConsume = available.y
postScrollConsumed.floatValue = abs(postConsume)
val newOffset = toolbarOffsetHeightPx.floatValue + postConsume
toolbarOffsetHeightPx.floatValue = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset(0f, postConsume)
} else {
postScrollConsumed.floatValue = 0f
return Offset.Zero
}
}
...
}
}
전체 코드에 대한 내용은 깃허브에 추가되어 있으므로 전체 코드는 해당 부분을 확인하길 바란다.
해당 부분에서 중요한 부분은, 각각에 선언된 if문 부분이라고 볼 수 있다.
각 함수를 디테일하게 확인해 보자.
val delta = available.y
// 위로 스크롤 시 툴바 숨기기 우선 처리
if (delta < 0) { // 위로 스크롤
val preConsume = delta * 0.3f // 30% 소비 (사용자 설정)
// 툴바 오프셋 업데이트
val newOffset = toolbarOffsetHeightPx.floatValue + preConsume
toolbarOffsetHeightPx.floatValue = newOffset.coerceIn(-toolbarHeightPx, 0f)
// 소비한 양만큼 반환 (나머지 70%는 LazyColumn으로)
return Offset(0f, preConsume)
}
스크롤이 발생하는 값의 30%를 pre에서 처리하고, 나머지를 child로 내린다는 의미가 된다.
필자가 만든 예제에는 큰 툴바가 존재하는데, 해당 툴바에 대한 offset 값을 변경하는데 처음부터 모든 스크롤을 사용하는 것이 아닌, 보다 자연스러운 스크롤을 구현하기 위해 스크롤되는 정도의 30%만 offset을 변경하고 나머지는 실제 LazyColumn을 스크롤할 수 있도록 구현하였다.
다음으로, post 함수를 확인해 보자.
// 아래로 스크롤 시 LazyColumn 한계 도달 후 툴바 보이기
if (available.y > 0) { // 아래로 스크롤 && LazyColumn 처리 불가
val postConsume = available.y // 남은 스크롤 전체 소비
// 툴바 오프셋 업데이트 (0에 가까워짐)
val newOffset = toolbarOffsetHeightPx.floatValue + postConsume
toolbarOffsetHeightPx.floatValue = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset(0f, postConsume)
}
주석에 남겨둔 것처럼, post는 LazyColumn의 스크롤이 불가능할 때.
즉, 모든 스크롤이 소모된 후에 남은 스크롤이 있을 때 post에서 받아서 처리할 수 있도록 해둔 것이다.
필자의 예제를 확인하면서 본 글을 보거나 이해하려고 한다면, Post가 아닌 Pre 쪽 함수에서 비율을 조절해 가면서 확인하면 보다 쉽게 스크롤 이벤트의 소모 순서를 확인할 수 있을 것이다.
위의 NestedScrollConnection 객체를 사용할 때,
필자가 만든 예제의 시나리오는 다음과 같다.
시나리오 1. 천천히 위로 스크롤
- onPreScroll: 30% 소비 (툴바 점진적 숨김)
- LazyColumn: 나머지 70% 처리 (실제 의도한 스크롤)
- onPostScroll: 거의 소비하지 않음.
시나리오 2. LazyColumn 최상단에서 아래로 스크롤
- onPreScroll: 0 소비 (아래로 스크롤이므로)
- LazyColumn: 처리 불가 (이미 최상단)
- onPostScroll: 전체 소비 (툴바 즉시 표시)
시나리오 3. 빠르게 위로 스크롤
- onPreFling: 속도 > 1000일 때 30% 소비
- onPreScroll: 연속 호출로 툴바 빠르게 숨김
- LazyColumn: 남은 속도로 자연스러운 플링
- onPostFling: 추가 처리
시나리오 4. LazyColumn 최하단에서 아래로 스크롤
- onPreScroll: 0 소비
- LazyColumn: 처리 불가 (이미 최하단)
- onPostScroll: 전체 소비 (툴바 나타남)
영상을 찍어도 되겠지만, 영상으로 확인하면 그냥 단순히 스크롤되며 어떻게 동작하는지 알 수 없으므로 시나리오 별 이벤트 처리 순서 및 동작을 작성하였으니 실제로 예제를 보면서 확인하면 좋을 것이다.
이렇게 NestedScrollConnection 객체와 사용 방법에 대해서 알아보았는데,
사용 시에 주의할 점이 존재한다.
// 모든 스크롤을 onPreScroll에서 소비
return Offset(0f, available.y)
// 복잡한 계산을 스크롤 콜백 내에서 수행
override fun onPreScroll(...): Offset {
heavyCalculation() // 복잡한 계산을 수행하는 함수
return ...
}
// 과도한 플링 속도 소비
return Velocity(0f, available.y * 0.8f)
주석에도 추가해 두었지만, 위의 3가지는 가장 조심해야 하는 경우이다.
첫 번째의 경우 모든 스크롤을 부모에서 소비하게 되면 당연히 Child에서는 스크롤을 소비할 수 없게 된다.
즉, 필자의 예제에서는 LazyColumn이 스크롤이 발생하지 않게 된다.
두 번째는 당연하겠지만, 스크롤 시 해당 함수는 상당히 빠른 속도로 많이 호출되게 된다.
그렇게 호출되는 환경에서 복잡한 계산을 수행하게 되면 당연하다시피 앱의 성능이 상당히 떨어지고 부정확한 동작을 수행할 수 있기 때문에 당연히 해당 함수 내부에서는 복잡한 계산을 수행해서는 안된다.
마지막으로, Fling 함수에서 return 하는 값인데 플링을 처리하는 부분에서 과도하게 소비하게 되면 정말 부자연스러운 스크롤 동작이 되기 때문에 의도한 동작을 수행하는 것이 아니라면 Fling에 관련된 값은 크지 않도록 조심하면 될 것 같다.
이것으로, 간단하게 NestedScrollConnection의 동작 원리와, 해당 인터페이스를 사용한 NestedScroll에 대하여 알아보았다.
NestedScroll은 복잡해 보이지만, 스크롤이 처리되는 순서와 호출되는 함수를 정확히 이해하고 활용할 수 있다면 생각보다 쉽게 NestedScroll에 대해서 이해할 수 있지만, 원하는 스크롤 이벤트를 구현하기는 쉽지 않다고 생각한다.
물론, 필자도 해당 예제를 만들면서 여러 가지 테스트를 해보았는데, 생각보다 쉽게 스크롤 이벤트를 구현하는 것은 안 되는 것을 체감했다.
일단 구현해 두고, 그다음에 비율을 조절해 가면서 원하는 스크롤 속도나 이벤트를 처리하도록 해야 하므로 많은 빌드와 디버깅이 필요한 작업이라고 생각된다.
특히, Fling에 관련된 작업은 이론만 보았을 때 아 이런 거구나.라고 생각을 할 수 있겠지만 실제로 해당 부분에 무언가를 하려고 하면 원하는 대로 동작을 전혀 하지 않는다는 것을 느낄 수 있으니 자연스러운 스크롤과 관성을 생각한다면 필자는 해당 부분을 건드는 것은 최소로 하는 것이 좋다고 생각한다.
해당 게시글에 사용한 예제는 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.
This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample
github.com
'Android > Jetpack Compose' 카테고리의 다른 글
| [Android] Compose에서 런타임 언어 변경 구현하기 - 앱 재시작 없이 실시간 적용 (1) | 2025.07.09 |
|---|---|
| [Android] Compose 환경에서 데이터 init 방식에 대한 고찰. (2) | 2025.06.29 |
| [Android] Text Shimmer UI 구현하기 (0) | 2025.01.26 |
| [Android] 사용성 높은 StickyHeader 구현하기 (1) | 2024.10.24 |
| [Android] Shimmer UI 구현하기 (2) | 2024.10.06 |