I am new to Jetpack Compose and I just started trying it out by creating a timer application but I ran into a problem.
In my application each task has a different duration and after one of them is finished (the time has elapsed) then the next one should start. My problem is that my app works correctly only for the first task. After the first task is finished and the second one should be displayed the task title and description change but the timer's countdown value remains 0, it doesn't update from the previous state.
The onFinished calls one of the viewmodel's method that will fetch the next task that should be displayed and loads it into a livedata. I observe the changes on this livedata the following way:
val task = viewModel.currentTask.observeAsState().value
This task has a duration field that I pass to this Timer composable but when this task will be updated (because the livedata has a new value) the Timer composable doesn't recognize these changes. It doesn't restart the countdown, it stays 0.
I am not sure if I am understanding and using correctly the MutableState concept so can someone please help me?
#Composable
fun Timer(duration: Long, onFinished: () -> Unit) {
var currentTimerValue by remember { mutableStateOf(duration) }
LaunchedEffect(key1 = currentTimerValue) {
if (currentTimerValue > 0) {
delay(1000L)
currentTimerValue--
} else {
onFinished.invoke()
}
}
Text(text = currentTimerValue.toString(), fontSize = 24.sp, color = Color.White)
}
The issue is here
var currentTimerValue by remember { mutableStateOf(duration) }
After Timer composable enters composition the block inside remember is initilized and it doesn't change unless you reset it with new key.
You can update it as
var currentTimerValue by remember(duration) { mutableStateOf(duration) }
which will reset to duration every time duration param of Timer function changes
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.
My current Android Jetpack Compose application employs snapShotFlow to convert mutableStateOf() to flow and trigger user actions as follows
In ViewModel:-
var displayItemState by mutableStateOf(DisplayItemState())
#Immutable
data class DisplayItemState(
val viewIntent: Intent? = null
)
In composable:-
val displayItemState = viewModel.displayItemState
LaunchedEffect(key1 = displayItemState) {
snapshotFlow { displayItemState }
.distinctUntilChanged()
.filter { it.viewIntent != null }
.collectLatest { displayItemState ->
context.startActivity(displayItemState.viewIntent)
}
}
everything works as expected while I keep my test device in portrait or landscape.
However when I change the device orientation the last collected snapShotFlow value is resent.
If I reset the displayItemState as follows in the snapShotFlow this fixes the issue
however this feels like the wrong fix. What am i doing wrong? what is the correct approach to stop the snapShotFlow from re triggering on orientation change
val displayItemState = viewModel.displayItemState
LaunchedEffect(key1 = displayItemState) {
snapshotFlow { displayItemState }
.distinctUntilChanged()
.filter { it.viewIntent != null }
.collectLatest { displayItemState ->
context.startActivity(displayItemState.viewIntent)
viewModel.displayItemState = DisplayItemState()
}
}
That's intended behavior, you are not doing anything wrong. Compose's (Mutable)State holds the last value, similarly to StateFlow, so new collection from them always starts with the last value.
Your solution is ok, something very similar is actually recommended in Android's app architecture guide here:
For example, when showing transient messages on the screen to let the user know that something happened, the UI needs to notify the ViewModel to trigger another state update when the message has been shown on the screen.
Another possibility would be to use SharedFlow instead of MutableState in your viewModel - SharedFlow doesn't keep the last value so there won't be this problem.
I have a counter logic using Flow in ViewModel, and auto increment.
class MainViewModel(
private val savedStateHandle: SavedStateHandle
): ViewModel() {
val counterFlow = flow {
while (true) {
val value = savedStateHandle.get<Int>("SomeKey") ?: 0
emit(value)
savedStateHandle["SomeKey"] = value + 1
delay(1000)
}
}
}
In the Activity
val counterFlowStateVariable = viewModel.externalDataWithLifecycle.collectAsStateWithLifecycle(0)
This counter will only increment and count during the App is active
It stops increment when onBackground, and continues when onForeground. It doesn't get reset. This is made possible by using collectAsStateWithLifecycle.
It stops increment when the Activity is killed by the system and restores the state when the Activity is back. The counter value is not reset. This is made possible by using savedStateHandle
I'm thinking if I can use a stateFlow instead of flow?
I would say, you should. flow is cold, meaning it has no state, so previous values aren't stored. Because your source of emitted values is external (savedStateHandle) and you mix emitting and saving that value within the flow builder, you introduce a synchronization problem, if more than one collector is active.
Perform small test:
// this value reflects "saveStateHandle"
var index = 0
val myFlow = flow {
while(true) {
emit(index)
index++
delay(300)
}
}
Now collect it three times:
launch {
myFlow.collect {
println("First: $it")
}
}
delay(299)
launch {
myFlow.collect {
println("second: $it")
}
}
delay(599)
launch {
myFlow.collect {
println("third: $it")
}
}
You'll start noticing that some collectors are reading previous values (newer values already read by other collectors), meaning their save operation will use that previous value, instead up to date one.
Using stateFlow you "centralize" the state read/update calls, making it independent of a number of active collectors.
var index = 0
val myFlow = MutableStateFlow(index)
launch {
while (true) {
index++
mySharedFlow.value = index
delay(300)
}
}
So when I press a button I need to wait 3 seconds before executing another method, I worked that out with the followin
val job = CoroutineScope(Dispatchers.Main).launch(Dispatchers.Default, CoroutineStart.DEFAULT) {
delay(THREE_SECONDS)
if (this.isActive)
product?.let { listener?.removeProduct(it) }
}
override fun onRemoveProduct(product: Product) {
job.start()
}
now, if I press a cancel button right after I start the job I stop the job from happening and that is working fine
override fun onClick(v: View?) {
when(v?.id) {
R.id.dismissBtn -> {
job.cancel()
}
}
}
The problem is that when I execute again the onRemoveProduct that executes the job.start() it will not start again, seems like that job.isActive never yields to true, why is this happening ?
A Job once cancelled cannot be started again. You need to do that in a different way. One way is to create a new job everytime onRemoveProduct is called.
private var job: Job? = null
fun onRemoveProduct(product: Product) {
job = scope.launch {
delay(THREE_SECONDS)
listener?.removeProduct(product) // Assuming the two products are same. If they aren't you can modify this statement accordingly.
}
}
fun cancelRemoval() { // You can call this function from the click listener
job?.cancel()
}
Also, in this line of your code CoroutineScope(Dispatchers.Main).launch(Dispatchers.Default, CoroutineStart.DEFAULT),
You shouldn't/needn't create a new coroutine scope by yourself. You can/should use the already provided viewModelScope or lifecycleScope. They are better choices as they are lifecycle aware and get cancelled at the right time.
Dispatchers.Main is useless because it gets replaced by Dispatchers.Default anyways. Dispatchers.Default is also not required here because you aren't doing any heavy calculations (or calling some blocking code) here.
CoroutineStart.DEFAULT is the default parameter so you could have skipped that one.
And you also need not check if (this.isActive) because
If the [Job] of the current coroutine is cancelled or completed while delay is waiting, it immediately resumes with [CancellationException].
I'm trying to loop through items in an array and log the index of each item every few seconds when a button is pressed.
Whenever the button is pressed, I can't press other buttons or interact with the screen in any way until it's done looping through all the items.
How do I make this task run in the background so I can interact with other elements on the screen?
for(wallpaperUUID in p0.children){
var wallpaperUUIDString = wallpaperUUID.key.toString()
var indexOfImage: Int = wallpaperArray.indexOf(wallpaperUUIDString)
Log.i("Index of Image", indexOfImage.toString())
val time = measureTimeMillis {
runBlocking {
for(i in 1..100000) {
launch {
delay(2000L)
}
}
}
}
}
You're using runBlocking which means it blocks the current thread until its action is done.
What you need is asynchronous call for that you can use Job and CoroutineScope in a Dispatchers.Default
ex:
val coroutineScope = CoroutineScope(Dispatchers.Default)
coroutineScope.launch {
// Your code
}
You can also call coroutineScope.cancel() to cancel the work you're doing.