#1 개요
기존에는 무한 스크롤을 위한 암시적 스크롤 로직을 ViewModel과 View과 양분하고 있었다. 본 게시글에서 이 스크롤 관련한 로직을 View에 일임한다. ViewModel은 이제 LazyColumn이 보유할 아이템 리스트의 추가ㆍ제거만 수행하면 될 것이다.
#2 코드
#2-1 ViewModel에서의 Scroll 관련 코드 삭제
...
class NutrientViewModel : ViewModel() {
// (1) 화면 표시용 State
...
// (2) ViewModel용 내부 변수
...
// (3) View에서 받아 처리할 이벤트
...
// (4) View로부터 받은 이벤트 처리
fun onEvent(event: NutrientViewModelEvent) {
when (event) {
is NutrientViewModelEvent.InitializeState -> {
...
}
is NutrientViewModelEvent.LoadMoreItemsAfterLastDate -> {
...
}
is NutrientViewModelEvent.LoadMoreItemsBeforeFirstDate -> {
val firstDate = _nutrientScreenState.value.dailyMeals.first().date
_nutrientScreenState.value.dailyMeals.add(
0,
DailyMeal(
date = firstDate.minusDays(1),
meals = SnapshotStateList()
)
)
/* 제거
viewModelScope.launch {
_nutrientScreenEventFlow.emit(NutrientScreenEvent.RequestScrollToItem(1))
}
*/
}
}
}
}
ViewModel부터 깔끔하게 만든다.
#2-2 NutrientScreenEvent.RequestScrollToItem 삭제
...
sealed class NutrientScreenEvent {
data class ShowSnackbar(val message: String) : NutrientScreenEvent()
/* 제거
data class RequestScrollToItem(val index: Int) : NutrientScreenEvent()
*/
}
이제 필요없는 이벤트이므로 삭제한다.
#2-3 실패한 시도: 새로운 LaunchedEffect() 영역 생성
...
@Composable
fun NutrientScreen(
...
) {
LaunchedEffect(key1 = true) {
// State 초기화
...
// ViewModel로부터 받은 이벤트 처리
...
}
LaunchedEffect(key1 = viewModel.nutrientScreenState.value.dailyMeals.getOrNull(0)) {
// 역방향 무한 스크롤 구현을 위한 암시적 스크롤
listState.requestScrollToItem(1, listState.firstVisibleItemScrollOffset)
}
LaunchedEffect(key1 = viewModel.isInitialized.value) {
if(viewModel.isInitialized.value) {
// 무한 스크롤
...
}
}
LazyColumn(
...
) {
...
}
}
[아이템 리스트 추가 → Recomposition]을 [아이템 리스트 추가 → requestScrollToItem → Recomposition]으로 만드는 것이 목적인데, 위 코드와 같이 새로운 LaunchedEffect() 영역을 선언하면 이 순서가 제대로 지켜지지 않는다. LaunchedEffect()는 코루틴 영역을 생성하는 함수(이 게시글의 #2 참조)고, 따라서 비동기적이기 때문이다. 위 코드대로 앱을 실행시켜보면 0.1초의 버벅임이 눈에 보인다.
#2-4 성공한 시도: 기존 무한 스크롤 관련 LaunchedEffect() 수정
...
@Composable
fun NutrientScreen(
...
) {
LaunchedEffect(key1 = true) {
// State 초기화
...
// ViewModel로부터 받은 이벤트 처리
...
}
LaunchedEffect(key1 = viewModel.isInitialized.value) {
if(viewModel.isInitialized.value) {
// 무한 스크롤
snapshotFlow { listState.layoutInfo.visibleItemsInfo }.collect { visibleItemsInfo ->
...
if(totalMaxIndex <= firstVisibleItemIndex + visibleItemCount) {
...
}
if(firstVisibleItemIndex == 0) {
viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsBeforeFirstDate)
// 역방향 무한 스크롤 구현을 위한 암시적 스크롤
listState.requestScrollToItem(1, listState.firstVisibleItemScrollOffset)
}
}
}
}
LazyColumn(
...
) {
...
}
}
아이템 리스트에 아이템을 추가하는 요청인 viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsBeforeFirstDate) 이후 바로 requestScrollToItem을 동기적으로 수행하기 때문에 [아이템 리스트 추가 → requestScrollToItem → Recomposition]라는 순서가 반드시 지켜진다. 즉 1프레임조차 버벅이는 순간이 없다는 얘기다.
#2-5 ViewModel에 있던 'delay(100)' 제거
...
class NutrientViewModel : ViewModel() {
// (1) 화면 표시용 State
...
// (2) ViewModel용 내부 변수
...
// (3) View에서 받아 처리할 이벤트
...
// (4) View로부터 받은 이벤트 처리
fun onEvent(event: NutrientViewModelEvent) {
when (event) {
is NutrientViewModelEvent.InitializeState -> {
...
repeat(20) {
...
}
/* 제거
viewModelScope.launch {
delay(100)
_isInitialized.value = true
}
*/
_isInitialized.value = true // 추가
}
is NutrientViewModelEvent.LoadMoreItemsAfterLastDate -> {
...
}
is NutrientViewModelEvent.LoadMoreItemsBeforeFirstDate -> {
...
}
}
}
}
이전 게시글에선, 초기 화면에서 이전 날이 첫 아이템으로 나오는 버그가 있었다. 이 문제 해결을 위해 ViewModel에 의미 없는 delay(100) 코드를 넣어주었는데 이 부분을 제거한다. ViewModel과 View의 명확한 역할 분리로 이제 스크롤이 꼬일 일이 없기 때문이다.
#3 요약
ViewModel이 짊어질 필요가 없는 스크롤 관련 로직을 View에 일임했다.
#4 완성된 앱
#4-1 작동 영상
이전 게시글과 동일하게 작동한다. 물론, 내부적으론 더 가볍고 직관적으로 변했다.
#4-2 이 게시글 시점의 Commit
#4-3 본 프로젝트의 가장 최신 Commit
'App 개발 일지 > Nutri Capture' 카테고리의 다른 글
Nutri Capture 백엔드 - 새 ERD와 Room의 @Entity 정의 (1) | 2024.10.23 |
---|---|
Nutri Capture 프론트엔드 - Card (0) | 2024.10.17 |
Nutri Capture 프론트엔드 - requestScrollToItem()을 이용한 깔끔한 역방향 무한 스크롤 (0) | 2024.10.16 |
Nutri Capture 프론트엔드 - 역방향 무한 스크롤 (1) | 2024.10.12 |
Nutri Capture 프론트엔드 - 무한 스크롤 로직 분리 (0) | 2024.10.12 |