Blog Infos
Author
Published
Topics
,
Published

In this post, I am going through the process of investigating the code and figuring out what is going on.

The app I got was very simple. One slider, button and text. We can move the slider and clicking on the button changes text value. Both text and slider are stored in the same state object in the ViewModel.

                                       Screen from the app

Desired behaviour was the following. Whenver  changes only  is re-composed, not  . However, given code was also updating  and I wanted to know why.

                                            Data flow

To start investigation, I need to share with you the code I was given:

class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeStateTestTheme {
val state: MainState by viewModel.state.collectAsState()
MainScaffold(
state,
onValueUpdate = { viewModel.updateSlider(it.roundToInt()) },
onButtonClick = { viewModel.updateCounter() }
)
}
}
}
}
view raw MainActivity.kt hosted with ❤ by GitHub
@Composable
fun MainScaffold(state: MainState, onValueUpdate: (Float) -> Unit, onButtonClick: () -> Unit) {
Scaffold(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.verticalScroll(rememberScrollState())
) {
Slider(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
value = state.sliderValue.toFloat(),
onValueChange = onValueUpdate,
valueRange = 0f..10f,
steps = 10
)
CounterRow(counter = state.counter, onButtonClick = onButtonClick)
}
}
}
@Composable
fun CounterRow(counter: Int, onButtonClick: () -> Unit) {
/** SHOULD NOT BE CALLED ON SLIDER CHANGE **/
Row(modifier = Modifier.fillMaxWidth()) {
Button(onClick = onButtonClick) {
Text(text = "Click me!")
}
Spacer(modifier = Modifier.width(24.dp))
Text(text = counter.toString())
}
}
view raw MainScaffold.kt hosted with ❤ by GitHub
data class MainState(
val sliderValue: Int = 0,
val counter: Int = 0
)
view raw MainState.kt hosted with ❤ by GitHub
class MainViewModel : ViewModel() {
private val _state = MutableStateFlow(MainState())
val state: StateFlow<MainState>
get() = _state.asStateFlow()
fun updateCounter() {
_state.value = state.value.copy(counter = state.value.counter.plus(1))
}
fun updateSlider(value: Int) {
_state.value = state.value.copy(sliderValue = value)
}
}

What we are examining is the following composable:

@Composable
fun CounterRow(counter: Int, onButtonClick: () -> Unit) {
    /** SHOULD NOT BE CALLED ON SLIDER CHANGE **/
    Row(modifier = Modifier.fillMaxWidth()) {
        Button(onClick = onButtonClick) {
            Text(text = "Click me!")
        }
        Spacer(modifier = Modifier.width(24.dp))
        Text(text = counter.toString())
    }
}

With the original code whenever we moved the slider,  was re-created. How do I know? Just put a logging call inside the function or a non-suspendable breakpoint inside that prints to logs whenever a function is executed.

                 Right-click on a breakpoint in Android Studio
Property to blame

The first thought was that it’s maybe the  parameter in  function because it comes from the  object and that object changes whenever the slider is moved. However, it’s not the case because what matters for re-composition here is the fact that the parameter is a primitive type  and on subsequent compositions value is not changing.

So I told to myself, let’s check the second param and that was it. A simple check with removing the param proved I am right:

@Composable
fun CounterRow(counter: Int) {
    /** onButtonClick removed from params **/
    Row(modifier = Modifier.fillMaxWidth()) {
        Button(onClick = {}) {
            Text(text = "Click me!")
        }
        Spacer(modifier = Modifier.width(24.dp))
        Text(text = counter.toString())
    }
}
Documentation to the rescue

I knew what causes re-composition but I didn’t know why. So it was time to look into documentation. Fortunately, Google did a good job with documenting Jetpack Compose. If you wanna learn about re-composition check out the Lifecycle section in the documentation. I will be referring to that section to explain what is going on.

Below you will find the most important part of documentation explaining what it takes for inputs of composable (function parameters) to be skippable on the next composition.

                                Part of Lifecycle documentation

After reading that part carefully a few times I was puzzled. I was passing primitive  and lambda of a type  that never changes. But is it really never changing?

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

Let’s examine  where callback is created:

ComposeStateTestTheme {
    val state: MainState by viewModel.state.collectAsState()
    MainScaffold(
        state,
        onValueUpdate = { viewModel.updateSlider(it.roundToInt()) },
        onButtonClick = { viewModel.updateCounter() }
    )
}

As you can see it is created whenever  is created. Since  changes on slider change, our  along with is also recreated. Compose uses  to check if composable’s input has changed and it did in this case. You can easily test it by putting another breakpoint or log message with lambda’s  . I could end here but I wanted to know what can I do to actually avoid that re-composition for  .

My initial thought was to move  to a variable so it’s reused every time:

val state: MainState by viewModel.state.collectAsState()
    val onButtonClick = { viewModel.updateCounter() }
    MainScaffold(
        state,
        onValueUpdate = { viewModel.updateSlider(it.roundToInt()) },
        onButtonClick = onButtonClick
    )
}

Problem solved, right? Not so fast my friends 🙂

val onButtonClick = { viewModel.updateCounter() }

I have found out that the above line is also called again when  changes. Everything inside  is called again since inside that composable our  is read. So the solution to the problem is moving lambda creation out of it:

setContent {
    val state: MainState by viewModel.state.collectAsState()
    val onButtonClick = { viewModel.updateCounter() }
    ComposeStateTestTheme {
        MainScaffold(
            state,
            onValueUpdate = { viewModel.updateSlider(it.roundToInt()) },
            onButtonClick = onButtonClick
        )
    }
}

Moving  out is not needed at all but it looks a bit cleaner to have it on the same level. Now we are done. Problem solved.

EDIT:

 had a very good point in the comment. We don’t have to move lambda but instead use method reference:

setContent {
    ComposeStateTestTheme {
        val state: MainState by viewModel.state.collectAsState()
        MainScaffold(
            state,
            onValueUpdate = { viewModel.updateSlider(it.roundToInt()) },
            onButtonClick = viewModel::updateCounter
        )
    }
}

That way we are reusing existing function in the ViewModel every time re-composition happens.

key()

There is one more thing I tried but decided to write about it last because that wasn’t that relevant yet a bit surprising.

Documentation mention  function to be very useful if we need to explicitly tell what should be used to compare composable on re-composition. I thought that maybe that can solve the problem for me, if I wrap my  with it, like this:

key(state.counter) {
    CounterRow(counter = state.counter, onButtonClick = onButtonClick)    
}

I was mistaken. It doesn’t work like that. In the docs, there is an example with a collection of movies and a movie being injected at the top of the collection. Each movie composable is associated with the collection index. So moving all movies down changes the index for all of them. But, when re-composition happens old composables are not gone. First, compose checks which can be re-used and  helps with that.

In our case, there is just 1 old composable and it has different input than a new one. So  is not able to point another composable because there is none.

Conclusion

I hope this study will help others to understand better Compose and encourage to make own. I believe it’s very important to dive deep and understand how things work. Especially now, when Jetpack Compose is super fresh and new for everyone. It’s very easy to get messy in more complex cases if we don’t understand the basics.

Happy coding!

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

1 Comment. Leave new

  • Santosh
    20.10.2021 3:42

    Thank you for this awesome article. I am currently facing the same issue in my app and seeing unwanted recomposition. I did put a log and found that the click listeners or lambda functions are the culprits as you have pointed out here. But, in my case , I have list of items and I do need to know which items has been clicked. So for that, my lambda would be

    click: ( someItem) -> unit

    and for this I have to pass or wrap around the lambda in my list item iteration like

    click = { clickAction(someItem)}.
    So, is there any way we could achieve the same as you have described out here? Thank you

    So,

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu