Compose 레이지 리스트 스크롤바는 내부에서 어떻게 동작하는가
Compose 레이지 리스트 스크롤바는 내부에서 어떻게 동작하는가
Compose는 부드러운 스크롤은 제공하지만, 정작 거기 어울리는 스크롤바까지 챙겨 주지는 않습니다. LazyColumn이나 LazyRow, LazyVerticalGrid는 콘텐츠 위를 미끄러지듯 지나가지만, 지금 내가 어디쯤 있는지 알려 주는 썸(thumb, 스크롤바의 손잡이)이 옆에 없고, 그 썸을 붙잡아 긴 목록의 한가운데로 휙 던질 방법도 없습니다. 예전 뷰 시스템에서는 RecyclerView의 패스트 스크롤러(fast scroller)가 이 역할을 거저 해 줬던 터라, 그 빈자리가 더 크게 느껴집니다. 이 빈틈을 메우는 가장 깔끔한 참조 구현은 Now in Android 샘플에 들어 있는데, 뜯어볼 만한 가치가 충분합니다. 레이지 리스트를 위한 스크롤바를 만드는 일이 알고 보면 제법 흥미로운 문제이기 때문입니다. 레이지 리스트는 지금 눈에 보이는 항목만 배치합니다. 화면 아래에 있는 만 개의 항목이 얼마나 큰지는 전혀 알지 못하므로, 스크롤바가 기대는 '콘텐츠 높이 대 뷰포트 높이'라는 흔한 계산은 애초에 쓸 수가 없습니다.
이 글에서는 레이지 리스트 스크롤바가 어떻게 동작하는지를 깊이 있게 파고듭니다. 썸의 기하 정보가 어떻게 하나의 값으로 패킹되는지, 썸의 크기와 위치가 어떻게 보이는 항목만으로 도출되는지, 썸이 항목 단위로 툭툭 건너뛰지 않도록 부드러운 소수 인덱스가 어떻게 보간되는지, 썸이 커스텀 레이아웃에서 어떻게 측정되고 배치되는지, 썸을 드래그하는 동작이 어떻게 다시 스크롤로 이어지는지, 트랙을 누르면 목록이 어떻게 조금씩 스크롤되는지, 그리고 썸이 상호작용에 따라 어떻게 서서히 나타났다 사라지는지를 차례대로 살펴봅니다. 여기 실린 코드는 실제 구현으로, 배포된 그대로 인용했습니다.
근본적인 문제: 보이는 것만 측정하는 목록을 위한 스크롤바
전통적인 스크롤바는 비율 문제입니다. 전체 콘텐츠 높이와 뷰포트 높이를 알고 있으니, 썸의 크기는 viewport / content이고 썸의 위치는 scrollOffset / (content - viewport)입니다. 레이지 리스트에서는 이 방식이 통하지 않습니다. 전체 콘텐츠를 한 번도 측정하지 않기 때문입니다. 어느 순간에도 레이지 리스트는 보이는 항목과 약간의 버퍼만 배치해 두며, 이는 layoutInfo.visibleItemsInfo로 노출됩니다. 위아래의 항목들은 측정되지 않고, 심지어 높이가 서로 같지 않을 수도 있습니다. 나눗셈의 분모로 삼을 전체 콘텐츠 높이가 없는 셈입니다.
빠져나갈 길은, 픽셀 단위로 생각하기를 멈추고 항목 인덱스 단위로 생각하기 시작하는 것입니다. 목록에 itemsAvailable개의 항목이 있고 첫 번째로 보이는 항목이 인덱스 firstIndex에 있다면, 썸은 전체 중 firstIndex / itemsAvailable만큼 내려온 것이고, 화면에 itemsVisible개의 항목이 들어간다면 썸은 트랙의 itemsVisible / itemsAvailable만큼을 덮어야 합니다. 둘 다 0과 1 사이의 비율값이며, 둘 다 보이는 항목만으로 계산할 수 있습니다. 남은 구현은 이 두 비율값을 정확히 도출하고, 값싸게 저장하고, 다시 그려지는 썸과 스크롤로 되돌리는 일입니다.
ScrollbarState: 하나의 Long에 담은 썸 기하 정보
스크롤바가 자신을 그리는 데 필요한 것은 두 개의 숫자가 전부입니다. 트랙 대비 썸이 얼마나 큰지, 그리고 트랙 대비 썸이 얼마나 이동했는지입니다. 이 구현은 두 개의 Float 상태를 들고 있는 대신, 둘을 하나의 Long에 패킹해 ScrollbarState로 노출합니다.
class ScrollbarState {
private var packedValue by mutableLongStateOf(0L)
internal fun onScroll(stateValue: ScrollbarStateValue) {
packedValue = stateValue.packedValue
}
val thumbSizePercent
get() = unpackFloat1(packedValue)
val thumbMovedPercent
get() = unpackFloat2(packedValue)
val thumbTrackSizePercent
get() = 1f - thumbSizePercent
}
두 비율값은 unpackFloat1과 unpackFloat2로 다시 읽어 내고, 세 번째 값인 thumbTrackSizePercent는 1f - thumbSizePercent로 도출됩니다. 이는 썸이 자유롭게 움직일 수 있는 트랙의 비율을 뜻합니다. 패킹된 값은 두 float을 하나의 Long에 써 넣는 작은 팩토리가 만들어 냅니다.
fun scrollbarStateValue(
thumbSizePercent: Float,
thumbMovedPercent: Float,
) = ScrollbarStateValue(
packFloats(
val1 = thumbSizePercent,
val2 = thumbMovedPercent,
),
)
두 float을 하나의 Long에 패킹하는 것은 그저 멋으로 하는 일이 아닙니다. mutableLongStateOf는 박싱 없이 원시 값을 담고, Long 하나를 갱신하는 일은 단일한 원자적 쓰기이므로, 썸의 크기와 위치는 언제나 함께 움직이고 읽는 쪽은 크기는 새 값인데 위치는 옛 값인, 절반만 갱신된 상태를 결코 보지 않습니다. ScrollbarStateValue와 뒤에 등장하는 내부의 ScrollbarTrack은 둘 다 Long을 감싼 @JvmInline value class 래퍼이고, 저장된 ScrollbarState는 패킹된 값을 mutableLongStateOf에 간직하므로, 썸의 기하 정보는 두 float을 박싱하지 않고 갱신됩니다.
보이는 항목에서 썸을 도출하기
두 비율값은 LazyListState의 scrollbarState 확장 함수에서 계산됩니다. 이 함수는 snapshotFlow로 레이아웃을 지켜보다가, 새 값이 나올 때마다 ScrollbarState에 밀어 넣습니다.
@Composable
fun LazyListState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,
): ScrollbarState {
val state = remember { ScrollbarState() }
LaunchedEffect(this, itemsAvailable) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
// ... firstIndex, itemsVisible, 그리고 두 퍼센트 값을 계산
}
.filterNotNull()
.distinctUntilChanged()
.collect { state.onScroll(it) }
}
return state
}
snapshotFlow 안에서 layoutInfo를 읽는 것이 스크롤바를 반응형으로 만들어 줍니다. 목록이 스크롤될 때마다 레이아웃 스냅샷이 바뀌고, 블록이 다시 실행되며, 새 ScrollbarStateValue가 흘러나옵니다. distinctUntilChanged는 중복 방출을 걸러 내어 실제로 무언가 움직였을 때에만 썸이 갱신되게 하고, filterNotNull은 그릴 것이 없는 빈 경우, 곧 빈 목록이거나 보이는 항목이 없는 목록을 건너뜁니다.
그 블록 안에서 두 비율값은 이렇게 하나로 모입니다.
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { it.size },
offset = { it.offset },
nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } },
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = itemInfo.size,
itemStartOffset = itemInfo.offset,
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(a = firstIndex / itemsAvailable, b = 1f)
val thumbSizePercent = min(a = itemsVisible / itemsAvailable, b = 1f)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
)