App 개발 일지/Nutri Capture

Nutri Capture 프론트엔드 - 스크롤 로직 View에 일임

interfacer_han 2024. 10. 16. 23:07

#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

GitHub - Kanmanemone/nutri-capture-new

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

github.com

 

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

GitHub - Kanmanemone/nutri-capture-new

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

github.com