App 개발 일지/Nutri Capture

Nutri Capture 프론트엔드 - requestScrollToItem()을 이용한 깔끔한 역방향 무한 스크롤

interfacer_han 2024. 10. 16. 09:36

#1 스크롤 함수 변경

#1-1 기존 함수

LazyListState.scrollToItem() 및 LazyListState.scrollBy()는, 시스템 상의 제약이 존재한다. 사용자가 화면에 손을 붙인 채로 유지하면 스크롤이 아예 잠겨버리기 때문이다. 이 시스템 상의 제약을 우회하려고 정말 많은 코드를 시도해보았지만, 제대로 작동하기 않았고 작동하더라도 앱이 굉장히 조잡해보이는 모양새였다.
 

#1-2 LazyListState.requestScrollToItem()

LazyListState  |  Android Developers

developer.android.com

그러다 찾은 함수가 LazyListState.requestScrollToItem()다. 다음 Recomposition 때 스크롤이 위치해야하는 부분을 지정하는 함수로, LazyListState.scrollToItem() 및 LazyListState.scrollBy()처럼 명령적으로 스크롤을 조작하는 게 아니라 선언적으로 Compose Runtime에게 스크롤 위치를 부탁하는 함수다. '스크롤 직접 조작 명령'은 사용자의 터치 이벤트에 의해 우선 순위가 밀려 Blocking될 수 있지만, Recomposition 시의 결정되는 스크롤 위치는 사용자의 터치 이벤트과 독립적이다. 따라서 내가 구현하고자 했던 매우 깔끔하고 매끄러운 역방향 무한 스크롤이 구현된다. 또, LazyListState.requestScrollToItem()는 LazyListState.scrollToItem() 및 LazyListState.scrollBy()처럼 비동기 코드가 아니라 동기 코드다 (즉, suspend 키워드가 붙지 않는다). requestScrollToItem()은 다음 Recomposition 때의 작업을 요청하는 함수이기 때문이다.
 
... 처음부터 공식 문서를 꼼꼼히 읽을 걸 그랬다. 이미 라이브러리에 존재하는 기능을 혼자서 구현해보려고 며칠간 쩔쩔맨 꼴이 아닌가. 아무래도 영어라서 그런 것도 같다. 그럼에도 번역기까지 동원해서 읽을 가치는 충분하다고 생각한다. 몇 시간의 공식 문서 정독이 몇십 시간을 아껴준다. 
 

#2 코드

#2-1 NutrientScreenEvent.ScrollToItem 이벤트 이름 변경 및 구현 내용 수정

...

@Composable
fun NutrientScreen(
    ...
) {
    LaunchedEffect(key1 = true) {
        // State 초기화
        ...

        // ViewModel로부터 받은 이벤트 처리
        viewModel.nutrientScreenEventFlow.collectLatest { event ->
            when (event) {
                is NutrientScreenEvent.ShowSnackbar -> {
                    ...
                }

                is NutrientScreenEvent.RequestScrollToItem -> {
                    Log.i("interfacer_han", "(이벤트 ScrollToItem) 시작 (아이템 갯수: ${viewModel.nutrientScreenState.value.dailyMeals.size})")
                    listState.requestScrollToItem(event.index, listState.firstVisibleItemScrollOffset)
                    Log.i("interfacer_han", "(이벤트 ScrollToItem) 끝 (아이템 갯수: ${viewModel.nutrientScreenState.value.dailyMeals.size})")
                }
            }
        }
    }

    LaunchedEffect(key1 = viewModel.isInitialized.value) {
        ...
    }

    LazyColumn(
        ...
    ) {
        ...
    }
}

NutrientScreenEvent.ScrollToItem를 NutrientScreenEvent.RequestScrollToItem로 바꾼다. 구현 내용에 있던 listState.scrollToItem()은 삭제하고 requestScrollToItem()을 대신 사용한다.
 

public final fun requestScrollToItem(
    @IntRange(from = 0.toLong()) index: Int,
    scrollOffset: Int = 0
): Unit

requestScrollToItem()의 첫번째 인수는 화면에 보일 첫번째 아이템, 두번째 인수는 scrollOffset이다. scrollOffset은 '얼마나 스크롤되었는지의 정도값'이다. scrollOffset이 사용된 코드는 직관적으로 이해하기 어렵다. 따라서 간단하게 그 구조를 아래에 남겨놓으려고 한다.
 

#2-2 scrollOffset 유무의 차이

먼저, requestScrollToItem()에서 scrollOffset이 0인 경우의 도식도는 아래와 같다.
 

원래 index의 최솟값은 0이지만, 직관적인 이해를 위해 끝없이 낮아질 수 있다고 가정한다.  1 에서  2 의 상태가 될 정도로 스크롤을 진행했다고 해보자. 이러면, 첫번째 아이템이 화면에 노출된 상태기 때문에 무한 스크롤 로직이 작동해  3 의 상태가 된다. 화면이  2 에서 갑자기  3 이 되면 뭔가 뚝뚝 끊어지는 느낌이 날 것이다. 
 
반면,  requestScrollToItem()에서 scrollOffset이 0이 아닌 경우도 있다. #2-1에서 사용된 scrollOffset은 LazyListState.firstVisibleItemScrollOffset로, 화면에 보이는 첫번째 아이템이 얼마나 스크롤되었는지를 의미하는 값이다. 이 경우의 도식도는 아래와 같다.
 

 2 에서  3 으로 갈 때, index 0이 화면의 맨 위에서 스크롤된 정도값만큼을 추가로 더해주면  3 이 아니라  3' 이 된다. 이러면 사용자 입장에서 아이템이 추가되었음에도 눈에 보이는 화면은 변함없이 연속된 것처럼 보인다.
 

#2-3 Log.i( ... ) 전부 삭제

프로젝트에 있던 Log.i( ... )를 전부 삭제한다. 내가 원하는 수준의 역방향 무한 스크롤 구현을 위해선, 함수 간 실행 순서를 인지하고 있어야 한다고 생각했다. 그래서 이전 게시글에서 남겨두었던 로그 코드인데, 본 게시글을 통해 역방향 무한 스크롤은 아주 깔끔하게 해결되었으니 이제 필요가 없다. 당연하겠지만 #2-1의 코드에 있는 Log.i( ... )도 마찬가지다.
 

#2-4 NutrientViewModelEvent.InitializeState 이벤트 구현 수정

...

class NutrientViewModel : ViewModel() {
    // (1) 화면 표시용 State
    ...

    // (2) ViewModel용 내부 변수
    ...

    // (3) View에서 받아 처리할 이벤트
    ...

    // (4) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            is NutrientViewModelEvent.InitializeState -> {
                var dateToInsert = LocalDate.now()
                repeat(20) {
                    _nutrientScreenState.value.dailyMeals.add(
                        DailyMeal(
                            date = dateToInsert,
                            meals = SnapshotStateList()
                        )
                    )
                    dateToInsert = dateToInsert.plusDays(1)
                }

                viewModelScope.launch {
                    delay(100)
                    _isInitialized.value = true
                }
            }

            is NutrientViewModelEvent.LoadMoreItemsAfterLastDate -> {
                ...
            }

            is NutrientViewModelEvent.LoadMoreItemsBeforeFirstDate -> {
                ...
            }
        }
    }
}

_isInitialized.value = true 구문을 코루틴 영역에 넣고 0.1초 있다가 실행되게 수정했다. 이렇게 수정하지 으면, 앱을 맨 처음 실행시켰을 때 화면에서 오늘 날짜가 아니라 그 전 날의 날짜가 처음으로 표시된다. 스크롤에 관련한 에러로, requestScrollToItem이 제대로 작동하지 않았다는 말이다. 추측컨대 LazyColumn에 들어갈 아이템 리스트에 아이템을 추가함과 동시에 _isInitialized.value의 값을 변경해서, 둘의 변경 사항이 한꺼번에 다음 번 Recomposition에 반영되어 일어나는 에러로 보인다. 해결을 위해 따라서 약간의 딜레이를 두었다. delay(100)이 아니라 delay(1)로 두어도 잘 작동하지만 일단은 delay(100)라고 두었다. 그리고 이 코드를 작성하며 #3에 있는 개선 방향성이 떠올랐다.
 

#3 개선 방안

바로, ViewModel에서 LazyColumn이 보유할 아이템 리스트를 조작한 이후에 스크롤 관련 이벤트를 View에 보내주는 구조가 그렇다. Model이 '재료'고 View가 '요리'라면, View Model은 '필요한 재료가 올라가있는 도마'다. ViewModel에서는 아이템 리스트의 관리만 담당하고, 스크롤 관련한 동작은 View에게 온전히 넘겨주어야 한다고 본다. #2-4만 봐도 당장의 에러를 고치기 위한 의미 없는 delay(100)이 사용되었는데, 이런 문제는 잘못된 구조에서 나오는 것으로 생각된다. 당장 다음 작업으로 이 구조의 개선을 할 수도 있지만, 크게 급한 부분은 아니기 때문에 다른 작업을 먼저 진행할 수도 있겠다.
 

#4 요약

LazyListState에 내가 원하는 함수가 있었다. 공식 문서를 잘 읽는 습관을 들이자. 또, 스크롤 관련한 동작을 View가 온전히 100% 담당하게끔 만들어야 할 개선 방향이 보인다.
 

#5 완성된 앱

#5-1 작동 동영상

 

#5-2 이 게시글 시점의 Commit

GitHub - Kanmanemone/nutri-capture-new

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

github.com

 

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

GitHub - Kanmanemone/nutri-capture-new

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

github.com