App 개발 일지/Nutri Capture

Nutri Capture 프론트엔드 - 역방향 무한 스크롤

interfacer_han 2024. 10. 12. 05:30

#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

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com

 

#5-2 본 프로젝트의 가장 최신 Commit

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com