Unwanted recomposition when using Context/Toast in event - Jetpack Compose - android

In a Jetpack Compose application, I have two composables similar to here:
#Composable
fun Main() {
println("Composed Main")
val context = LocalContext.current
var text by remember { mutableStateOf("") }
fun update(num: Number) {
text = num.toString()
Toast.makeText(context, "Toast", Toast.LENGTH_SHORT).show()
}
Column {
Text(text)
Keypad { update(it) }
}
}
#Composable
fun Keypad(onClick: (Number) -> Unit) {
println("Composed Keypad")
Column {
for (i in 1..10) {
Button(onClick = {onClick(i)}) {
Text(i.toString())
}
}
}
}
Clicking each button causes the two composables to recompose and produces this output:
I/System.out: Composed Main
I/System.out: Composed Keypad
Recomposing the Keypad composable is unneeded and makes the app freeze (for several seconds in a bigger project).
Removing usages of context in the event handles (in here, commenting out the Toast) solves the problem and does not recompose the Keypad and produces this output:
I/System.out: Composed Main
Is there any other way I could use context in an event without causing unneeded recompositions?

The issue is the Context not being a stable (#Stable) type. The lambda/callback of KeyPad is updating a state and its immediately followed by a component that uses an unstable Context, this results to the onClickLambda to be re-created (you can see its hashcode changing everytime you click a button), thus making the Keypad composable not skippable.
You can consider four approaches to deal with your issue. I also made some changes to your code removing the local function and put everything directly in the lambda/callback to make everything smaller.
For the first two, start first by creating a generic wrapper class like this.
#Stable
data class StableWrapper<T>(val value: T)
Wrapping Context in the #Stable wrapper
Using the generic wrapper class, you can consider wrapping the context and use it like this
#Composable
fun Main() {
Log.e("Composable", "Composed Main")
var text by remember { mutableStateOf("") }
val context = LocalContext.current
val contextStableWrapper = StableWrapper(context)
Column {
Text(text)
Keypad {
text = it.toString()
Toast.makeText(contextStableWrapper.value, "Toast", Toast.LENGTH_SHORT).show()
}
}
}
Wrapping your Toast in the #Stable wrapper
Toast is also an unstable type, so you have to make it "stable" with this second approach.
Note that this only applies if your Toast message will not change.
Hoist them up above your Main where you'll create an instance of your static-message Toast and put it inside the stable wrapper
val toastWrapper = StableWrapper(
Toast.makeText(LocalContext.current, "Toast", Toast.LENGTH_SHORT)
)
Main(toastWrapper = toastWrapper)
and your Main composable will look like this
#Composable
fun Main(toastWrapper: StableWrapper<Toast>) {
Log.e("Composable", "Composed Main")
var text by remember { mutableStateOf("") }
Column {
Text(text)
Keypad {
text = it.toString()
toastWrapper.value.show()
}
}
}
remember{…} the Context
(I might expect some correction here), I think this is called "memoizing the value (Context) inside remember{…}", this looks similar to a deferred read.
#Composable
fun Main() {
Log.e("Composable", "Composed Main")
var text by remember { mutableStateOf("") }
val context = LocalContext.current
val rememberedContext = remember { { context } }
Column {
Text(text)
Keypad {
text = it.toString()
Toast.makeText(rememberedContext(), "Toast", Toast.LENGTH_SHORT).show()
}
}
}
Use Side-Effects
You can utilize Compose Side-Effects and put the Toast in them.
Here, SideEffect will execute every post-recomposition.
SideEffect {
if (text.isNotEmpty()) {
Toast.makeText(context, "Toast", Toast.LENGTH_SHORT).show()
}
}
or you can utilize LaunchedEffect using the text as its key, so on succeeding re-compositions, when the text changes, different from its previous value (invalidates), the LaunchedEffect will re-execute and show the toast again
LaunchedEffect(key1 = text) {
if (text.isNotEmpty()) {
Toast.makeText(context, "Toast", Toast.LENGTH_SHORT).show()
}
}
Replacing your print with Log statements, this is the output of any of the approaches when clicking the buttons
E/Composable: Composed Main // first launch of screen
E/Composable: Composed Keypad // first launch of screen
// succeeding clicks
E/Composable: Composed Main
E/Composable: Composed Main
E/Composable: Composed Main
E/Composable: Composed Main
The only part I'm still not sure of is the first approach, even if Toast is not a stable type based on the second, just wrapping the context in the stable wrapper in the first approach is sufficient enough for the Keypad composable to get skipped.

Related

Lambda function used as input argument causes recomposition

Consider snippet below
fun doSomething(){
}
#Composable
fun A() {
Column() {
val counter = remember { mutableStateOf(0) }
B {
doSomething()
}
Button(onClick = { counter.value += 1 }) {
Text("Click me 2")
}
Text(text = "count: ${counter.value}")
}
}
#Composable
fun B(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("click me")
}
}
Now when pressing "click me 2" button the B compose function will get recomposed although nothing inside it is got changed.
Clarification: doSomething is for demonstration purposes. If you insist on having a practical example you can consider below usage of B:
B{
coroutinScope.launch{
bottomSheetState.collapse()
doSomething()
}
}
My questions:
Why this lamda function causes recomposition
Best ways to fix it
My understanding of this problem
From compose compiler report I can see B is an skippable function and the input onClick is stable. At first I though its because lambda function is recreated on every recomposition of A and it is different to previous one. And this difference cause recomposition of B. But it's not true because if I use something else inside the lambda function, like changing a state, it won't cause recomposition.
My solutions
use delegates if possible. Like viewmode::doSomething or ::doSomething. Unfortunately its not always possible.
Use lambda function inside remember:
val action = remember{
{
doSomething()
}
}
B(action)
It seems ugly =)
3. Combinations of above.
When you click the Button "Click me 2" the A composable is recomposed because of Text(text = "count: ${counter.value}"). It happens because it recompose the scope that are reading the values that can change.
If you are using something like:
B {
Log.i("TAG","xxxx")
}
the B composable is NOT recomposed clicking the Button "Click me 2".
If you are using
B{
coroutinScope.launch{
Log.i("TAG","xxxx")
}
}
the B composable is recomposed.
When a State is read it triggers recomposition in nearest scope. And a scope is a function that is not marked with inline and returns Unit.
To use a coroutinScope you have to use rememberCoroutineScope that is a composable inline function. The the body of inline composable functions are simply copied into their call sites, such functions do not get their own recompose scopes.
To avoid it you can use:
B {
Log.i("TAG","xxxx")
}
and
#Composable
fun B(onClick: () -> Unit) {
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
onClick()
}
}
) {
Text(
"click me ",
)
}
}
Sources and credits:
Thracian's answer: Jetpack Compose Smart Recomposition
What is “donut-hole skipping” in Jetpack Compose? post: https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose
scoped recomposition: https://dev.to/zachklipp/scoped-recomposition-jetpack-compose-what-happens-when-state-changes-l78
You can use the LogCompositions composable described in the 2nd post to check the recomposition in your code.
Generally speaking, if you are using a property inside a lambda function that is unstable, it causes the child compose function unskippable and thus gets recomposed every time its parent gets recomposed. This is not something easily visible and you need to be careful with it. For example, the bellow code will cause B to get recomposed because coroutinScope is an unstable property and we are using it as an indirect input to our lambda function.
fun A(){
...
val coroutinScope = rememberCoroutineScope()
B{
coroutineScope.launch {
doSomething()
}
}
}
To bypass this you need to use remember around your lambda or delegation (:: operator). There is a note inside this video about it. around 40:05
There are many other parameters that are unstable like context. To figure them out you need to use compose compiler report.
Here is a good explanation about the why: https://multithreaded.stitchfix.com/blog/2022/08/05/jetpack-compose-recomposition/

Jetpack Compose: Provide initial value for TextField

I want to achieve the following use case: A payment flow where you start with a screen to enter the amount (AmountScreen) to pay and some other screens to enter other values for the payment. At the end of the flow, a summary screen (SummaryScreen) is shown where you can modify the values inline. For the sake of simplicity we will assume there is only AmountScreen followed by SummaryScreen.
Now the following requirements should be realized:
on AmountScreen you don't loose your input on configuration change
when changing a value in SummaryScreen and go back to AmountScreen (using system back), the input is set to the changed value
AmountScreen and SummaryScreen must not know about the viewModel of the payment flow (PaymentFlowViewModel, see below)
So the general problem is: we have a screen with an initial value for an input field. The initial value can be changed on another (later) screen and when navigating back to the first screen, the initial value should be set to the changed value.
I tried various approaches to achieve this without reverting to Kotlin flows (or LiveData). Is there an approach without flows to achieve this (I am quite new to compose so I might be overlooking something obvious). If flows is the correct approach, would I keep a MutableStateFlow inside the PaymentFlowViewModel for amount instead of a simple string?
Here is the approach I tried (stripped and simplified from the real world example).
General setup:
internal class PaymentFlowViewModel : ViewModel() {
var amount: String = ""
}
#Composable
internal fun NavigationGraph(viewModel: PaymentFlowViewModel = viewModel()) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "AMOUNT_INPUT_SCREEN"
) {
composable("AMOUNT_INPUT_SCREEN") {
AmountInputRoute(
// called when the Continue button is clicked
onAmountConfirmed = {
viewModel.amount = it
navController.navigate("SUMMARY_SCREEN")
},
// apply the entered amount as the initial value for the input text
initialAmount = viewModel.amount
)
}
composable("SUMMARY_SCREEN") {
SummaryRoute(
// called when the amount is changed inline
onAmountChanged = {
viewModel.amount = it
},
// apply the entered amount as the initial value for the input text
amount = viewModel.amount
)
}
}
}
The classes of the AmountScreen look like this:
#Composable
internal fun AmountInputRoute(
initialAmount: String,
onAmountConfirmed: (String) -> Unit
) {
// without the "LaunchedEffect" statement below this fulfils all requirements
// except that the changed value from the SummaryScreen is not applied
val amountInputState: MutableState<String> = rememberSaveable { mutableStateOf(initialAmount) }
// inserting this fulfils the req. that the changed value from SummaryScreen is
// applied, but breaks keeping the entered value on configuration change
LaunchedEffect(Unit) {
amountInputState.value = initialAmount
}
Column {
AmountInputView(
amountInput = amountInputState.value,
onAmountChange = { amountInput ->
amountInputState.value = amountInput
}
)
Button(onClick = { onAmountConfirmed(amountInputState.value) }) {
Text(text = "Continue")
}
}
}
```
I achieved the goal with a quite complicated approach - I would think there are better alternatives out there.
What I tried that did not work: using rememberSaveable passing initialAmount as parameter for inputs. Theoretically rememberSaveable would reinitialize its value when inputs changes, but apparently this does not happen when the composable is only on the back stack and also is not executed when it gets restored from the back stack.
What I implemented that did work:
#Composable
internal fun AmountInputRoute(
initialAmount:String,
onAmountConfirmed: (String) -> Unit
) {
var changedAmount by rememberSaveable {
mutableStateOf<String?>(null)
}
val amountInput by derivedStateOf {
if (changedAmount != null)
changedAmount
else
initialAmount
}
AmountInputView(
amountInput = amountInput,
onContinueClicked = {
onAmountConfirmed(amountInput)
changedAmount = null
},
validAmountChanged = {
changedAmount = it
}
)
}
Any better ideas?

Global Snackbar Handling Jetpack Compose

Seeing as I have multiple places where snackbars could be triggered, I want to have a central place in my app where I can handle showing/dismissing snackbars.
This is the structure of my app:
I've implemented a BaseViewModel that contains a StateFlow which should keep track of the SnackBar message (every other ViewModel inherits from this BaseViewModel):
#HiltViewModel
open class BaseViewModel #Inject constructor() : ViewModel() {
val _snackBarMessage = MutableStateFlow("")
val snackBarMessage: StateFlow<String> = _snackBarMessage
}
To test if the update of the StateFlow is triggered correctly, I've implemented a message that should update the StateFlow after every login:
private fun setSnackBarMessage() {
_snackBarMessage.value = "A wild snackBar appeared"
}
MainContent contains my Scaffold (incl. scaffoldState, snackbarHost), should react to changes in the snackBarMessage flow and display/dismiss the Snackbar when needed:
fun MainContent(...){
val message by viewModel.snackBarMessage.collectAsState()
LaunchedEffect(message) {
if (message.isNotEmpty() Timber.d("We got a snackbar")
}
Scaffold(...){...}
}
During debugging, I noticed that after every login the snackBarMessage value is updated correctly but MainContent does not get those updates which, in turn, means that the snackbar is never displayed.
Is there a reason why MainContent does not get those updates from the LoginComposable?
Is it even possible to have a central instance of a snackbar or do I really need to handle snackbars separately in every Composable?
You can use this
#Composable
fun MainScreen() {
val coroutineScope = rememberCoroutineScope()
val showSnackBar: (
message: String?,
actionLabel: String,
actionPerformed: () -> Unit,
dismissed: () -> Unit
) -> Unit = { message, actionLabel, actionPerformed, dismissed ->
coroutineScope.launch {
val snackBarResult = scaffoldState.snackbarHostState.showSnackbar(
message = message.toString(),
actionLabel = actionLabel
)
when (snackBarResult) {
SnackbarResult.ActionPerformed -> actionPerformed.invoke()
SnackbarResult.Dismissed -> dismissed.invoke()
}
}
}
//Global using
showSnackBar.invoke(
"YOUR_MESSAGE",
"ACTION_LABEL",
{
//TODO ON ACTION PERFORMED
},
{
//TODO ON DISMISSED
}
)
}
Probably, the cause of your problem is using message as the key for LaunchedEffect and not changing the message at the same time. In the documentation you can read that type of side-effect will be re-launched after key modification.
If LaunchedEffect is recomposed with different keys (see the
Restarting Effects section below), the existing coroutine will be
cancelled and the new suspend function will be launched in a new
coroutine.
Some effects in Compose, like LaunchedEffect, produceState, or
DisposableEffect, take a variable number of arguments, keys, that are
used to cancel the running effect and start a new one with the new
keys.
I suggest wrapping snackbar message in some kind of object (not data class) with field containing snackbar content.
Cheers

Composable reparenting in Jetpack Compose

Is there a way to reparent a Composable without it losing the state? The androidx.compose.runtime.key seems to not support this use case.
For example, after transitioning from:
// This function is in the external library, you can not
// modify it!
#Composable
fun FooBar() {
val uid = remember { UUID.randomUUID().toString() }
Text(uid)
}
Box {
Box {
FooBar()
}
}
to
Box {
Row {
FooBar()
}
}
the Text will show a different message.
I'm not asking for ways to actually remember the randomly generated ID, as I could obviously just move it up the hierarchy. What I want to archive is the composable keeping its internal state.
Is this possible to do without modifying the FooBar function?
The Flutter has GlobalKey specifically for this purpose. Speaking Compose that might look something like this:
val key = GlobalKey.create()
Box {
Box {
globalKey(key) {
FooBar()
}
}
}
Box {
Row {
globalKey(key) {
FooBar()
}
}
}
This is now possible with
movableContentOf
See this example:
val boxes = remember {
movableContentOf {
LetterBox(letter = 'A')
LetterBox(letter = 'B')
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = { isRow = !isRow }) {
Text(text = "Switch")
}
if (isRow) {
Row(
Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
boxes()
}
} else {
Column(
Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
boxes()
}
}
}
remember will store only one value in the same view. The key in Compose has a very different purpose: if the key passed to remember has a different value from the last recomposition, it means that the old value is no longer relevant and must be recomputed.
There is no direct equivalent of Flutter keys in Compose.
You can simply declare a global variable. In case you need to change it, wrap it with a mutable state, so changes will update your view.
var state by mutableStateOf(UUID.randomUUID().toString())
I'm not sure if that the same what GlobalKey does, in any case it's not the best practice, just like any other global variable.
If you need to share some data between views, it is much cleaner to use view models.
#Composable
fun TestScreen() {
val viewModel = viewModel<SomeViewModel>()
Column {
Text("TestScreen text: ${viewModel.state}")
OtherView()
}
}
#Composable
fun OtherView() {
val viewModel = viewModel<SomeViewModel>()
Text("OtherScreen text: ${viewModel.state}")
}
class SomeViewModel: ViewModel() {
var state by mutableStateOf(UUID.randomUUID().toString())
}
The hierarchy topmost viewModel call creates a view model - in my case inside TestScreen. All children that call viewModel of the same class will get the same object. The exception to this is different destinations of Compose Navigation, see how to handle this case in this answer.
You can update the mutable state value, and it will be reflected on all views using that model. Check out more about state in Compose.
When the view that created the view model is removed from the view hierarchy, the view model is also freed, so a new one will be created next time.

Jetpack Compose: Not able to show text in TextField

Recently I'm playing with Jetpack Compose and I noticed that the text may not show up in TextField.
So I have a ViewModel with Flow of ViewState.
In my Compose file, I have something similar to this:
#Composable
internal fun TestScreen() {
val state by viewModel.state.collectAsState()
TestScreen {
viewState = state,
actioner = { ... }
}
}
#Composable
private fun TestScreen(viewState: ViewState, actioner: () -> Unit) {
var name by remember {
mutableStateOf(
TextFieldValue(viewState.name)
)
}
Surface {
....
Column {
....
OutlinedTextField(
...
value = name,
onValueChange = { textFieldValue ->
name = textFieldValue
actioner(...)
}
)
}
}
}
the OutlineTextField will never show what's already inside viewState.name
However, if I change this:
var name by remember {
mutableStateOf(
TextFieldValue(viewState.name)
)
}
To this:
var name = TextFieldValue(viewState.name)
Obviously it could show the value in viewState.name.
According to the Documentation (https://developer.android.com/jetpack/compose/state#state-in-composables) in which it recommends using remember & mutableStateOf to handle the changes.
I'll be very grateful if someone could help me to explain why the code with remember doesn't work but the directly assigned value worked?
EDIT
viewState.name is a String
and I "partially solved" this issue by doing the following:
var name by remember {
mutableStateOf(
TextFieldValue("")
)
}
name = TextFieldValue(viewState.name)
then the name can be shown. But it doesn't look quite right?
remember is used just to ensure that upon recomposition, the value of the mutableStateOf object does not get re-initialised to the initial value.
For example,
#Composable
fun Test1(){
var text by mutableStateOf ("Prev Text")
Text(text)
Button(onClick = { text = "Updated Text" }){
Text("Update The Text")
}
}
would not update the text on button click. This is because button click will change the mutableStateOf text, which will trigger a recomposition. However, when the control reaches the first line of the Composable, it will re-initialise the variable text to "Prev Text".
This is where remember comes in.
If you change the initialisation above to
var text by remember { mutableStateOf ("Prev Text") },
It wil tell compose to track this variable, and "remember" its value, and use it again on recomposition, when the control reaches the initialisation logic again. Hence, remember over there acts as a "guard" that does not let the control reach into the initialisation logic, and returns that latest remembered value of the variable it currently has in store.

Categories

Resources