UI 상태는 UI 상태를 읽고 쓰는 모든 컴포저블의 가장 낮은 공통 상위 요소로 호이스팅해야 한다. 상태는 상태가 소비되는 위치에서 가장 가까운 곳에 유지해야 한다. 상태 소유자로부터 소비자에게 변경 불가능한 상태 및 이벤트를 노출하여 상태를 수정한다.
가장 낮은 공통 상위 요소가 컴포지션 외부에 있을 수도 있다. (ex viewModel)
1) UI 상태 : UI를 설명하는 속성이며 2가지 유형이 있다.
NewsUiState
클래스에는 UI를 렌더링하는 데 필요한 뉴스 기사와 기타 정보가 포함될 수 있다. 이 상태는 앱 데이터를 포함하므로 대개 계층 구조의 다른 레이어에 연결된다.2) 로직 : 비즈니스 로직 또는 UI 로직일 수 있다.
UI 로직에서 상태를 읽거나 써야 하는 경우, UI의 수명 주기에 따라 UI 상태 범위를 지정해야 한다. 이렇게 하려면 구성 가능한 함수에서 상태를 올바른 수준으로 호이스팅해야 한다. 또는 UI 수명 주기로 범위가 지정된 일반 상태 홀더 클래스에서 상태를 호이스팅할 수도 있다.
상태와 로직이 간단하다면 컴포저블에 UI 로직과 UI 요소 상태를 사용하는 것이 좋다. 필요에 따라 상태를 컴포저블 내부에 유지하거나 호이스팅할 수 있다.
상태를 항상 호이스팅할 필요는 없으며, 상태를 제어해야 하는 다른 컴포저블이 없는 경우 상태를 컴포저블 내부에 유지할 수 있다.
@Composable
fun ChatBubble(
message: Message
) {
var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state
ClickableText(
text = message.content,
onClick = { showDetails = !showDetails } // Apply simple UI logic
)
if (showDetails) {
Text(message.timestamp)
}
}
showDetails는 UI 요소의 내부 상태이다. 이 컴포저블에서만 읽고 수정되며, 적용된 로직은 매우 단순하다. 따라서 상태를 호이스팅해도 별다른 이익이 없으므로 내부에 유지할 수 있다. 이렇게 하면 이 컴포저블이 확장 상태의 소유자이자 단일 정보 소스가 된다.
💡 핵심 사항 : 구성 가능한 함수 내부에 UI 요소 상태를 유지하는 것은 허용된다. 상태와 상태에 적용하는 로직이 단순하고 UI 계층 구조의 다른 부분에서 상태가 필요하지 않은 경우에 유용한 방식이다. ex) 애니메이션 상태에서 사용된다.
UI 요소 상태를 다른 컴포저블과 공유하고 여러 위치에서 상태에 UI 로직을 적용해야 하는 경우, 상태를 UI 계층 구조의 상단으로 호이스팅할 수 있다. 👉 컴포저블을 재사용하고 테스트하기가 쉬워진다.
JumpToBottom
: 메시지 목록을 하단으로 스크롤한다. 이 버튼은 목록 상태를 대상으로 UI 로직을 실행한다.MessagesList
: 사용자가 새 메시지를 보낸 후에 하단으로 스크롤된다. UserInput은 목록 상태를 대상으로 UI 로직을 실행한다.컴포저블 계층 구조는 아래와 같다.
앱이 UI 로직을 실행하고 상태를 필요로 하는 모든 컴포저블에서 상태를 읽을 수 있도록 LazyColumn 상태가 대화 화면으로 호이스팅된다.
최종적으로 컴포저블은 다음과 같다.
코드는 아래와 같다.
@Composable
private fun ConversationScreen(...) {
val scope = rememberCoroutineScope()
val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen
MessagesList(messages, lazyListState) // Reuse same state in MessageList
UserInput(
onMessageSent = { // Apply UI logic to lazyListState
scope.launch {
lazyListState.scrollToItem(0)
}
},
)
}
@Composable
private fun MessagesList(
messages: List<Message>,
lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {
LazyColumn(
state = lazyListState // Pass hoisted state to LazyColumn
) {
items(messages, key = { message -> message.id }) { item ->
Message(...)
}
}
JumpToBottom(onClicked = {
scope.launch {
lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
}
})
}
LazyListState
는 적용될 UI 로직에 필요한 수준만큼 상단으로 호이스팅된다. LazyListState
는 구성 가능한 함수에서 초기화되므로 수명주기에 따라 컴포지션에 저장된다.
LazyListState
는 MessageList()
함수에서 기본값인 rememberLazyListState()
로 정의되는 것을 볼 수 있다. 이는 Compose에서 일반적인 패턴으로 이로 인해 컴포저블의 재사용과 유연성이 향상된다. 그러면 앱의 여러 곳에서 컴포저블을 사용할 수 있다. 이 중에는 상태를 제어할 필요가 없는 곳도 있을 수 있다. 주로 컴포저블을 테스트하거나 Preview 하는 경우에 그렇다. LazyColumn은 정확히 이러한 방법으로 상태를 정의한다.
💡 핵심 사항 : 상태를 가장 낮은 공통 상위 요소로 호이스팅하고, 상태를 필요로 하지 않는 컴포저블에 전달하지 않는다.
컴포저블에 UI 요소의 하나 또는 여러 개의 상태 필드가 사용되는 복잡한 UI 로직이 포함되어 있다면 일반 상태 홀더 클래스와 같은 상태 홀더로 그 책임을 위임해야 한다. 이렇게 하면 컴포저블의 로직을 격리된 상태에서 더 쉽게 테스트할 수 있고 복잡성이 줄어든다. 이 접근 방식은 관심사 분리 원칙을 따른다. 즉, 컴포저블이 UI 요소를 방출하고 상태 홀더가 UI 로직과 UI 요소의 상태를 포함한다.
일반 상태 홀더 클래스는 구성 가능한 함수의 호출자가 로직을 직접 작성할 필요가 없도록 편리한 함수를 제공한다. 이러한 일반 클래스는 컴포지션에서 생성되고 기억된다. 일반 클래스는 컴포저블의 수명주기를 따르므로 rememberNavController()
, rememberLazyListState()
와 같이 Compose 라이브러리에서 제공하는 형식을 받을 수 있다.
LazyColumn
또는 LazyRow
의 UI 복잡성을 제어하기 위해 Compose에서 구현되는 LazyListState
일반 상태 홀더 클래스를 예로 들어볼 수 있다.
// LazyListState.kt
@Stable
class LazyListState constructor(
firstVisibleItemIndex: Int = 0,
firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
/**
* The holder class for the current scroll position.
*/
private val scrollPosition =
LazyListScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset)
suspend fun scrollToItem(...) { ... }
override suspend fun scroll() { ... }
suspend fun animateScrollToItem() { ... }
}
LazyListState
는 이 UI 요소의 scrollPosition
을 저장하는 LazyColumn의 상태를 캡슐화한다. 또한, 특정 항목으로 스크롤하는 등의 방식으로 스크롤 위치를 수정하는 메소드도 노출한다.
컴포저블과 일반 상태 홀더 클래스가 UI 로직과 UI 요소의 상태를 담당하는 경우, 화면 수준 상태 홀더가 다음의 작업을 담당한다.
Android 개발에서 AAC ViewModel이 가진 이점이 있으므로, 비즈니스 로직에 대한 접근 권한을 제공하고 화면에 표시하기 위한 애플리케이션 데이터를 준비하는데 ViewModel이 적합하다.
ViewModel에서 UI 상태를 호이스팅하면 상태가 컴포지션 외부로 이동된다.
<그림 6. ViewModel 외부로 호이스팅된 상태는 컴포지션 외부에 저장된다.>
ViewModel은 컴포지션의 일부로 저장되지 않는다. ViewModel은 프레임워크에 의해 제공되며, ViewModelStoreOwner
(Activity, Fragment, NavGraph 등)로 범위가 지정된다.
그러면 ViewModel이 정보 소스이자 UI 상태의 가장 낮은 공통 상위 요소가 된다.
UI 상태는 비즈니스 규칙을 적용하여 생성된다. 화면 UI 상태는 일반적으로 화면 수준 상태 홀더(ViewModel)에서 호이스팅된다.
아래 코드를 보자.
class ConversationViewModel(
private val channelId: String,
private val messagesRepository: MessagesRepository
) : ViewModel() {
val messages = messagesRepository
.getLatestMessages(channelId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// Business logic
fun sendMessage(message: Message) { /* ... */ }
}
컴포저블은 ViewModel에서 호이스팅된 화면 UI 상태를 소비한다. 화면 수준 컴포저블에 ViewModel 인스턴스를 삽입하여 비즈니스 로직에 대한 접근을 제공해야 한다.
@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {
val messages by conversationViewModel.messages.collectAsStateWithLifecycle()
ConversationScreen(messages, { message -> conversationViewModel.sendMessage(message) })
}
@Composable
private fun ConversationScreen(
messages: List<Messages>, onSendMessage: (Message) -> Unit)
) {
MessagesList(messages, onSendMessage)
// ...
}
viewModel() 함수를 사용하기 위해서는 build.gradle에 아래의 의존성을 추가해야 한다.
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:x.y.z"
속성 드릴
은 여러 중첩된 하위 구성요소를 통과하여 데이터를 데이터가 읽힌 위치로 전달하는 것을 의미한다.
ex) 최상위 수준에서 화면 수준 상태 홀더(ViewModel)를 삽입하고 상태와 이벤트를 하위 컴포저블에 전달하는 경우.
이로 인해 구성 가능한 함수 서명의 오버로드가 추가로 생성될 수 있다.
이벤트를 개별 람다 매개변수로 노출하면 함수 서명이 오버로드될 수 있지만, 구성 가능한 함수 책임의 가시성이 극대화된다. 👉 함수의 기능을 한눈에 확인할 수 있다.
래퍼 클래스를 만드는 것보다 속성 드릴을 사용하여 한곳에서 상태 및 이벤트를 캡슐화하는게 좋다. 이렇게 하면 컴포저블이 갖는 책임의 가시성이 줄어들기 때문이다. 게다가 래퍼 클래스가 없으면 컴포저블에 꼭 필요한 매개변수만 전달할 가능성이 커진다. 👉 권장사항이다.
UI 요소 상태를 읽거나 써야 하는 비즈니스 로직이 있다면 상태를 화면 수준 상태 홀더(ViewModel)로 호이스팅할 수 있다.
아래 채팅 앱에서 @를 입력하고 힌트를 입력하면 그룹 채팅에 사용자 제안을 표시한다. 이러한 제안은 데이터 레이어에서 제공되며, 사용자 제안 목록을 계산하는 로직은 비즈니스 로직으로 간주된다.
ex) ViewModel
class ConversationViewModel(...) : ViewModel() {
// Hoisted state
var inputMessage by mutableStateOf("")
private set
val suggestions: StateFlow<List<Suggestion>> =
snapshotFlow { inputMessage }
.filter { hasSocialHandleHint(it) }
.mapLatest { getHandle(it) }
.mapLatest { repository.getSuggestions(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun updateInput(newInput: String){
inputMessage = newInput
}
}
여기서 inputMessage는 TextField 상태를 저장하는 변수이다. 사용자가 새롭게 입력할 때마다 앱이 비즈니스 로직을 호출하여 suggestions를 생성한다.
suggestions는 화면 UI 상태로 StateFlow에서 수집하여 Compose UI에서 사용된다.
예제에서는 사용자 제안을 생성하는데 비즈니스 로직에 이 변수가 필요하지만, 실제로 비즈니스 로직에 변수가 필요하지 않은 경우에는 화면 수준 상태 홀더로 호이스팅 하지 않아야 한다. 이 경우 변수를 필요로 하는 구성 가능한 함수와 더 가까운 위치인 UI에서 변수를 정의하고 저장해야 한다.
Compose UI 요소의 일부 상태 홀더는 상태를 수정하는 메소드를 노출한다. 그중 일부는 애니메이션을 트리거하는 suspend 함수일 수 있다. 이러한 suspend 함수는 컴포지션으로 범위가 지정되지 않은 CoroutineScope에서 호출하는 경우, 예외를 발생시킬 수 있다.
ex) 앱 검색 창의 콘텐츠가 동적이며 앱 검색 창이 닫힌 후에 데이터 레이어에서 콘텐츠를 가져와서 새로고침해야 한다고 가정해보자. 이 요소에서 상태 소유자로부터 UI와 비즈니스 로직을 모두 호출할 수 있도록 검색 창 상태를 ViewModel로 호이스팅 해야 한다.
그러나 Compose UI에서 viewModelScope을 사용하여 DrawerState의 close() 메소드를 호출하면 런타임에 IllegalStateException
이 발생한다. 이는 '이 CoroutineContext에서 MonotonicFramClock을 사용할 수 없음'이라는 메시지가 표시된다.
해결을 위해서는 컴포지션으로 범위가 지정된 CoroutineScope을 사용해야 한다. CoroutineScope은 CoroutineContext에서 suspend 함수가 작동하는데 필요한 MonotonicFrameClock을 제공한다.
class ConversationViewModel(...) : ViewModel() {
val drawerState = DrawerState(initialValue = DrawerValue.Closed)
private val _drawerContent = MutableStateFlow<DrawerContent>(DrawerContent.Empty)
val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()
fun closeDrawer(uiScope: CoroutineScope) {
viewModelScope.launch {
withContext(uiScope.coroutineContext) { // Use instead of the default context
drawerState.close()
}
// Fetch drawer content and update state
_drawerContent.update { ... }
}
}
}
// in Compose
@Composable
private fun ConversationScreen(
conversationViewModel = viewModel()
) {
val scope = rememberCoroutineScope()
ConversationScreen(onCloseDrawer = { viewModel.closeDrawer(uiScope = scope) })
}
하지만, 이렇게 ViewModel로 호이스팅하는 것보다는 가장 낮은 공통의 상위 요소인 UI로 호이스팅하는게 좋다.