Jemshit
Jemshit

Reputation: 10048

Jetpack Compose Modifier with same parameters forces recomposition

I have this ScrollableTabRow with list of tabs inside:

@Immutable
enum class Tab(val title:Int){
    Some(R.string.some),
    Other(R.string.other);
}

@Composable
fun MyTab(modifier: Modifier = Modifier,
          tabs: List<Tab>,
          selectedTab: Tab,
          onTabSelected: (Tab) -> Unit) {
    Log.d("MyTab", "Draw")

    if (tabs.isEmpty()) {
        return
    }

    ScrollableTabRow(selectedTabIndex = tabs.indexOf(selectedTab),
                     modifier
                         .navigationBarsPadding()
                         .requiredHeight(48.dp),
                     backgroundColor = Color.Transparent,
                     indicator = {},
                     divider = {}) {

        tabs.forEach { tab ->
            Log.d("MyTab", "${tab.title}-${tab == selectedTab}")
            val tabModifier = Modifier
                .clip(RoundedCornerShape(8.dp))
                .padding(horizontal = 8.dp)
                .shadow(0.5.dp, RoundedCornerShape(8.dp))
                .background(MaterialTheme.colors.surface)
                .border(1.dp,
                    if (tab==selectedTab) MaterialTheme.appColors.bottomTabSelect else Color.Transparent,
                    RoundedCornerShape(8.dp))
                .clickable {
                    if (tab==selectedTab) {
                        // already selected
                        return@clickable
                    }
                    onTabSelected(tab)
                }
                .padding(8.dp)

            Test(tab.title, tabModifier, logger)
            
        }
    }

}


@Composable
private fun Test(title: Int,
                 modifier: Modifier) {
    Log.d("MyTab", "Test")

    Text(text = stringResource(id = title),
         modifier = modifier,
         color = MaterialTheme.colors.onSurface,
         fontSize = 12.sp,
         textAlign = TextAlign.Center)
}

Simply, it lays out list of tabs horizontally. Whenever tab is selected, its modifier is updated and border with primary color is drawn. If not selected, then border is transparent.

My aim is to avoid recomposition of tabs (Test composable in code) if their state is not changed. So if i have 5 tabs, and i select a new tab, only 2 tabs' states are changed and other 3 tabs should not get recomposed.

Test composable has Int, Modifier as parameters. On recomposition of MyTab, title:Int of Test does not change but tabModifier is created again with same parameters (but new instance). This somehow forces recomposition of all 5 tabs when new tab is selected. But if i move tabModifier inside Test and give tab==selectedTab, tab, selectedTab as parameters, it works as expected.

Is Modifier created again with same parameters not Stable? Can we avoid recomposition without moving tabModifier into Test? (Modifier interface is marked as @Stable)

Upvotes: 3

Views: 1063

Answers (1)

Jo&#227;o Ferreira
Jo&#227;o Ferreira

Reputation: 55

I don't know exactly the impact of it, but wrapping the Modifier inside a remember solved the problem of recomposition for me.

Text(
    "Sample text",
    modifier = remember { <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
        Modifier.clickable(onClick = { viewModel.stopFlow() })
    },
    color = Color.Red,
    style = MaterialTheme.typography.h4
)

Edit:

Explaining why the recomposition is happening

The compose checks for different attributes compared to previous recompositions to decide whether or not to recompose a composable. In this case, it checks if the current lambda expression ({ viewModel.stopFlow() }) is the same as the previous composition. Because of the nature of a lambda expression, it is recreated again and again everytime the code is rerun (and compose reruns the UI code many many times to check if it needs recomposition), and every time it reruns the lambda expression, the compose is saying: "Hey, the memory reference of this lambda has changed, it is a new one! Let's recompose this part of the UI then :D" and it recomposes. Lambdas are kinda of a pain for composable performance and you have to be careful.

How to handle lambdas then?

One way to handle it is by using rememberUpdatedState(value). As the documentation of the function says:

[rememberUpdatedState] should be used when parameters or values computed during composition are referenced by a long-lived lambda or object expression. Recomposition will update the resulting [State] without recreating the long-lived lambda or object, allowing that object to persist without cancelling and resubscribing, or relaunching a long-lived operation that may be expensive or prohibitive to recreate and restart.

Basically, if your code has something that must live along recompositions but its state changes, by nature, every recomposition, you should be using it. Lambdas are the perfect scenario.

So this piece of code may now look like this and works as expected:

val stopFlow by rememberUpdatedState { viewModel.stopFlow() }
Text(
    "Sample text",
    modifier = Modifier.clickable(onClick = stopFlow),
    color = Color.Red,
    style = MaterialTheme.typography.h4
)

Remember to refer the new "stopFlow" variable directly and do not create a new lambda like { stopFlow() }. This way, the reference of the lambda will live along recomposition and the component should now be recomposed only when needed.

Upvotes: 2

Related Questions