서론
중첩 스크롤(Nested Scroll)은 두 개 이상의 컴포넌트에서 스크롤 이벤트가 동시에 발생할 때, 우선순위와 처리 방식을 정의하는 기술입니다. 기존 View 시스템에서는 '슬라이딩 충돌'이라고 불리며, 외부 차단(External Intercept)과 내부 차단(Internal Intercept) 방식으로 해결했습니다. Jetpack Compose에서는 Modifier.nestedScroll 수정자를 통해 이 문제를 해결합니다.
1. Compose의 중첩 스크롤 처리 철학
Modifier.nestedScroll을 살펴보기 전에 Compose가 중첩 스크롤을 처리하는 기본 원리를 이해해야 합니다. 스크롤 이벤트가 발생하면, 해당 이벤트는 먼저 부모 컴포넌트로 전달되어 일부 또는 전체를 소비(consume)할 수 있습니다. 부모가 소비하고 남은 이벤트는 자식 컴포넌트가 처리합니다. 자식이 처리한 후에도 남은 이벤트가 있다면, 다시 부모 컴포넌트가 처리할 기회를 갖습니다.
이 과정은 세 단계로 나눌 수 있습니다:
- 1차 전달 (Pre-pass): 이벤트가 자식에서 부모로 전달됩니다.
- 2차 소비 (Consumption): 부모가 소비한 후 남은 이벤트를 자식이 처리합니다.
- 3차 후처리 (Post-pass): 자식이 소비하고 남은 이벤트가 다시 부모로 전달됩니다.
2. Modifier.nestedScroll
이제 Modifier.nestedScroll의 구조를 살펴보겠습니다.
fun Modifier.nestedScroll(
connection: NestedScrollConnection,
dispatcher: NestedScrollDispatcher? = null
): Modifier
이 수정자는 필수 파라미터 connection과 선택적 파라미터 dispatcher를 받습니다.
- connection: 중첩 스크롤 동작의 핵심 로직입니다. 자식이 스크롤 이벤트를 처리하기 전에 미리 일부 또는 전체를 소비하거나(
onPreScroll), 자식이 소비하고 남은 이벤트를 처리(onPostScroll)하는 콜백을 제공합니다. - dispatcher: 스크롤 이벤트를 부모에게 전달하는 역할을 합니다. 내부에 부모의
NestedScrollConnection을 참조하며,dispatch*메서드를 통해 부모에게 이벤트를 전달합니다.
2.1 NestedScrollConnection
NestedScrollConnection 인터페이스는 네 개의 콜백 메서드를 정의합니다.
interface NestedScrollConnection {
// 자식이 이벤트를 처리하기 전에 미리 소비
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
// 자식이 이벤트를 처리한 후 남은 이벤트를 소비
fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = Offset.Zero
// Fling(관성 스크롤) 시작 시 속도를 미리 소비
suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
// Fling 종료 시 남은 속도를 소비
suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = Velocity.Zero
}
Fling이란? 사용자가 빠르게 스크롤하고 손을 떼면, 리스트가 관성에 의해 일정 거리를 더 이동한 후 멈추는 현상을 말합니다. onPreFling은 손을 떼는 순간 호출되고, onPostFling은 이동이 완전히 멈춘 후 호출됩니다.
2.2 NestedScrollDispatcher
NestedScrollDispatcher는 자식 컴포넌트가 스크롤 이벤트를 부모에게 전달할 때 사용합니다. 주요 메서드는 다음과 같습니다.
fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
return parent?.onPreScroll(available, source) ?: Offset.Zero
}
fun dispatchPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
}
suspend fun dispatchPreFling(available: Velocity): Velocity {
return parent?.onPreFling(available) ?: Velocity.Zero
}
suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity {
return parent?.onPostFling(consumed, available) ?: Velocity.Zero
}
이 메서드들은 단순히 부모의 NestedScrollConnection에 있는 해당 메서드를 호출합니다.
3. 실전 예제
3.1 부모가 자식의 스크롤 이벤트를 먼저 소비
다음과 같은 레이아웃을 구현한다고 가정해봅시다.
- 상단에는 이미지가 있고, 하단에는 스크롤 가능한 리스트가 있습니다.
- 위로 스크롤하면 이미지가 먼저 축소되고, 최소 높이에 도달하면 리스트가 스크롤됩니다.
- 아래로 스크롤하면 이미지가 먼저 확장되고, 최대 높이에 도달하면 리스트가 스크롤됩니다.
- 이미지 자체는 터치 이벤트를 감지하지 않고, 리스트만 터치를 감지합니다.
@Composable
fun NestedScrollExample() {
val minHeight = 80.dp
val maxHeight = 200.dp
val density = LocalDensity.current
val minHeightPx = with(density) { minHeight.toPx() }
val maxHeightPx = with(density) { maxHeight.toPx() }
var imageHeightPx by remember { mutableStateOf(maxHeightPx) }
val scrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source != NestedScrollSource.Drag) return Offset.Zero
val deltaY = available.y
val targetHeight = if (deltaY < 0) minHeightPx else maxHeightPx
val remainingDistance = targetHeight - imageHeightPx
val consume = if (deltaY < 0) {
// 위로 스크롤
if (deltaY > remainingDistance) deltaY else remainingDistance
} else {
// 아래로 스크롤
if (deltaY < remainingDistance) deltaY else remainingDistance
}
imageHeightPx += consume
return Offset(0f, consume)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.nestedScroll(connection = scrollConnection)
) {
Image(
painter = painterResource(R.drawable.sample_image),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.fillMaxWidth()
.height(with(density) { imageHeightPx.toDp() })
)
LazyColumn {
items(50) { index ->
Text("항목 $index", modifier = Modifier.fillMaxWidth().padding(16.dp))
}
}
}
}
위 코드에서 onPreScroll은 리스트가 스크롤 이벤트를 받기 전에 호출됩니다. 여기서 이미지 높이를 변경하여 스크롤을 소비합니다. 리스트는 남은 스크롤만 처리하게 됩니다.
3.2 자식이 스크롤 이벤트를 분배 (Dispatcher 사용)
이번에는 이미지가 리스트(LazyColumn)의 아이템 중 하나로 포함된 경우를 생각해보겠습니다. 이미지를 드래그하면 이미지 크기가 변하고, 리스트는 스크롤되지 않아야 합니다.
@Composable
fun NestedScrollWithDispatcher() {
val minHeight = 80.dp
val maxHeight = 200.dp
val density = LocalDensity.current
val minHeightPx = with(density) { minHeight.toPx() }
val maxHeightPx = with(density) { maxHeight.toPx() }
var imageHeightPx by remember { mutableStateOf(maxHeightPx) }
val dispatcher = remember { NestedScrollDispatcher() }
val connection = remember { object : NestedScrollConnection {} }
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(5) { index ->
Text("아이템 $index")
}
item {
val draggableState = rememberDraggableState { delta ->
val preConsumed = dispatcher.dispatchPreScroll(Offset.Zero, NestedScrollSource.Drag)
val availableDelta = delta - preConsumed.y
val consume = if (availableDelta < 0) {
val diff = minHeightPx - imageHeightPx
if (availableDelta > diff) availableDelta else diff
} else {
val diff = maxHeightPx - imageHeightPx
if (availableDelta < diff) availableDelta else diff
}
imageHeightPx += consume
val remaining = delta - consume
dispatcher.dispatchPostScroll(
consumed = Offset(0f, consume),
available = Offset(0f, remaining),
source = NestedScrollSource.Drag
)
}
Image(
painter = painterResource(R.drawable.sample_image),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.fillMaxWidth()
.height(with(density) { imageHeightPx.toDp() })
.draggable(draggableState, Orientation.Vertical)
.nestedScroll(connection, dispatcher)
)
}
items(45) { index ->
Text("아이템 ${index + 6}")
}
}
}
여기서는 NestedScrollDispatcher를 사용하여 부모의 onPreScroll과 onPostScroll을 호출합니다. 이미지 자체가 드래그 이벤트를 감지하고, dispatcher를 통해 부모(LazyColumn)에게 이벤트를 전달하지만, 부모는 소비하지 않습니다. 따라서 이미지 크기만 변경됩니다.
3.3 분배 순서에 따른 처리
3.1 예제에서는 이미지가 터치 이벤트를 감지하지 않았습니다. 이번에는 이미지를 직접 드래그하여 크기를 조절할 수 있도록 수정해보겠습니다. 이를 위해 Column에 draggable 수정자를 추가하고, dispatcher를 사용합니다.
@Composable
fun AdvancedNestedScroll() {
val minHeight = 80.dp
val maxHeight = 200.dp
val density = LocalDensity.current
val minHeightPx = with(density) { minHeight.toPx() }
val maxHeightPx = with(density) { maxHeight.toPx() }
var imageHeightPx by remember { mutableStateOf(maxHeightPx) }
val dispatcher = remember { NestedScrollDispatcher() }
val scrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source != NestedScrollSource.Drag) return Offset.Zero
val deltaY = available.y
val target = if (deltaY < 0) minHeightPx else maxHeightPx
val diff = target - imageHeightPx
val consume = if (deltaY < 0) {
if (deltaY > diff) deltaY else diff
} else {
if (deltaY < diff) deltaY else diff
}
imageHeightPx += consume
return Offset(0f, consume)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.draggable(
state = rememberDraggableState { delta ->
val preConsumed = dispatcher.dispatchPreScroll(Offset(0f, delta), NestedScrollSource.Drag)
val remaining = delta - preConsumed.y
val consume = if (remaining < 0) {
val diff = minHeightPx - imageHeightPx
if (remaining > diff) remaining else diff
} else {
val diff = maxHeightPx - imageHeightPx
if (remaining < diff) remaining else diff
}
imageHeightPx += consume
val finalRemaining = delta - consume
dispatcher.dispatchPostScroll(
consumed = Offset(0f, consume),
available = Offset(0f, finalRemaining),
source = NestedScrollSource.Drag
)
},
orientation = Orientation.Vertical
)
.nestedScroll(connection = scrollConnection, dispatcher = dispatcher)
) {
Image(
painter = painterResource(R.drawable.sample_image),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier
.fillMaxWidth()
.height(with(density) { imageHeightPx.toDp() })
)
LazyColumn {
items(50) { index ->
Text("아이템 $index", modifier = Modifier.fillMaxWidth())
}
}
}
}
이제 이미지를 드래그하면 이미지 크기가 변하고, 리스트 영역을 드래그하면 리스트가 스크롤됩니다. dispatcher는 이벤트를 부모와 자식 간에 적절히 분배합니다.
핵심 정리
- 중첩 스크롤 개념: 스크롤 이벤트는 부모가 먼저 소비할 기회를 얻고, 남은 이벤트를 자식이 처리합니다. 자식 처리 후 남은 이벤트는 다시 부모가 처리합니다.
- NestedScrollConnection: 자식이 감지한 스크롤 이벤트를 부모가 선점하거나(
onPreScroll), 자식이 처리한 후 남은 이벤트를 처리(onPostScroll)할 때 사용합니다. Fling 처리도 유사합니다. - NestedScrollDispatcher: 부모가 아닌 자식이 스크롤 이벤트를 직접 감지할 때, dispatcher를 통해 부모에게 이벤트를 전달합니다.
- Fling: 관성 스크롤 이벤트도 동일한 매커니즘으로 처리됩니다. Compose는
ScrollableDefaults.flingBehavior()와 같은 기본 구현을 제공합니다.