#1 개요
이전 게시글에서 역방향 무한 스크롤을 불완전하게 구현했었다. 문제점은 다음과 같았다.
1
눈에 보이는 Item의 인덱스 중 0이 존재하면, 새로운 Item을 Load했다.
2
이러면 새로 Load된 Item의 인덱스가 다시 0이되면서 동시에 눈에 보이게 된다. 따라서 무한 재귀호출이 발생했었다.
3
이 문제를 해결하려면 새 Item을 Load함과 동시에 스크롤을 조작해야 한다. 그렇게 해서 '새 아이템이 눈에 보이기 전에' 스크롤을 성공시킨다면 무한 재귀호출에서 벗어날 수 있다.
본 게시글에선 3 의 상태를 만드는 걸 목표로 잡는다.
#2 코드
#2-1 깨알 변경
...
@Composable
fun NutrientScreen(
scope: CoroutineScope,
snackbarHostState: SnackbarHostState,
viewModel: NutrientViewModel = viewModel<NutrientViewModel>(),
listState: LazyListState = rememberLazyListState()
) {
...
}
프로퍼티 listState의 위치를 생성자로 옮겼다. 이 편이 더 깔끔해보였기 때문이다.
#2-2 이벤트 선언
// package com.example.nutri_capture_new.nutrient
sealed class NutrientScreenEvent {
...
data class ScrollToItem(val index: Int) : NutrientScreenEvent()
}
ViewModel.onEvent()에 이벤트 LoadMoreItemsBeforeFirstDate가 인수로서 전달되면, LazyColumn이 표시할 아이템 리스트의 앞 부분에 새 Item을 넣어주었다. 새 Item을 넣은 직후에 #1에서 말한 스크롤 작업을 View에 요청해야 한다. 해당 요청을 위한 이벤트를 선언한다.
#2-3 이벤트 구현
...
class NutrientViewModel : ViewModel() {
...
// (4) View로부터 받은 이벤트 처리
fun onEvent(event: NutrientViewModelEvent) {
when (event) {
...
is NutrientViewModelEvent.LoadMoreItemsBeforeFirstDate -> {
Log.i("interfacer_han", "(이벤트 LoadMoreItemsBeforeFirstDate) 시작 (아이템 갯수: ${_nutrientScreenState.value.dailyMeals.size})")
val firstDate = _nutrientScreenState.value.dailyMeals.first().date
_nutrientScreenState.value.dailyMeals.add(
0,
DailyMeal(
date = firstDate.minusDays(1),
meals = SnapshotStateList()
)
)
Log.i("interfacer_han", "(이벤트 LoadMoreItemsBeforeFirstDate) 끝 (아이템 갯수: ${_nutrientScreenState.value.dailyMeals.size})")
viewModelScope.launch {
Log.i("interfacer_han", "(이벤트 ScrollToItem) 호출")
_nutrientScreenEventFlow.emit(NutrientScreenEvent.ScrollToItem(1))
}
}
}
}
}
viewModelScope에서 StateFlow.emit()을 수행한다. 덕지덕지 붙어있는 Log문은 함수 호출 순서를 파악하기 위함이다. #1에서 '새 아이템이 눈에 보이기 전에' 스크롤이 완료되어야 한다고 했는데, 현 프로젝트처럼 비동기 코드가 많이 쓰인 프로젝트의 경우 내가 의도한 순서대로 실제 앱이 작동하지 않을 수 있다. 따라서 Log을 통해 함수 호출 순서를 명확히 알아두어야 한다.
#2-4 이벤트 받기
...
@Composable
fun NutrientScreen(
...
) {
LaunchedEffect(key1 = true) {
// State 초기화
...
// ViewModel로부터 받은 이벤트 처리
viewModel.nutrientScreenEventFlow.collectLatest { event ->
when (event) {
...
is NutrientScreenEvent.ScrollToItem -> {
Log.i("interfacer_han", "(이벤트 ScrollToItem) 시작 (아이템 갯수: ${viewModel.nutrientScreenState.value.dailyMeals.size})")
listState.scrollToItem(event.index)
Log.i("interfacer_han", "(이벤트 ScrollToItem) 끝 (아이템 갯수: ${viewModel.nutrientScreenState.value.dailyMeals.size})")
}
}
}
}
LaunchedEffect(key1 = viewModel.isInitialized.value) {
...
}
LazyColumn(
...
) {
val dailyMeals = viewModel.nutrientScreenState.value.dailyMeals
Log.i("interfacer_han", "(LazyColumn Recomposition) 아이템 갯수: ${dailyMeals.size}")
items(...) { ... ->
...
}
}
}
View에서 이벤트를 받는다. 마찬가지로 Log문을 붙여준다. 또, Recomposition 타이밍도 알기 위해서 LazyColumn 쪽에도 Log문을 넣었다.
#2-5 로그 파악
...
(이벤트 LoadMoreItemsBeforeFirstDate) 호출
(이벤트 LoadMoreItemsBeforeFirstDate) 시작 (아이템 갯수: 44)
(이벤트 LoadMoreItemsBeforeFirstDate) 끝 (아이템 갯수: 45)
(이벤트 ScrollToItem) 호출
(LazyColumn Recomposition) 아이템 갯수: 45
(이벤트 ScrollToItem) 시작 (아이템 갯수: 45)
(이벤트 ScrollToItem) 끝 (아이템 갯수: 45)
(이벤트 LoadMoreItemsBeforeFirstDate) 호출
(이벤트 LoadMoreItemsBeforeFirstDate) 시작 (아이템 갯수: 45)
(이벤트 LoadMoreItemsBeforeFirstDate) 끝 (아이템 갯수: 46)
(이벤트 ScrollToItem) 호출
(LazyColumn Recomposition) 아이템 갯수: 46
(이벤트 ScrollToItem) 시작 (아이템 갯수: 46)
(이벤트 ScrollToItem) 끝 (아이템 갯수: 46)
(이벤트 LoadMoreItemsBeforeFirstDate) 호출
(이벤트 LoadMoreItemsBeforeFirstDate) 시작 (아이템 갯수: 46)
(이벤트 LoadMoreItemsBeforeFirstDate) 끝 (아이템 갯수: 47)
(이벤트 ScrollToItem) 호출
(LazyColumn Recomposition) 아이템 갯수: 47
(이벤트 ScrollToItem) 시작 (아이템 갯수: 47)
(이벤트 ScrollToItem) 끝 (아이템 갯수: 47)
...
사실 해당 로그는 성공적으로 동작하는 스크롤 코드를 만들기 위한 과정의 부산물이다. 이 게시글을 쓰는 시점에서는 위 로그들이 이미 성공한 코드의 리뷰(?)처럼 보인다. 어찌됐건, 위 로그를 보면 새 Item을 추가하는 이벤트인 LoadMoreItemsBeforeFirstDate 이후 LazyColumn의 Recomposition과 ScrollToItem 이벤트가 수행되는 모습을 확인할 수 있다. Recomposition이 ScrollToItem 완료보다 더 먼저 수행되는 것처럼 로그가 출력되었지만 실제론 반대일 것이다. 반대로 출력된 이유는 로그를 출력하는 시점의 문제일 것으로 보인다. 무슨 말이냐면 LazyColumn Recomposition이라는 로그가 정말 LazyColumn이 Recomposition되는 정밀한 시점에 출력되는 게 아닐 거라는 얘기다. 아무튼, ScrollToItem은 LazyColumn에 생성자 주입될 LazyListState를 조작한다. 따라서, Recomposition되는 시점에선 화면에 새 Item이 그려지기 전에 (= 눈에 보이기 전에) 스크롤이 수행된다.
#3 새로운 문제점
#3-1 문제의 작동 영상
#3-2 문제의 원인
이 문제는 사용자의 터치에 의해 발생한다. 스크롤을 위로 올리는 동작을 하기 위해서 스크롤을 0.3초 정도 단위로 끊어서 하면 1 상태에서 2 상태로 잘 이동한다. 하지만, 스크롤을 유지한 채로 (= 손을 터치 스크린에서 떼지 않은 채로) 가만히 냅두면 스크롤 고정 상태가 되어 #2에서 공들여 만든 암시적 스크롤 이벤트가 먹통이 되어 버린다. 따라서 이 문제를 해결하려면 사용자의 스크롤 요청을 차단하는 방식 등이 요구될 것으로 보인다. 다음 게시글에서 바로 문제 해결에 착수하겠다.
#4 요약
무한 스크롤을 일으키던 기존의 문제 원인을 제거했다. 하지만, 새로운 문제가 나왔다.
#5 완성된 앱
#5-1 이 게시글 시점의 Commit
#5-2 본 프로젝트의 가장 최신 Commit
'App 개발 일지 > Nutri Capture' 카테고리의 다른 글
Nutri Capture 프론트엔드 - 스크롤 로직 View에 일임 (0) | 2024.10.16 |
---|---|
Nutri Capture 프론트엔드 - requestScrollToItem()을 이용한 깔끔한 역방향 무한 스크롤 (0) | 2024.10.16 |
Nutri Capture 프론트엔드 - 무한 스크롤 로직 분리 (0) | 2024.10.12 |
Nutri Capture 프론트엔드 - NutrientScreen의 무한 스크롤 (1) | 2024.10.11 |
Nutri Capture 프론트엔드 - NutrientScreen 구조 잡기 (1) | 2024.10.04 |