본문 바로가기
⛏️ | 개발 기록/🎬 | 부트캠프 심화 팀 프로젝트

[Android] ViewModel과 UiState를 이용해 상황에 맞는 화면 띄우기 (1)

by immgga 2024. 8. 16.

출처: unsplash.com

 

내일배움캠프 앱 개발 심화 팀 프로젝트

 

서론

이제 본격적으로 부트캠프에서 앱 개발하고 나서 발생한 문제의 해결 과정과 새로 알게 된 기능 정리 및 알고 있던 개념 복습 차원에서 개발 기록 포스팅을 남기도록 하겠다.

기능 개발이 끝나고 나서 포스팅을 작성할 것이기 때문에 매일 올라오지는 않는다 :)

 

 

UiState란?

uiState는 자신이 직접 state를 만들어서 현재 앱이 어떤 상태인지 직접적으로 알 수 있게 해주는 역할을 한다.

대체로 UiState는 sealed class를 이용해서 구현한다.

sealed class SearchUiState {
    data class Success(val searchResultModel: SearchResultModel): SearchUiState()
    data object Loading: SearchUiState()
    data object LoadingMore: SearchUiState()
    data object Empty: SearchUiState()
    data object Failure: SearchUiState()
    data object Init: SearchUiState()
}

자신이 원하는 State를 class 내부에 생성해 주고, sealed class를 상속받게 해 주면 끝난다.

 

그럼 UiState가 있다는 것은 알겠는데, 이걸 왜 써야 하는지 궁금할 수 있다.

 

 

UiState를 쓰는 이유

위 코드를 보면 State를 여러 개 생성해서 activity 또는 fragment에 state를 전달한다.

예를 들어서 State Success의 경우에는 특정 로직 수행에 성공했을 때 나타나는 State이다.

data class Success(val searchResultModel: SearchResultModel): SearchUiState()

이 State들을 ViewModel의 LiveData 또는 StateFlow에 저장해서 activity 또는 fragment에 전달할 수 있다.

state의 정보를 담은 LiveData이면 observe로, stateFlow라면 collect를 이용해 관찰하면서 state가 변함에 따라 UI 요소들을 설정할 수 있다.

예를 들면 Loading State면 결과를 보여주는 RecyclerView를 GONE 처리한다.

 

이런 방식으로 구현을 해주면 기존에 API를 호출하고 결과를 받아올 때 즉시 받아오는 게 아니라 어느 정도 시간차가 발생할 수밖에 없는데, API를 호출하고 결괏값을 받아올 때는 Loading State를 활성화시켜 Loading에 필요한 UI 요소들(원형 로딩바)을 띄우는 작업을 할 수 도 있다. 데이터를 받아올 때까지 원형 로딩바깥은 UI 요소가 보이는 게 사용자 입장에서도 훨씬 편리할 것이다.

사용자가 Loading UI 요소를 보게 되면 결과를 불러오고 있는 중이구나라고 생각할 수 있을 것이다.

Loading이 끝나면 Success로 State를 변경해서 Success에 해당하는 로직을 수행시켜 줄 수 있다. Success에서 받아오는 데이터를 이용해서 RecyclerView를 띄워줄 수도 있다.

 

UiState를 쓰지 않을 때는 State를 지정해 주는 작업이 복잡해지기도 하고 Acitvity 또는 Fragment 파일에 코드를 넣기 때문에 가독성도 해칠 수 있다.

 

사용자들이 앱을 더 편리하게 사용하고, 개발자 입장에서도 훨씬 보기 좋은 코드를 만들 수 있는 데다가 State별로 추가적인 UI요소를 표시하거나 안 보이게 설정하면서 쉽게 개발할 수 있다.

 

 

UiState 사용하기

위에서 언급했다시피, sealed class로 UiState를 생성한다. 자신이 원하는 만큼 State를 만들면 된다.

sealed class SearchUiState {
    data class Success(val searchResultModel: SearchResultModel): SearchUiState()
    data object Loading: SearchUiState()
    data object LoadingMore: SearchUiState()
    data object Empty: SearchUiState()
    data object Failure: SearchUiState()
    data object Init: SearchUiState()
}

 

State를 사용할 때 추가 데이터가 필요하다면 data class를 안에 만들어서 필드로 받아주면 된다.

그 외의 경우에는 data object를 사용한다. seled class를 상속해 주는 것을 잊지 말자.

 

State를 사용하기 위해서는 ViewModel에서 LiveData 또는 StateFlow로 사용해야 한다.

내 경우에는 StateFlow를 사용했다.

private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Init)
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()

ViewModel 안에 uiState를 생성해 준다. ViewModel 안에서는 _uiState를 쓰고 UI 코드에서는 uiState를 사용할 것이다.

초기에는 Init으로 초기화를 해준다.

 

stateFlow에 데이터를 넣는 건 LiveData와 다를 게 없다.

fun searchVideo(query: String, page: String?, category: String?) {
    viewModelScope.launch {
        _uiState.value = SearchUiState.Loading

        searchUseCase(query, page, category) {
            if (it != null) {
                if (it.items?.isEmpty() == true) _uiState.value = SearchUiState.Empty
                else {
                    if (page == null) _searchResult.value = mutableListOf()

                    _uiState.value = SearchUiState.Success(it)

                    val currentResult = _searchResult.value.toMutableList()
                    currentResult.addAll(it.items!!)
                    _searchResult.value = currentResult
                }
            } else {
                _uiState.value = SearchUiState.Failure
            }
        }
    }
}

함수가 시작하자마자 Loading 상태로 진입한다.

요청에 성공한 경우 추가적인 작업을 수행하고 실패한 경우는 Failure State를 전달한다.

요청 결과 리스트가 비어 있으면 Empty State를 전달한다. 그렇지 않은 경우에는 Success를 전달한다. Success에는 이전에 만든 data class의 필드가 필요하기 때문에 요청의 결괏값을 넣어 준다.

 

 

Ui 코드에서 UiState 사용하기

이제 Activity 또는 Fragment 코드에서 UiState를 불러와야 한다. 우선 ViewModel은 다음과 같이 불러올 수 있다.

private val searchViewModel by viewModels<SearchViewModel>()

불러온 viewModel 변수를 이용해 state 관찰 함수를 만들어 보자.

private fun observeSearchState() = with(binding) {
    viewLifecycleOwner.lifecycleScope.launch {
        searchViewModel.uiState.collectLatest {
            when (it) {
                is SearchUiState.Success -> {
                    searchingLoadingIndicator.visibility = View.GONE
                    searchResultIsEmptyText.visibility = View.GONE
                    searchResultRv.visibility = View.VISIBLE

                    // todo :: add recyclerview
                    val adapter = SearchResultAdapter()
                    adapter.submitList(it.searchResultModel.items)

                    searchResultRv.adapter = adapter
                    searchResultRv.layoutManager = LinearLayoutManager(context)
                    searchResultRv.addItemDecoration(SearchResultItemDecoration(16))
                }
                is SearchUiState.Empty -> {
                    searchingLoadingIndicator.visibility = View.GONE
                    searchResultIsEmptyText.visibility = View.VISIBLE
                    searchResultRv.visibility = View.GONE
                }
                is SearchUiState.Init -> {
                    searchingLoadingIndicator.visibility = View.GONE
                    searchResultIsEmptyText.visibility = View.GONE
                    searchResultRv.visibility = View.GONE
                }
                is SearchUiState.Loading -> {
                    searchingLoadingIndicator.visibility = View.VISIBLE
                    searchResultIsEmptyText.visibility = View.GONE
                    searchResultRv.visibility = View.GONE
                }
                is SearchUiState.LoadingMore -> TODO()
                is SearchUiState.Failure -> {
                    Log.e("TAG", "observeSearchRes: failure")
                }
            }
        }
    }
}

위와 같이 when으로 각 state에 따라 다른 작업을 수행하도록 설정할 수 있다.

처음 상태인 Init에서는 모든 UI View들을 GONE처리하고, Loading State일 때는 searchingLoadingIndicator를 VISIBLE로 변경한다. searchingLoadingIndicator는 원형 로딩 바이다.

만약 Empty State가 되면 비어 있음을 알리는 TextView를 VISIBLE로 띄워준다(나머지는 GONE).

Success인 경우에는 it을 이용해 데이터를 불러와서 사용하면 된다. 내 경우에는 RecyclerView를 띄워 주었다.

 

마지막으로 실행 영상까지 봐보자.

 

이제 UiState에 대한 정리가 되었을까?

 

 

정리

UiState는 기존에 불편한 state 처리를 쉽게 구현할 수 있도록 도와주는 역할을 하면서 앱 사용성도 좋게 만들고 개발자에게도 편하다.

ViewModel을 쓰려고 하면 UiState 적용을 고려해 보는 건 어떨까?

728x90