이번 포스트에서는 Android Developers 공식 문서를 통해 Jetpack Compose의 상태 호이스팅의 개념과 상태 호이스팅을 하는 다양한 방법들에 대해 정리하겠습니다.
스테이트풀(Stateful)과 스테이트리스(Stateless)
remember API를 사용하여 객체를 저장하는 Composable은 내부 상태를 생성하여 Composable을 스테이트풀(Stateful)
로 만듭니다. HelloContent는 내부적으로 name이라는 상태를 보존하고 수정하므로 스테이트풀(Stateful) 컴포저블의 한 예시가 됩니다. 이는 호출자가 상태를 제어할 필요가 없고 상태를 직접 관리하지 않아도 상태를 사용할 수 있는 경우에 유용합니다. 그러나 내부 상태를 갖는 Composable은 재사용 가능성이 적고 테스트하기 더 어려운 경향이 있습니다.
스테이트리스(Stateless)
Composable은 상태를 갖지 않는 Composable입니다. 스테이트리스(Stateless)를 달성하는 한 가지 쉬운 방법은 상태 호이스팅을 사용하는 것입니다.
재사용 가능한 컴포저블을 개발할 때는 동일한 컴포저블의 스테이트풀(Stateful) 버전과 스테이트리스(Stateless) 버전을 모두 노출해야 하는 경우가 있습니다. 스테이트풀(Stateful) 버전은 상태를 염두에 두지 않는 호출자에게 편리하며, 스테이트리스(Stateless) 버전은 상태를 제어하거나 끌어올려야 하는 호출자에게 필요합니다.
상태 호이스팅(State hoisting)
Compose에서 상태 호이스팅
은 컴포저블을 스테이트리스(Stateless)로 만들기 위해 상태를 컴포저블의 호출자로 옮기는 패턴입니다. Jetpack Compose에서 상태 호이스팅을 하는 일반적 패턴은 상태 변수를 다음 두 개의 매개변수로 바꾸는 것입니다.
value: T
: 표시할 현재 값onValueChange: (T) -> Unit
: T가 새 값인 경우 값을 변경하도록 요청하는 이벤트
하지만, onValueChange로만 제한되지 않습니다. 더 구체적인 이벤트가 Composable에 적합한 경우 람다를 사용하여 정의해야 합니다.
이러한 방식으로 끌어올린 상태에는 중요한 속성이 몇 가지 있습니다.
단일 정보 소스
: 상태를 복제하는 대신 위로 옮겼기 때문에 정보 소스가 하나만 있습니다. 버그 방지에 도움이 됩니다.캡슐화됨
: 스테이트풀(Stateful) 컴포저블만 상태를 수정할 수 있습니다. 철저히 내부적인 속성입니다.공유 가능함
: 호이스팅한 상태를 여러 컴포저블과 공유할 수 있습니다. 다른 컴포저블에서 name을 읽으려는 경우 호이스팅을 통해 가능합니다.가로채기 가능함
: 스테이트리스(Stateless) 컴포저블의 상태가 저장할 수 있습니다. 예를들면, name 상태를 ViewModel로 이동할 수 있습니다.
// 변경 후
@Composable
fun HelloScreen() {
var name by rememberSaveable { mutableStateOf("") }
HelloContent(name = name, onNameChange = { name = it })
}
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, $name",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
}
}
이 예제에서는 HelloContent에서 name과 onValueChange를 추출한 다음, 이러한 항목을 트리 상단을 거쳐 HelloContent를 호출하는 HelloScreen 컴포저블로 옮깁니다.
HelloContent에서 상태를 끌어올리면 더 쉽게 컴포저블을 추론하고 여러 상황에서 재사용하고 테스트할 수 있습니다.
HelloContent는 상태의 저장 방식과 분리됩니다. 분리된다는 것은 HelloScreen을 수정하거나 교체할 경우 HelloContent를 수정할 필요가 없다는 의미입니다.
상태가 내려가고 이벤트가 올라가는 패턴을 단방향 데이터 흐름이라고 합니다. 이 경우 상태는 HelloScreen에서 HelloContent로 내려가고 이벤트는 HelloContent에서 HelloScreen으로 올라갑니다. 단방향 데이터 흐름을 따르면 UI에 상태를 표시하는 컴포저블과 상태를 저장하고 변경하는 앱 부분을 서로 분리할 수 있습니다.
상태를 끌어올릴 때 상태의 이동 위치를 쉽게 파악할 수 있는 세 가지 규칙이 있습니다.
1. 상태는 적어도 그 상태를 사용하는 모든 컴포저블의 가장 낮은 공통 상위 요소로 끌어올려야 합니다.(읽기)
2. 상태는 최소한 변경될 수 있는 가장 높은 수준으로 끌어올려야 합니다.(쓰기)
3. 동일한 이벤트에 대한 응답으로 두 상태가 변경되는 경우 두 상태를 함께 끌어올려야 합니다.
이러한 규칙에서 요구하는 것보다 상태를 더 높은 수준으로 끌어올릴 수 있습니다. 하지만 상태를 끌어내리면 단방향 데이터 흐름을 따르기가 어렵거나 불가능할 수 있습니다.
상태 호이스팅을 어디로 해야하는가?
Compose를 사용하는 앱에서 UI 상태를 어디로 호이스팅해야 하는지는 UI 상태가 UI 로직과 비즈니스 로직 중 어느 쪽에서 필요한지에 따라 달라집니다.
그래서, UI 로직
과 비즈니스 로직
두 가지 시나리오에 대해 설명하겠습니다.
상태 호이스팅을 하는 권장하는 방법
UI 상태는 UI 상태를 읽고 쓰는 모든 컴포저블의 가장 낮은 공통 상위 요소로 호이스팅해야 합니다. 즉, 상태는 상태가 소비되는 위치에서 가장 가까운 곳에 배치되어야 합니다. 이렇게 상태 소유자로부터 소비자에게 변경 불가능한 상태 및 이벤트를 노출하여 상태를 수정합니다.
가장 낮은 공통 상위 요소가 컴포지션 외부에 있을 수도 있습니다. 이런 경우에는 비즈니스 로직과 관련되어 있기 때문에 ViewModel에서 상태를 호이스팅할 수 있습니다.
UI 상태 및 로직의 유형
UI 상태
UI 상태는 UI를 설명하는 속성입니다.
화면 UI 상태
-> 화면에 표시해야 하는 UI의 상태입니다. 예를 들면, NewsUiState 클래스에는 UI를 렌더링하는 데 필요한 뉴스 기사와 기타 정보들이 포함될 수 있습니다. 이 상태는 앱의 데이터를 포함하므로 일반적으로 계층 구조의 다른 레이어와 연결됩니다.UI 요소 상태
-> 렌더링 방식에 영향을 주는 UI 요소(element)의 고유한 속성을 나타냅니다. UI element를 show/hide 하거나, 특정 글꼴이나 글꼴 크기, 글꼴 색상을 적용할 수 있습니다. Android 뷰에서 뷰는 기본적으로 스테이트풀(Stateful)이므로 이 상태 자체를 관리하여 상태를 수정하거나 쿼리하는 메소드를 노출합니다. 즉, TextView 클래스의 text에 관련한 get 및 set 메소드를 예시로 들 수 있습니다. Jetpack Compose에서 상태는 Composable 외부에 위치하여, 컴포저블의 가까운 곳에 위치한 Composable 함수나 State holder로 호이스팅 할 수도 있습니다. (e.g. Scaffold 컴포저블의 ScaffoldState)
로직
앱의 로직은 비즈니스 로직 또는 UI 로직일 수 있습니다.
비즈니스 로직
-> 앱 데이터에 대한 제품 요구사항의 구현입니다. 예를 들면, 사용자가 버튼을 탭할 때 뉴스 리더 앱에서 기사를 북마크에 추가합니다. 북마크를 파일이나 데이터베이스에 저장하는 이 로직은 일반적으로 도메인 또는 데이터 레이어에 배치됩니다. State Holder는 일반적으로 노출되는 메소드를 호출하여 이 로직을 도메인 혹은 데이터 레이어에 위임합니다.UI 로직
-> 화면에 UI 상태를 표시하는 방법과 관련 있습니다. 사용자가 카테고리를 선택했을 때 올바른 Search Bar의 hint를 가져오는 것, 목록의 특정 항목으로 스크롤하는 것, 또는 사용자가 버튼을 클릭할 때 특정 화면으로의 탐색 로직을 예로 들 수 있습니다.
UI 로직
UI 로직에서 상태를 읽거나 써야하는 경우 UI의 수명 주기에 따라 UI 상태 범위를 지정해야 합니다. 이렇게 하려면 두 가지 방법이 존재합니다.
1. 컴포저블 함수에서 상태를 올바른 수준으로 호이스팅하는 방법
2. 범위가 UI 수명 주기로 지정된 Plain State Holder 클래스에서 상태를 호이스팅하는 방법
아래는 이 두 가지 방법과 각각을 사용해야 하는 경우를 정리합니다.
상태 소유자로서의 컴포저블
상태와 로직이 간단하다면 컴포저블에 UI 로직와 UI element의 상태를 정의하는 것이 좋습니다. 필요에 따라서 상태를 컴포저블 내부에 유지하거나 호이스팅할 수 있습니다.
상태 호이스팅이 불필요한 경우
상태를 항상 호이스팅할 필요는 없습니다. 상태를 제어해야 하는 다른 컴포저블이 없는 경우 상태를 컴포저블 내부에 유지할 수 있습니다. 아래 코드는 탭하면 펼쳐지거나 접히는 컴포저블이 있습니다.
@Composable
fun ChatBubble(
message: Message
) {
var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state
ClickableText(
text = AnnotatedString(message.content),
onClick = { showDetails = !showDetails } // Apply simple UI logic
)
if (showDetails) {
Text(message.timestamp)
}
}
변수 showDetails는 이 UI element의 내부 상태입니다. 이 변수는 이 컴포저블에서만 읽고 수정되며, 적용된 로직은 매우 간단합니다. 따라서 이 경우에는 상태를 호이스팅해도 별 다른 이익이 없으므로 내부에 상태를 유지할 수 있습니다. 이렇게 하면 이 컴포저블이 확장 상태의 소유자이며 단일 정보 소스가 됩니다.
*컴포저블 함수 내부에 UI element 상태를 유지하는 것은 허용됩니다. 상태와 상태에 적용하는 로직이 단순하고 UI 계층 구조의 다른 부분에서 상태가 필요하지 않은 경우에 유용한 방식입니다. 보통은 애니메이션 상태에서 이러한 방식이 사용됩니다.
컴포저블 내부에서 호이스팅
UI element 상태를 다른 컴포저블과 공유하고 여러 위치에서 상태에 UI 로직을 적용해야 하는 경우 상태를 UI 계층 구조의 상단으로 호이스팅 할 수 있습니다. 이렇게 하면 컴포저블을 재사용하고 테스트하기가 쉬워집니다.
다음 예는 두 가지 기능을 구현하는 채팅 앱입니다.
JumpToBottom
-> 버튼을 통해 메시지 목록을 하단으로 스크롤합니다. 이 버튼은 목록 상태를 대상으로 UI 로직을 실행합니다.
MessageList
-> 목록은 사용자가 새 메시지를 보낸 후에 하단으로 스크롤합니다. UserInput은 목록 상태를 대상으로 UI 로직을 실행합니다.
이 앱의 컴포저블 계층 구조는 아래 그림과 같습니다.
아래와 그림과 같이 앱이 UI 로직을 실행하고 상태를 필요로 하는 모든 컴포저블에서 상태를 읽을 수 있도록 LazyColumn 상태가 ConversationScreen으로 호이스팅됩니다.
최종적으로 컴포저블의 트리는 아래와 같습니다.
코드는 아래와 같습니다.
@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(/*...*/)
}
}
val scope = rememberCoroutineScope()
JumpToBottom(onClicked = {
scope.launch {
lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
}
})
}
LazyListState는 적용될 UI 로직에 필요한 수준만큼 상단으로 호이스팅 됩니다. LazyListState는 컴포저블 함수에서 초기화되므로 수명 주기에 따라 컴포지션에 저장됩니다.
lazyListState는 MessageList 메소드의 매개변수에서 기본값 rememberLazyListState()로 정의되는 것을 볼 수 있습니다. 이는 Compose에서 사용되는 일반적인 패턴으로, 이로 인해 컴포저블의 재사용성과 유연성이 향상됩니다. 이렇게 되면 앱의 여러 곳에서 컴포저블을 사용할 수 있습니다. 이 중에는 상태를 제어할 필요가 없는 곳도 있을 수 있습니다. 주로 컴포저블을 Preview나 테스트 할 때 유용하게 사용될 수 있습니다. 이것이 LazyColumn의 상태를 정의하는 방법입니다.
즉, 상태를 가장 낮은 공통 상위 요소로 호이스팅하고, 상태를 필요로 하지 않는 컴포저블에는 전달하지 않습니다.
Plain State Holder 클래스를 이용한 상태 소유
컴포저블에 UI element의 하나 또는 여러 개의 상태 필드가 사용되는 복잡한 UI 로직이 포함되어 있다면 일반 상태 홀더 클래스(Plain State Holder Class)
로 그 책임을 위임할 수 있습니다. 이렇게 하면 컴포저블의 로직을 격리된 상태에서 더 쉽게 테스트할 수 있고 복잡성이 줄어듭니다. 이 접근 방식은 관심사 분리 원칙을 따릅니다. 즉, 컴포저블이 UI element를 방출하고 State Holder가 UI 로직과 UI element의 상태를 포함하게 됩니다.
일반 상태 홀더 클래스(Plain State Holder Class)는 컴포저블 함수의 호출자가 로직을 직접 작성할 필요가 없도록 편리한 함수를 제공합니다.
이러한 일반 클래스는 컴포지션에서 생성되고 기억됩니다. 일반 클래스는 컴포저블의 생명 주기를 따르므로 rememberNavController(), rememberLazyListState()와 같이 Compose 라이브러리에서 제공하는 형식을 받을 수 있씁니다.
LazyColumn 또는 LazyRow의 UI 복잡성을 제어하기 위해 Compose에서 구현되는 LazyListState를 가지는 일반 상태 홀더 클래스(Plain State Holder Class)를 예시로 들 수 있습니다.
// 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 element의 scrollPosition을 저장하는 LazyColumn의 상태를 캡슐화합니다. 또한 특정 항목으로 스크롤하는 등의 방식으로 스크롤 위치를 수정하는 메소드도 포함하고 있습니다.
*이 클래스는 Stable 어노테이션을 붙였습니다. Stable 어노테이션은 컴포즈 컴파일러에게 이 객체는 변할수 있지만 변한다면 컴포즈 런타임이 알 수 있도록 하는 어노테이션입니다.
보시다시피 컴포저블의 책임을 늘리면 상태 홀더의 필요성이 증가합니다. 책임은 UI 로직이거나, 단순히 추적할 상태의 양일 수 있습니다.
또 다른 일반적인 패턴은 일반 상태 홀더 클래스를 사용하여 앱에서 Root 컴포저블 함수의 복잡성을 처리하는 것입니다. 이러한 클래스를 사용하여 탐색 상태 및 화면 크기 조정과 같은 앱 수준 상태를 캡슐화할 수 있습니다.
비즈니스 로직
컴포저블과 일반 상태 홀더 클래스가 UI 로직과 UI element의 상태를 담당하는 경우 화면 수준 상태 홀더가 아래와 같은 작업들을 담당합니다.
비즈니스 레이어
-> 데이터 레이어 등 주로 계층 구조의 다른 레이어에 배치되는 애플리케이션의 비즈니스 로직에 대한 액세스 권한 제공
특정 화면에 표시하기 위한 애플리케이션 데이터 준비(화면 UI 상태가 됨)
ViewModel을 이용한 상태 소유
Android 개발에서 AAC ViewModel이 가진 이점이 있으므로, 비즈니스 로직에 대한 액세스 권한을 제공하고 화면에 표시하기 위한 애플리케이션 데이터를 가지는 데에는 ViewModel이 적합합니다.
ViewModel은 컴포지션의 내부로 저장되지 않습니다. ViewModel은 프레임워크에 의해 제공되며, ViewModelStoreOwner(Activity, Fragment, Navigation Graph 혹은 Navigation Graph의 대상)로 범위가 지정됩니다.
그러면 ViewModel이 정보의 소스이자 UI 상태의 가장 낮은 공통 상위 element가 됩니다.
즉, 위 그림에서 보면 ConversationScreen 컴포저블 함수의 상태인 inputMessage는 ViewModel로 이동될 수 있습니다.
화면 UI 상태
위의 정의에 따라 화면 UI 상태는 비즈니스 규칙을 적용하여 생성됩니다. 화면 UI 상태는 화면 수준의 State Holder가 담당한다는 사실을 고려하면 이는 화면 UI 상태는 일반적으로 화면 수준 상태 홀더(여기서는 ViewModel)에서 호이스팅됨을 의미합니다.
채팅 앱의 ConversationViewModel과 화면 UI 상태 및 이벤트를 노출하여 수정하는 방식을 살펴보겠습니다.
class ConversationViewModel(
channelId: String,
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 인스턴스를 삽입하여 비즈니스 로직에 대한 액세스를 제공해야 합니다.
아래는 컴포저블 ConversationScreen()이 ViewModel에서 호이스팅된 화면 UI 상태인 messages를 소비하는 코드입니다.
@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {
val messages by conversationViewModel.messages.collectAsStateWithLifecycle()
ConversationScreen(
messages = messages,
onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
)
}
@Composable
private fun ConversationScreen(
messages: List<Message>,
onSendMessage: (Message) -> Unit
) {
MessagesList(messages, onSendMessage)
/* ... */
}
속성 드릴(Property drilling)
속성 드릴(Property drilling)
은 여러 중첩된 하위 구성요소를 통과하여 데이터를 데이터가 읽힌 위치로 전달하는 것을 의미합니다.
Compose에서 속성 드릴이 나타날 수 있는 일반적인 예로 최상위 수준에서 화면 수준 상태 홀더를 삽입하고 상태와 이벤트를 하위 컴포저블에 전달하는 경우를 들 수 있습니다. 이로 인해 추가로 컴포저블 함수 서명의 오버로드가 추가로 생성될 수 있습니다.
이벤트를 개별 람다 매개변수로 노출하면 함수 서명이 오버로드될 수 있지만, 컴포저블 함수 책임의 가시성이 극대화 되므로 함수의 기능을 한눈에 확인할 수 있습니다.
래퍼 클래스를 만드는 것보다 속성 드릴을 사용하여 한곳에서 상태 및 이벤트를 캡슐화하는 것이 좋습니다. 이렇게 하면 컴포저블이 갖는 책임의 가시성이 줄어들기 때문입니다. 게다가 래퍼 클래스가 없으면 컴포저블에 꼭 필요한 매개변수만 전달할 가능성이 커집니다. 그러므로 이렇게 하는 것이 권장사항입니다.
UI 요소 상태(UI element state)
UI 요소 상태를 읽거나 써야 하는 비즈니스 로직이 있다면 상태를 화면 수준 상태 홀더로 호이스팅할 수 있습니다.
채팅 앱은 사용자가 @기호를 입력하고 힌트를 입력하면 그룹 채팅에 사용자 제안을 표시합니다. 이러한 제안은 데이터 레이어에서 제공되며, 사용자 제안 목록을 계산하는 로직은 비즈니스 로직으로 간주됩니다. 이 기능은 아래와 같습니다.
이 기능을 구현하는 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의 상태를 저장하는 변수입니다. 사용자가 새 입력을 입력할 때마다 앱이 비즈니스 로직을 호출하여 suggestion을 생성합니다.
suggestions는 화면 UI 상태로, StateFlow에서 collect하여 Compose UI에서 사용됩니다.
주의
일부 Compose UI 요소 상태(element state)의 경우 ViewModel로 호이스팅하려면 특별한 고려사항이 필요할 수 있습니다. 예를 들면, Compose UI element의 일부 상태 홀더는 상태를 수정하는 메소드를 노출합니다. 그 중 일부는 애니메이션을 트리거하는 suspend 함수일 수 있습니다. 이러한 suspend 함수는 컴포지션으로 범위가 지정되지 않은 CoroutineScope에서 호출하는 경우 예외를 발생시킬 수 있습니다.
앱 검색 창의 콘텐츠가 동적이고 앱 검색 창이 닫힌 후에 데이터 레이어에서 콘텐츠를 가져와서 새로고침해야 한다고 가정하겠습니다. 이 element에서 상태 소유자로부터 UI와 비즈니스 로직을 모두 호출할 수 있도록 검색 창 상태를 ViewModel로 호이스팅해야 합니다.
그러나 Compose UI에서 viewModelScope를 사용하여 DrawerState의 close() 메소드를 호출하면 IllegalStateException 타입의 런타임 예외가 발생하고 이 CoroutineContext에서 'MonotonicFrameClock을 사용할 수 없음'이라는 메시지가 표시됩니다.
이 문제를 해결하려면 컴포지션으로 범위가 지정된 CoroutineScope를 사용하면 됩니다. CoroutineScope는 CoroutineContext에서 suspend 함수가 작동하는 데 필요한 MonotonicFrameClock을 제공합니다.
*Compose UI element의 상태에 노출되었고 애니메이션을 트리거하는 일부 suspend 함수를 호출할 경우, 컴포지션으로 범위가 지정되지 않은 CoroutineScope에서 호출되면 예외가 발생합니다. 이러한 함수의 예로 LazyListState.animateScrollTo()와 DrawerState.close()를 들 수 있습니다.
이 비정상 종료를 해결하려면 아래 코드처럼 ViewModel에 있는 코루틴의 CoroutineContext를 컴포지션으로 범위가 지정된 컨텍스트를 전환하면 됩니다.
class ConversationViewModel(/*...*/) : ViewModel() {
val drawerState = DrawerState(initialValue = DrawerValue.Closed)
private val _drawerContent = MutableStateFlow(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 { content }
}
}
}
// in Compose
@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {
val scope = rememberCoroutineScope()
ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}