UI 이벤트
UI 이벤트는 UI 레이어에서 UI 또는 ViewModel로 처리해야 하는 작업입니다. 보통 아래와 같은 형식으로 UI 이벤트를 어디서 처리할지 결정하게 됩니다.
예를 들어 RecyclerView에서 북마크 버튼을 통해 UiState를 업데이트 한다고 가정합니다. NewsItemUiState에 해당 Ui에서 필요한 데이터와 기능을 가지고 있으며 onBookmark에서는 repository에 북마크되어어할 id가 전달하는 것을 볼 수 있습니다.
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
이렇게 하면 RecyclerView 어댑터가 NewsItemUiState 객체 목록과 같이 필요한 데이터만 사용할 수 있습니다. 어댑터가 전체 ViewModel에 액세스할 수 없으므로 ViewModel에 의한 노출된 기능을 악용할 가능성이 낮습니다. 활동 클래스만 ViewModel을 사용하도록 허용하는 경우 책임이 분리됩니다. 이렇게 하면 뷰 또는 RecyclerView 어댑터와 같은 UI별 객체가 ViewModel과 직접 상호작용하지 않습니다.
ViewModel 이벤트 처리
ViewModel에서 발생하는 UI 작업 (ViewModel 이벤트)은 항상 UI 상태 업데이트로 이어집니다. 이 방식은 UDF(단방향 데이터 흐름)을 준수하기 때문에 구성 변경 후에 이벤트를 재현할 수 있으며 UI 작업이 손실되지 않습니다. UI 작업을 UI 상태에 매핑하는 절차가 간단하지는 않지만, 그렇게 하면 로직은 더 간단해집니다.
예를 들어 사용자가 로그인 화면에 로그인한 후 홈 화면으로 이동하는 경우를 가정합니다.
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
이 UI는 isUserLoggedIn 상태 변경에 반응하고 필요에 따라 올바른 대상으로 이동합니다.
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
/* ... */
}
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
이벤트를 소비하면 상태 업데이트
예를 들어 화면에 메시지를 띄우는 경우 UI가 잘 동작한 후에 ViewModel에 State의 변경을 알려야 하는 케이스입니다.
ViewModel은 UI가 화면에 메시지를 표시하는 방식을 알 필요가 없습니다. 표시해야 하는 사용자 메시지가 있다는 사실만 알면 됩니다. 임시 메시지가 표시되면 UI가 ViewModel에 이를 알려야 하며 그러면 userMessage 속성을 삭제하기 위해 또 다른 UI 상태 업데이트가 발생합니다.
// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
val news: List<News> = emptyList(),
val isLoading: Boolean = false,
val userMessage: String? = null
)
class LatestNewsViewModel(/* ... */) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
val uiState: StateFlow<LatestNewsUiState> = _uiState
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = "No Internet connection")
}
return@launch
}
// Do something else.
}
}
fun userMessageShown() {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = null)
}
}
}
class LatestNewsActivity : AppCompatActivity() {
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.userMessage?.let {
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown()
}
...
}
}
}
}
}
탐색 이벤트
사용자가 버튼을 탭했을 때 UI에서 이벤트가 트리거되는 경우 UI는 탐색 컨트롤러를 호출하거나 적절하게 이벤트를 호출자 컴포저블에 노출하여 이를 처리합니다. 탐색 전에 데이터 입력에 비즈니스 로직 확인이 필요하면 ViewModel은 UI에 상태를 노출해야 합니다. UI는 상태 변경에 반응하고 적절하게 이동합니다.
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
위 예에서는 앱이 예상대로 작동합니다. 현재 대상인 로그인이 백 스택에 유지되지 않기 때문입니다. 사용자가 뒤로 버튼을 누르면 로그인 화면으로 돌아갈 수 없습니다. 그러나 이러한 상황이 발생할 경우 솔루션에는 추가 로직이 필요합니다.
대상이 백 스택에 유지된 경우 탐색 이벤트
ViewModel이 화면 A에서 화면 B로 탐색 이벤트를 생성하는 상태를 설정하고 화면 A가 탐색 백 스택에 유지될 때는 자동으로 B로 진행되지 않도록 추가 로직이 필요할 수 있습니다. 이를 구현하려면 UI가 다른 화면으로 이동하는 것을 고려해야 하는지를 나타내는 추가 상태가 있어야 합니다. 일반적으로 이러한 상태는 UI에 유지됩니다. 탐색 로직이 ViewModel이 아닌 UI에 관한 문제이기 때문입니다. 이 점을 설명하기 위해 다음 사용 사례를 살펴보겠습니다.
개발자가 앱의 등록 흐름에 있다고 가정해 보겠습니다. 생년월일 확인 화면에서 사용자가 날짜를 입력할 때 사용자가 '계속' 버튼을 탭하면 ViewModel에서 날짜를 확인합니다. ViewModel은 확인 로직을 데이터 레이어에 위임합니다. 날짜가 유효하면 사용자는 다음 화면으로 이동합니다. 추가 기능으로는 사용자가 일부 데이터를 변경하려는 경우 여러 등록 화면 간에 이동할 수 있습니다. 따라서 등록 흐름의 모든 대상이 동일한 백 스택에 유지됩니다. 이러한 요구사항을 고려해 이 화면을 다음과 같이 구현할 수 있습니다.
// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"
class DobValidationFragment : Fragment() {
private var validationInProgress: Boolean = false
private val viewModel: DobValidationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = // ...
validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false
binding.continueButton.setOnClickListener {
viewModel.validateDob()
validationInProgress = true
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.collect { uiState ->
// Update other parts of the UI ...
// If the input is valid and the user wants
// to navigate, navigate to the next screen
// and reset `validationInProgress` flag
if (uiState.isDobValid && validationInProgress) {
validationInProgress = false
navController.navigate(...) // Navigate to next screen
}
}
}
return binding
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
}
}
생년월일 확인은 ViewModel이 담당하는 비즈니스 로직입니다. 대부분의 경우 ViewModel은 이 로직을 데이터 레이어에 위임합니다. 사용자를 다음 화면으로 이동시키는 로직은 UI 로직입니다. 이러한 요구사항은 UI 구성에 따라 변경될 수 있기 때문입니다. 예를 들어 여러 등록 단계를 동시에 표시하는 경우 태블릿의 다른 화면으로 자동으로 넘어가지 않아야 할 수도 있습니다. 위 코드의 validationInProgress 변수는 이 기능을 구현하며, 생년월일이 유효하고 사용자가 다음 등록 단계로 계속 진행하려고 할 때마다 UI가 자동으로 이동해야 하는지를 처리합니다.
'Android' 카테고리의 다른 글
[Android] 도메인 레이어 (0) | 2023.09.30 |
---|---|
[Android] 데이터 레이어 (0) | 2023.09.29 |
[Android] 앱 아키텍처 UI레이어와 UiState (0) | 2023.09.28 |
[Android-Test] Android Compose Test 도입하기 - 실전 (0) | 2023.09.27 |
[Android-Test] Android Compose Test 도입하기 - 이론 (0) | 2023.09.26 |
댓글