aerfanr
aerfanr

Reputation: 51

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

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?

Upvotes: 4

Views: 1832

Answers (1)

z.g.y
z.g.y

Reputation: 6257

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.

Upvotes: 3

Related Questions