1. nestedScroll이란?
- 여러개의 스크롤 가능한 요소가 겹쳐 있을 때 스크롤 이벤트를 어떻게 나누어 가질지 협력하는 매커니즘
- 겹쳐 있다는 것은 스크롤 가능한 부모컴포저블의 content에 스크롤 가능한 자식컴포저블이 있는 경우를 말함
- 대표적인 예시로 유투브 댓글창을 계속 위로 스크롤하다가 첫번째 댓글까지 간 후 또 스크롤하면 댓글창이 축소되는 경우가 있는데 이런 모습을 구현하고 싶을 때(BottomSheet + CoordinatorLayout을 쓰는 다른 방법도 있다고 하는데 직관적인 예시라 적어둠)
2. nestedScroll이 없다면?
1) 같은 방향의 스크롤이 겹친 경우
- 예를들어 LazyColumn 안에 또다른 LazyColumn이 있는 경우
- 예시 케이스로 작성하긴 하는데 애초에 이런식으로 구현할일은 없을 듯..?
- 자식 컴포저블이 터치 이벤트를 완전히 독점하게 됨
- 자식 컴포저블을 끝까지 스크롤한 후 더 스크롤해도 부모 컴포저블은 스크롤되지 않음
2) 다른 방향의 스크롤이 겹친 경우
- 예를들어 LazyColumn 안에 LazyRow가 있는 경우
- 터치 방향에 따라 이벤트를 누가 가져갈지 결정됨
- 대각선으로 스크롤하는 경우 처음에 어느 방향으로 더 많이 움직였는지에 따라 결정
3. nestedScroll을 사용한다면?
- 자식 컴포저블을 터치했을 때 부모 컴포저블에게 먼저 스크롤하게 넘길수도 있고, 자식 컴포저블을 스크롤한 후 남은만큼 부모 컴포저블이 스크롤되게 할 수도 있음
4. 사용 방법
1) 부모 입장에서의 조율
- NestedScrollConnection 사용
- 부모 컴포저블이 자식의 스크롤 신호를 어떻게 처리할지 정의
- 4가지 주요 메서드 사용
- onPreScroll : 자식이 스크롤하기 전에 호출됨, 부모가 먼저 스크롤의 일부를 소모할 수 있음
- onPostScroll : 자식이 스크롤을 마친 후 남은 양이 있을 때 호출됨
- onPreFling : 사용자가 스크롤 후 손을 뗐을 때 발생하는 관성(Fling) 스크롤 직전에 호출됨
- onPostFling : 관성 스크롤이 끝난 후 호출됨
// 1. NestedScrollConnection 정의
val nestedScrollConnection = remember {
object : NestedScrollConnection {
// 1. onPreScroll: 자식(리스트)이 스크롤되기 '전'에 호출됨
// 부모가 먼저 스크롤 소모하려면 여기서 처리
override fun onPreScroll(
available: Offset, // 사용자가 움직인 거리(음수 : 위로 스크롤, 양수 : 아래로 스크롤)
source: NestedScrollSource // 스크롤의 출처(Drag, Fling, Relocate, Wheel)
): Offset {
val delta = available.y
return if (delta < 0) { // 위로 스크롤했다면 모두 소모하고 자식에게 전달하지 않음
Offset(x = 0f, y = delta)
} else {
Offset.Zero // 아래로 스크롤할 때는 아무것도 소모하지 않음
}
}
// 2. onPostScroll: 자식이 스크롤을 '마친 후'에 호출됨
// 자식이 쓰고 남은 스크롤이 부모에게 전달됨
override fun onPostScroll(
consumed: Offset, // 이미 사용된 스크롤 양
available: Offset, // 남아서 사용 가능한 스크롤 양
source: NestedScrollSource // 스크롤의 출처
): Offset {
val delta = available.y
return Offset(x = 0f, y = delta)
}
// 3. onPreFling: 사용자가 손을 뗐을 때 발생하는 관성(Fling) 시작 '전'에 호출됨
// 특정 조건에서 관성 스크롤을 부모가 가로채서 중단시키거나 직접 처리할때 사용
override suspend fun onPreFling(
available: Velocity // 관성 발생 초기의 속도(pixels per second)
): Velocity {
return Velocity.Zero // 본인이 소모할 관성 속도, 소모하지 않았으므로 자식에게 모두 전달됨
}
// 4. onPostFling: 자식의 관성 스크롤이 '끝난 후'에 호출됨
// 관성 에너지가 아직 남아있다면 부모가 관성을 이어받을 수 있음
override suspend fun onPostFling(
consumed: Velocity, // 이미 사용된 관성 속도
available: Velocity // 남아서 사용 가능한 관성 속도
): Velocity {
return available // 본인이 소모할 관성 속도
}
}
}
// 2. 부모 컨테이너에 nestedScroll Modifier 적용
Box(
Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection) // 이렇게 Modifier에 적용
) {
// 자식 스크롤 요소 (LazyColumn 등)
LazyColumn() {
items(100) {
Text(
"Item $it",
modifier = Modifier.padding(16.dp)
)
}
}
}
2) 자식 입장에서의 전달
- NestedScrollDispatcher 사용
- 자식 컴포저블의 스크롤 이벤트를 부모에게 알리고 싶을 때 사용
- 대부분의 스크롤 컴포넌트에 이미 내장되어 있어서 직접 구현할일은 적음
@Composable
fun CustomScrollingChild(modifier: Modifier = Modifier) {
val dispatcher = remember { NestedScrollDispatcher() } // 1. 디스패처 생성
var offset by remember { mutableStateOf(0f) }
Box(
modifier = modifier
// 2. 부모의 Connection과 나의 Dispatcher 연결
// (자식 컴포넌트이지만 dispatcher를 넘겨줌으로써 부모에게 보고할 준비를 함)
.nestedScroll(connection = object : NestedScrollConnection {}, dispatcher = dispatcher)
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consume()
val delta = Offset(0f, dragAmount.y)
// (A) 부모에게 먼저 물어봄 (PreScroll)
val parentsPreConsumed = dispatcher.dispatchPreScroll(
available = delta,
source = NestedScrollSource.UserInput
)
// (B) 부모가 쓰고 남은 양 계산
val leftForMe = delta - parentsPreConsumed
// (C) 자식(나)이 실제로 스크롤을 수행
val oldOffset = offset
offset += leftForMe.y
val myConsumed = Offset(0f, offset - oldOffset)
// (D) 내가 쓰고 남은 양 계산
val leftForParent = leftForMe - myConsumed
// (E) 부모에게 남은 양을 전달 (PostScroll)
dispatcher.dispatchPostScroll(
consumed = myConsumed + parentsPreConsumed,
available = leftForParent,
source = NestedScrollSource.UserInput
)
}
)
}
.offset { IntOffset(0, offset.roundToInt()) }
)
}
