Still new to compose + kotlin, so I'm having some trouble getting stateflow working. There might be some fundamentals that I don't understand or missing some function.
Problem:
I have two stateflows in my view model and both would trigger a recomposition of the other one.
ViewModel:
private val _networkUiState = MutableStateFlow<NetworkUIState>(NetworkUIState.Empty)
val networkUIState: StateFlow<NetworkUIState> = _networkUiState.asStateFlow()
private val _uiState = MutableStateFlow<UIState>(UIState.Empty)
val uiState: StateFlow<UIState> = _uiState.asStateFlow()
fun someAPICall(
) {
_networkUiState.value = NetworkUIState.Loading
networkJob = CoroutineScope(Dispatchers.IO + exceptionHandler).launch {
try {
val response = repo.apiCall()
withContext(Dispatchers.Main) {
_networkUiState.value = NetworkUIState.Loaded
_uiState.value = UIState.DoSomething(response)
}
} catch (error: NetworkException) {
_networkUiState.value = NetworkUIState.NetworkError(error)
}
}
}
//exceptionHandler calls _networkUIState.value = NetworkUIState.NetworkError(some error) as well
Compose:
val networkUIState = viewModel.networkUIState.collectAsState().value
val uiState = viewModel.uiState.collectAsState().value
Column() {
//...
//UI code
when (uiState) {
is UIState.DoSomething -> {
//read uiState.response and do something
}
is UIState.DoAnotherThing -> {
//read response and do something else
}
}
when (networkUIState) {
is NetworkUIState.Error -> {
//display network error
}
is NetworkUIState.Loading -> {
//display loading dialog
}
else -> {}
}
}
What happens:
1st time calling api:
NetworkUIState triggers loading (display loading)
NetworkUIState triggers loaded (hides loading)
uiState triggers DoSomething with response data
2nd time calling api:
NetworkUIState triggers loading (display loading)
uiState triggers DoSomething with response data (from last call)
NetworkUIState triggers loaded (hides loading)
uiState triggers DoSomething with response data (new data)
I understand this is because of the recomposition of NetworkUiState before UIState but UIState still has the old value.
My question is how can I avoid this when I absolutely need to separate these 2 states at least in the view model?
Simplest thing you can do is providing an Idle or DoNothing state for your UiState which and set to this state when you are done doing event or when you start a new api call. This is a pattern i use a lot, i also use this if there shouldn't be a Loading in ui initially. uiState starts with Idle, when user clicks to fetch from api i set to Loading and then Error or Success based on data fetch result.
I also use this pattern with gestures to not leave state in Up state when user lifts fingers to not start drawing from Up state as in this answer
Related
I'm trying to post a state as "Loading" to display a progress bar to the user while downloading data from the server, it looks like this:
private fun loadBottomSheetItems(currentViewState: BusinessMapViewState.Display, getBusinessByIdsRequest: GetBusinessByIdsRequest) {
viewModelScope.launch {
_businessMapViewState.postValue(
currentViewState.copy(
bottomSheetState = BottomSheetViewState.Loading <--------------- Always that state!
)
)
val responseFlow = businessRepository.getBusinessListByIds(
getBusinessByIdsRequest
)
responseFlow.collect { result ->
if (result.isSuccess()) {
val businesses = result.asSuccess().value.businessList
_businessMapViewState.postValue(
currentViewState.copy(
bottomSheetState = BottomSheetViewState.Display(
items = businesses.map { business ->
BusinessListCardItemModel(
businessId = business.id,
businessName = business.name
)
}
)
)
)
} else {
_businessMapViewState.postValue(
currentViewState.copy(
bottomSheetState = BottomSheetViewState.Error
)
)
}
}
}
}
But when I post the "Loading" state, that state doesn't change after the data is loaded.
If I remove the postValue block for the "Loading" state, or add a delay, the data displays correctly, but I need a progress bar.
I also tried to move the postValue block for the "Loading" state outside the viewModelScope, nothing changes
UPDATE
I solved the problem, the other part of my code was changing the state of ui 🙈
Don’t use postValue if you need things to happen orderly. “Post” means ”do this sometime soon” and causes the value to be changed at some later time while the coroutine is still running. It will necessarily happen sometime after your coroutine relinquishes the main thread.
Use value = instead. This is fine because viewModelScope is on Dispatchers.Main.
Suppose I have some data that I need to transfer to the UI, and the data should be emitted with a certain delay, so I have a Flow in my ViewModel:
val myFlow = flow {
listOfSomeData.forEachIndexed { index, data ->
//....
emit(data.UIdata)
delay(data.requiredDelay)
}
}
Somewhere in the UI flow is collected and displayed:
#Composable
fun MyUI(viewModel: ViewModel) {
val data by viewModel.myFlow.collectAsState(INITIAL_DATA)
//....
}
Now I want the user to be able to pause/resume emission by pressing some button. How can i do this?
The only thing I could come up with is an infinite loop inside Flow builder:
val pause = mutableStateOf(false)
//....
val myFlow = flow {
listOfSomeData.forEachIndexed { index, data ->
emit(data.UIdata)
delay(data.requiredDelay)
while (pause.value) { delay(100) } //looks ugly
}
}
Is there any other more appropriate way?
You can tidy up your approach by using a flow to hold pause value then collect it:
val pause = MutableStateFlow(false)
//....
val myFlow = flow {
listOfSomeData.forEachIndexed { index, data ->
emit(data.UIdata)
delay(data.requiredDelay)
if (pause.value) pause.first { isPaused -> !isPaused } // suspends
}
}
Do you need mutableStateOf for compose? Maybe you can transform it into a flow but I'm not aware how it looks bc I don't use compose.
A bit of a creative rant below:
I actually was wondering about this and looking for more flexible approach - ideally source flow should suspend during emit. I noticed that it can be done when using buffered flow with BufferOverflow.SUSPEND so I started fiddling with it.
I came up with something like this that lets me suspend any producer:
// assume source flow can't be accessed
val sourceFlow = flow {
listOfSomeData.forEachIndexed { index, data ->
emit(data.UIdata)
delay(data.requiredDelay)
}
}
val pause = MutableStateFlow(false)
val myFlow = sourceFlow
.buffer(Channel.RENDEZVOUS, BufferOverflow.SUSPEND)
.transform {
if (pause.value) pause.first { isPaused -> !isPaused }
emit(it)
}
.buffer()
It does seem like a small hack to me and there's a downside that source flow will still get to the next emit call after pausing so: n value gets suspended inside transform but source gets suspended on n+1.
If anyone has better idea on how to suspend source flow "immediately" I'd be happy to hear it.
If you don't need a specific delay you can use flow.filter{pause.value != true}
I have Jetpack Compose CustomAlertDialog that depends on state change for recomposition. CustomAlertDialog contains TryAgain button that retries network call on Error with this function:
private val _systemList = MutableStateFlow<Result<List<SystemDomain>>>(Result.Loading)
val systemList: StateFlow<Result<List<SystemDomain>>> = _systemList
fun getSystemList() {
viewModelScope.launch {
_systemList.emit(Result.Loading)
val result = systemSelectionRepository.getSystemList()
_systemList.emit(result)
}
}
Problem is that sometimes _systemList.emit(Result.Loading) is not being emitted if _systemList.emit(result) as Result.Error is being called to fast, as only latest value is being collected.
By having Result.Error after Result.Error state, my CustomAlertDialog does not recompose itself.
My question is, how do I force emit(Result.Loading) to actually be emitted. One way is to do this, but it looks like a bad practice:
fun getSystemList() {
viewModelScope.launch {
_systemList.emit(Result.Loading)
delay(10)
val result = systemSelectionRepository.getSystemList()
_systemList.emit(result)
}
}
Okay so I've been using StateFlow with Room database for a while. Now I have one common case. At the start of my app I have a logic that if ROOM database is empty I should show an EmptyContent(), otherwise I will show the ListContent() from ROOM database.
Now every time I launch the app, I'm always getting that EmptyContent() shown for a HALF a second maybe, and then the ListContent() is displayed. After that when I'm using the app everything works normal. But at that app launch time, while ROOM database is working I guess, that EmptyContent() is shown for just a small amount of period (Because my StateFlow default value is an empty list), and after that the actual LIST from Database is displayed.
Now I have one solution for that, to just use delay() function inside a Coroutine, to wait for example 200MS and then trigger the function for reading the DATABASE, because those 200MS are enough for ROOM database to actually get the value and update my STATE FLOW variable with the actual data instead of using that StateFlow default value for a half second at the beginning.
Is that a good solution, I must ask? Because I'm using coroutine, the thread is not blocked, and I'm just waiting until ROOM database updates my STATE FLOW variable the second time.
#Composable
fun displayContent(
tasks: List<ToDoTask>,
ListContent: #Composable () -> Unit
) {
val scope = rememberCoroutineScope()
var counter by remember { mutableStateOf(0)}
LaunchedEffect(Unit){
scope.launch {
delay(200)
counter = 1
}
}
if(counter == 1){
if (tasks.isNotEmpty()) {
ListContent()
} else {
EmptyContent()
}
}
}
My suggestion would be map your expected states.
For instance:
sealed class RequestState<out T> {
object Idle : RequestState<Nothing>()
object Loading : RequestState<Nothing>()
data class Success<T>(val data: T) : RequestState<T>()
data class Error(
val t: Throwable,
var consumed: Boolean = false
) : RequestState<Nothing>()
}
And your function would be something like:
#Composable
fun YourScreen() {
val requestState = viewModel.screenData.collectAsState()
when (requestState) {
is Idle ->
// This is the default state, do nothing
is Loading ->
// Display some progress indicator
is Success ->
YourListScreen(requestState.data) // Show the list
is Error ->
// Display an error.
}
LaunchedEffect(Unit) {
viewModel.loadData()
}
}
Of course, in your view model you must emit these values properly...
class YourView: ViewModel() {
private val _screenData =
MutableStateFlow<RequestState<List<ToDoTask>>>(RequestState.Idle)
val screenDatae: StateFlow<RequestState<List<ToDoTask>>> = _screenData
fun loadData() {
_screenData.value = Loading
try {
// load the data from database
_screenData.value = Success(yourLoadedData)
} catch (e: Exception) {
_screenData.value = Error(e)
}
}
}
In my current Android project I have a dialog that retrieves a list of objects from a webservice and show these in a list. It has a problem though. The webservice (outside my control) is not the fastest, so the process takes a while and often the user changes the orientation of the device while this process is ongoing. For now the orientation change results in the original webservice call to be cancelled and a new one is created, but this is not how it should be done I know. I would like the dialog to able to continue the loading from the webservice when an orientation change happens, but I just can't get my head around how to do this. How is it possible to hook into an ongoing call and display this state in the view (the dialog)? Any recommendations are appreciated. I've played around with Android Architecture Components and kotlins sealed classes saving the viewstate in a livedata object that the view observes, but have not found a solution that I like.
I believe a lot of developers use RxJava for this kind of issue. Is this my only option?
Btw. at the moment I use MVP architecture in my project.
Edit
This is where I cancel the job - if the listener is null its cancelled (I'm using kotlin coroutines):
override fun getWorklist() {
job = onWorkerThread {
val result = repository.getResult().awaitResult()
onMainThread {
when (result) {
is Result.Ok -> listener?.onResult(result.getOrDefault(emptyList())) :? job.cancel()
// Any HTTP error
is Result.Error -> listener?.onHttpError(result.exception) :? job.cancel()
// Exception while request invocation
is Result.Exception -> listener?.onException(result.exception) :? job.cancel()
}
}
}
}
Edit 2
I've tried controlling the viewstate using this Android Arch ViewModel, but the MediatorLiveData object isn't triggered on changes of the viewstate object. And the view stays in Loading state:
class MainModel : ViewModel() {
private var job: Job? = null
val viewState = MutableLiveData<MainViewState>().apply { value = Loading("Warsaw") }
val dataGetter = MediatorLiveData<MainViewState>().apply {
addSource(viewState, {
Log.d("MainModel", "Viewstate is: " + viewState.value.toString() + ")...")
when(it) {
is Loading -> {
Log.d("MainModel", "Launching coroutine...")
job = launch(UI) {
Log.d("MainModel", "Loading...")
val items = Repository.getWorklist()
Log.d("MainModel", "Charts retrieved...")
this#MainModel.viewState.postValue(Success(it.items))
Log.d("MainModel", "Posted update to viewstate...")
}
}
}
})
}
override fun onCleared() {
Log.d("MainModel", "Clearing ViewModel")
job?.cancel()
}
}
interface ViewState
sealed class MainViewState : ViewState
class Loading() : MainViewState()
class Error(val error: String) : MainViewState()
class Success(val items: List<WorklistItem>) : MainViewState()