pycxu
pycxu

Reputation: 103

Compose: LaunchedEffect not relaunching after key change

I have a mutable state stored in viewmodel:

val state: MutableState<Int?> = mutableStateOf(null)

And in my compose UI, I have a LaunchedEffect that listens for state change:

LaunchedEffect(state.value) {
    while(state.value != null && state.value != 0) {
        ...do stuff
    }
}

When I update the state value with a different valye, it triggers the LaunchedEffect.

What I'm trying to achieve is to re-launched the effect with a repeated state value.

e.g. But this does not trigger the effect.

state.value = null
state.value = 30

e.g. Whereas this will trigger the effect.

state.value = null
delay(100L)
state.value = 30

Why do I have to delay here for LaunchedEffect to detect change?


Edit:

Thanks Leviathan and Chuckj for answering. It's more clear now.

As for why I needed the LaunchedEffect, I have a scrollable list and I need to programmatically scroll whenever a pendingScrollState (from business logic) changes:

LaunchedEffect(pendingScrollPosition.value) {
    while(pendingScrollPosition.value != null && pendingScrollPosition.value != 0) {
        when(pendingScrollPosition.value) {
            currentPosition.value -> {
                pendingScrollPosition.value // pending scroll finishes
            }

            else -> {
                lazyListState.animateScrollBy(  // animate scroll
                    value = calculatePendingScrollAmount(pendingScrollPosition.value)
                )
            }
        }
    }
}

Sometimes the animate scroll gets cancelled by user gesture and I need to resume the scroll.

Therefore I tried to relaunch the effect by

val pendingScrollToResume = pendingScrollPosition.value
pendingScrollPosition.value = null
//delay(100L)
pendingScrollPosition.value = pendingScrollToResume 

And it does not relaunch without the delay.

Now that I have a better understanding of the snapshot system; I know I need a more reliable solution.

I was able to solve it by adding another key to the LaunchedEffect to check when scrolling finishes:

val isScrolling = remember {
    derivedStateOf {
        lazyListState.isScrollInProgress
    }
}
LaunchedEffect(key1 = pendingScrollPosition.value, key2 = isScrolling.value ) {
    while(!isScrolling.value && pendingScrollPosition.value != null && pendingScrollPosition.value != 0) {
        when(pendingScrollPosition.value) {
            currentPosition.value -> {
                pendingScrollPosition.value = null // pending scroll finishes
            }

            else -> {
                lazyListState.animateScrollBy(  // animate scroll
                    value = calculatePendingScrollAmount(pendingScrollPosition.value)
                )
            }
        }
    }
}

Upvotes: 6

Views: 2813

Answers (2)

chuckj
chuckj

Reputation: 29585

Leviathan's answer is better than this one and you should follow the advice in it. However, Leviathan's answer doesn't explain what the underlying reason for the behavior you are seeing. This answer tries to answer your question more directly (but less usefully).

First, the premise that changing the key to a LaunchedEffect will "trigger" the launched affect is wrong. The key is intended to convey that, if this key changes, then the LaunchedEffect should be cancelled and restarted. This should be used to reflect state that is captured by the effect and, when state that would be captured is different, restart the effect.

A good example of this is in Material 3's date picker:

LaunchedEffect(lazyListState) {
    updateDisplayedMonth(
        lazyListState = lazyListState,
        ...
    )
}

The value of lazyListState is captured by the lambda and, if a new lazyListState is used then the coroutine using the old value should be cancelled and a new coroutine should be started with the new value. This is performed in composition which, in general, is only performed once per frame and at the beginning of a frame.

Second, snapshots do not record a sequence of values and should not be used for this. If you need a sequence of values use a Flow instead of snapshot state.

Snapshots are, as the name implies, a "snapshot" of the value of the state at a particular point in time. The code,

state.value = null
state.value = 30

just writes to state.value twice in the same snapshot. The null is immediately overwritten without any record of it having been performed and no notifications are sent that this occurred. It is as if it never happened.

The write to state, however, will eventually trigger another global snapshot to be created and inform the Recomposer that any composition that that read the value of state needs to recompose. At the beginning of the recomposition a snapshot is taken of the value and the state the value of state at that point is used during recomposition. At this point it just sees the value 30 and will never know that the value was temporarily null.

The reason delay(100L) appears to work is that it is long enough for a new frame to be created and recomposition to finish (approximately every 16ms). However, this is not guaranteed to occur as the frame rate may be different on systems (e.g. can be 60Hz, 120Hz or 1Hz) and is not guaranteed to occur at all (such as when the phone sleeps). For example, delay(100L) is not long enough when a device with variable refresh rate is reduced to its "UI idle rate" which is typically 1Hz. This means that this code wouldn't work as intended on a Pixel Fold.

The purpose of composition is to transform the current state of the application's model to the current state of the UI. Given this, composition should never write back to the user model, it should only read it.

Any use of composition to observe the application model for anything other than transforming the model into the current state of the UI will run into friction as that is not the intended use of composition as the assumptions the framework make will not be right for any use other than for updating the UI.

Upvotes: 3

tyg
tyg

Reputation: 15674

When you execute your first example on the main thread (in a composable, f.e.), it will not trigger the first LaunchedEffect because everything else on the main thread is blocked until the entire code is executed. Only then state changes are evaluated and a recomposition of LaunchedEffect is triggered. There will never occur a recomposition with the first value (null), only the second value (30) will be seen.

When you execute that in the view model while in a coroutine it could be possible that the snapshot system is fast enough to actually trigger two different recompositions, but I wouldn't count on it.

Instead, I would challenge why you even need the LaunchedEffect for the first value. After all, Compose is state-driven. When there is a new state, any old or intermediate state is thrown away and the new state is used to build the UI. There is no history, no queue that evaluates one state change after the other. Actually, a lot of what the compose framework does under the hood is to optimize unnecessary recompositions away by skipping as much as possible.

If you do need to execute the payload of the LaunchedEffect for each value, a MutableState isn't viable to observe the changes. What you should do instead:

If the payload of the LaunchedEffect is necessary to react to a callback from user interaction (like a button click), the safe way to do that in compose is to launch a coroutine in the callback itself. Use rememberCoroutineScope outside the callback to retrieve a scope that you then use to launch a coroutine inside the callback. You should only do that, though, when whatever the payload does is related to the composable where you retrieved the scope. A good example would be if it is UI related.

The reason for that is that the coroutine will be cancelled when the composable leaves the composition (e.g. by a back gesture of the user). If the payload was only UI related, then that won't be an issue. If the payload is about persisting data or something else that should survive the current composable, then you need to move the execution of the payload to the view model. In that case use viewModelScope.launch to launch a coroutine.

If the trigger for the state change originates from a lower layer of your app, like the repository or the data layer, in that case the UI shouldn't be involved at all. The view model (or even some lower layer) should handle the execution of the payload instead.

Upvotes: 1

Related Questions