App 개발 일지/Nutri Capture

Nutri Capture 프론트엔드 - NutrientScreen의 무한 스크롤

interfacer_han 2024. 10. 11. 02:46

#1 깨알 변경 - SnapshotStateList로 마이그레이션

#1-1 NutrientScreenState.kt

...

...
import androidx.compose.runtime.snapshots.SnapshotStateList

data class NutrientScreenState(
    val dailyMeals: SnapshotStateList<DailyMeal>
)

data class DailyMeal(
    var date: LocalDate,
    val meals: SnapshotStateList<Meal>
)

data class Meal(
    var time: LocalTime,
    var name: String,
    val nutritionInfo: NutritionInfo,
)

List형을 전부 SnapshotStateList로 변경한다. 그 이유는 #1-2에 서술했다. 또, val형 데이터형들 중 일부를 var로 바꾼다.

 

#1-2 NutrientViewModel

...

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

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

    // (3) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            is NutrientViewModelEvent.InitializeState -> {
                _nutrientScreenState.value.dailyMeals.add(
                    ...
                )
            }
        }
    }
}

_nutrientScreenState.value = _nutrientScreenState.value.copy( ... ) 구문을 삭제하고 위와 같이 바꾼다. 이전 게시글에서 copy 구문을 썼던 이유는 해당 State 프로퍼티가 새로운 인스턴스로 재할당되었음을 Compose Runtime에게 알리기 위함이었다.

 

_nutrientScreenState.value에 MutableList형 프로퍼티가 있다고 해보자. 이 프로퍼티의 인스턴스에 무언가를 add()하는 것은 해당 인스턴스가 (State적 측면에서) 변경된 것으로 취급되지 않는다. 따라서 _nutrientScreenState.value에 _nutrientScreenState.value.copy( ... )을 통째로 재할당(=)했던 것이다.

 

반면, SnapshotStateList는 Stateful한 MutableList로, 인스턴스에 add()가 수행되면 Jetpack Compose Runtime이 해당 변경 사항을 감지하여 리컴포지션을 수행한다. 따라서 위와 같이 코드를 간소화할 수 있는 것이다.

 

#2 개요

#2-1 LazyListState

 

LazyListState  |  Android Developers

androidx.appsearch.builtintypes.properties

developer.android.com

LazyColumn을 무한스크롤로 구현해야한다. 왜냐하면, NutrientScreen에서 아래로 스크롤할 때마다 미래의 날짜가 표시되어야 하기 때문이다. 그리고 이를 위해선 LazyListState가 필수적으로 요구된다. LazyListState는 LazyColumn의 아이템을 참조하거나 스크롤을 관리한다. 즉, LazyColumn의 메타 데이터라고 보면 된다.

 

LazyListState는 LazyListLayoutInfo를 프로퍼티로서 보유하고 LazyListLayoutInfo는 List<LazyListItemInfo>를 프로퍼티로서 보유한다. 이 List<LazyListItemInfo>를 본 게시글에서 활용할 것이다.

 

#2-2 무한 스크롤의 의미

LazyColumn의 '마지막 아이템이 화면에 보일 때' 추가 아이템을 LazyColumn에 추가하는 코루틴. 그리고 '첫번째 아이템이 화면에 보일 때' 첫번째 아이템 이전에 존재해야할 아이템을 LazyColumn에 추가하는 코루틴. 이 2개 코루틴의 조합이 무한 스크롤이다. 일반적인 게시판 형식에서 '다음 게시글' 혹은 '이전 게시글' 버튼을 암시화한 작업이라고 보면 된다. 본 게시글에서도 무한 스크롤을 2개의 코루틴을 통해 구현할 것이다.

 

#2-3 본 게시글의 목표

1. NutrientScreen의 첫 화면에 오늘 날짜가 표시되는 item이 최상단에 존재하게 만듦
2. 아래로 스크롤할 때마다 그 다음 날짜가 표시됨
3. 위로 스크롤할 때마다 그 전 날짜가 표시됨

이 3가지의 목표를 구현해보겠다.

 

#3 코드 - 오늘 날짜가 표시되는 item 보이기

...

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

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

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

더미용 DailyMeal 아이템을 하나만 남기고, 해당 아이템에 LocalDate.of(2011, 11, 11) 대신 LocalDate.now()를 적어넣는다.

 

#4 코드 - 아랫 방향 무한 스크롤

#4-1 NutrientViewModelEvent

// package com.example.nutri_capture_new.nutrient

sealed class NutrientViewModelEvent {
    ...
    data object LoadMoreItemsAfterLastDate : NutrientViewModelEvent()
}

먼저 이벤트부터 정의한다.

 

#4-2 NutrientViewModel

...

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

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

    // (3) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            ...

            is NutrientViewModelEvent.LoadMoreItemsAfterLastDate -> {
                val lastDate = _nutrientScreenState.value.dailyMeals.last().date
                _nutrientScreenState.value.dailyMeals.add(
                    DailyMeal(
                        date = lastDate.plusDays(1),
                        meals = SnapshotStateList()
                    )
                )
            }
        }
    }
}

방금 만든 이벤트를 ViewModel의 onEvent() 분기문에 추가하고 구현한다. dailyMeals의 마지막 아이템 Date에 하루를 더한 아이템을 붙인다.

 

#4-3 NutrientScreen.kt - LazyListState 추가

...

@Composable
fun NutrientScreen(
    ...
) {
    val listState = rememberLazyListState()

    LaunchedEffect(key1 = true) {
        ...
    }

    LazyColumn(
        state = listState,
        ...
    ) {
        ...
    }
}

#2-1에서 말했던 LazyListState를 추가한다. rememberLazyListState는 State Remembering이 적용된 LazyListState을 반환한다. 그리고 LazyColumn의 프로퍼티에 넣어준다.

 

#4-4 NutrientScreen.kt - 아랫 방향 무한 스크롤 구현

...

@Composable
fun NutrientScreen(
    ...
) {
    ...

    LaunchedEffect(key1 = true) {
        // State 초기화
        ...

        // ViewModel로부터 받은 이벤트 처리
        launch {
            viewModel.nutrientScreenEventFlow.collectLatest { event ->
                ...
            }
        }

        // 무한 스크롤
        launch {
            snapshotFlow { listState.layoutInfo.visibleItemsInfo }.collect { visibleItemsInfo ->
                val totalMaxIndex = listState.layoutInfo.totalItemsCount - 1
                val firstVisibleItemIndex = listState.firstVisibleItemIndex
                val visibleItemCount = visibleItemsInfo.size

                if(totalMaxIndex <= firstVisibleItemIndex + visibleItemCount) {
                    viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsAfterLastDate)
                }
            }
        }
    }

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

LaunchedEffect에선 무한 스크롤을 구현할 코루틴 공간 하나를 할당한다. 'viewModel로부터 받은 이벤트 처리' 코루틴과 코루틴 영역이 겹치므로 각각을 launch { ... }로 감싸 구분한다.

 

launch { ... } 내부에는 먼저 LazyListState.layoutInfo.visibleItemsInfo를 담는 snapshotFlow(이 게시글의 #5 참조)를 선언한다. visibleItemsInfo는 현재 LazyColumn에서 보여지는 아이템의 정보를 반환한다. 이 정보가 변경될 때마다 emit()되어 snapshotFlow.collect()에서 처리된다. 그리고 이 snapshotFlow.collect()에서 무한 스크롤 코드를 구현할 것이다.

 

totalMaxIndex는 UI에 보이는 부분을 포함한 전체 아이템의 가장 마지막 인덱스다. firstVisibleItemIndex는 눈(UI)에 보이는 부분의 가장 첫번째 아이템의 인덱스다. 그리고 visibleItemCount는 현재 눈에 보이는 부분의 모든 아이템 갯수다. 이 3가지 변수의 관계에서, 아랫 방향 무한 스크롤의 trigger 조건인 '리스트의 마지막 요소가 화면이 보일 때'를 if문으로 표현할 수 있다. 그리고 해당 if문을 충족하는 경우 #4-2에서 정의한 이벤트 LoadMoreItemsAfterLastDate를 호출하게 둔다.

 

#5 코드 - 윗 방향 무한 스크롤

#5-1 NutrientViewModelEvent

// package com.example.nutri_capture_new.nutrient

sealed class NutrientViewModelEvent {
    ...
    data object LoadMoreItemsAfterLastDate : NutrientViewModelEvent()
    data object LoadMoreItemsBeforeFirstDate : NutrientViewModelEvent()
}

#4와 동일하다.

 

#5-2 NutrientViewModel

...

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

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

    // (3) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            ...

            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()
                    )
                )
            }
        }
    }
}

#4와 동일하다.

 

#5-3 NutrientScreen.kt - 윗 방향 무한 스크롤 구현

...

@Composable
fun NutrientScreen(
    ...
) {
    ...

    LaunchedEffect(key1 = true) {
        // State 초기화
        ...

        // ViewModel로부터 받은 이벤트 처리
        launch {
            viewModel.nutrientScreenEventFlow.collectLatest { event ->
                ...
            }
        }

        // 무한 스크롤
        launch {
            snapshotFlow { listState.layoutInfo.visibleItemsInfo }.collect { visibleItemsInfo ->
                val totalMaxIndex = listState.layoutInfo.totalItemsCount - 1
                val firstVisibleItemIndex = listState.firstVisibleItemIndex
                val visibleItemCount = visibleItemsInfo.size

                if(totalMaxIndex <= firstVisibleItemIndex + visibleItemCount) {
                    viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsAfterLastDate)
                }
                
                if(firstVisibleItemIndex == 0) {
                    viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsBeforeFirstDate)
                }
            }
        }
    }

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

#4와 동일하다. 이때, #4보다는 if문 작성의 난이도가 훨씬 낮다.

 

#5-4 윗 방향 무한 스크롤의 문제점

앱을 실행하고 나서 3초 정도가 지난 후의 모습이다. 2024-10-11가 맨 위에 있어야 하는데, 무한 재귀호출로 끝없이 위로 스크롤된다.

하지만, 윗 방향 무한 스크롤은 아랫 방향 무한 스크롤에는 없는 문제점을 가지고 있다 (현재 코드 기준). 바로 아이템이 새로 추가되면, 그 즉시 새로 추가된 그 아이템이 화면에 보여지게 되는 이유로 LoadMoreItemsBeforeFirstDate가 무한 재귀호출되기 때문이다. 이 문제점은 다음 게시글에서 해결해보겠다.

 

#6 요약

불완전한 무한 스크롤을 구현했다.

 

#7 완성된 앱

#7-1 스크린샷

 

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

 

GitHub - Kanmanemone/nutri-capture-new

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

github.com

 

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

 

GitHub - Kanmanemone/nutri-capture-new

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

github.com