7

I have a TextField for a search query and a Button that will execute the search and the results are shown in a column. Since the search takes a few seconds to run I want it to be executed on button press and not on text change.

Here is a simplified demonstration:

Column {
    val list = remember { mutableStateListOf<String>() }
    val textFieldValue = remember { mutableStateOf(TextFieldValue("")) }

    TextField(
        value = textFieldValue.value,
        onValueChange = { textFieldValue.value = it }
    )

    Button({
        list.clear()
        list.addAll(textFieldValue.value.text.split(""))
    }) {
        Text("Search")
    }

    list.forEach {
        println("test")
        Text(it)
    }
}

After the first time that the button is pressed, the foreach loop will run on text change. Even clicking on the TextField will rerun the loop. This doesn't run the search on text change, but re-renders the results and that causes glitches while typing in the text field.

How can this be prevented?

nima
  • 6,566
  • 4
  • 45
  • 57

3 Answers3

12

The above is true for Jetpack Compose. The author wanted to know about Compose Desktop, and it's not the same there yet, because it's in alpha and is not optimised that much yet.

Modifying a mutableState value always leads to a recomposition of all views, which its read value.

Any changes to value will schedule recomposition of any composable functions that read value. documentation

The way to stop it is moving out all views that reads mutableState value into a separate view. It'll be recomposed for each mutableState value change, but it won't affect the container.

In your case it's pretty simple: just move TextField and pass textFieldValue into the new function. You can forward all params you need, like modifier, textStyle, etc.

@Composable
fun TestView(
) {
    Column {
        val textFieldValue = remember { mutableStateOf(TextFieldValue("")) }
        val list = remember { mutableStateListOf<String>("test") }

        TextField(textFieldValue)

        Button({
            list.clear()
            list.addAll(textFieldValue.value.text.split(""))
        }) {
            Text("Search")
        }

        list.forEach {
            println("test $it")
            Text(it)
        }
    }
}

@Composable
fun TextField(
    textFieldValue: MutableState<TextFieldValue>,
) {
    TextField(
        value = textFieldValue.value,
        onValueChange = { textFieldValue.value = it }
    )
}

I'm not sure why there's no system function with this semantics, but in compose they prefer State hoisting pattern to match UDF.

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • 1
    Your code is correct, but you are mistaken about why. Modifying a value inside a `remember` does not inherently cause anything to happen, and the location of the `remember` call is not important. You could, if you liked, set up `textFieldValue` and pass it to `TextAndButton`, and it would still work properly. – Ryan M Aug 04 '21 at 02:53
  • @RyanM I'm pretty confused right now =) even moving `Button` seems not necessary. I've updated the code in the answer that doesn't leads to a recomposition too. Looks like it's smart enough to not recompose the whole view if only one view depends on it. but how is it different from the original code? only `TextField` was depending on its value there. And if this is so, why all `TextField`s accepts `value`+`onValueChange` instead of `MutableState`? This would prevent recompositions in many cases. – Phil Dukhov Aug 04 '21 at 03:11
  • @RyanM I kind of sorted that out and updated my answer, still not sure why there's no defined `TextField` with `MutableState` parameter in Compose framework, as most of users would use it without a model, just with `remember`, so having both variants would be cool. – Phil Dukhov Aug 04 '21 at 04:48
  • 1
    It seems that compose is smart enough to see the link, even if you put the variable in another composable. I believe there's no way around it because this is how the designers wanted it to be. The only thing you can do is to make your view as simple as possible and precalculate everything so that each recomposition is as fast as possible. – nima Aug 04 '21 at 11:14
  • @nima doesn't my answer solves your question? It stops recompositions of the whole view and only recomposes the `TextField` – Phil Dukhov Aug 04 '21 at 12:19
  • I tested it and the `foreach` loop is run on text change and even on click. – nima Aug 04 '21 at 12:58
  • @nima quite strange, it works in my case. Are you using stable compose version 1.0.0? Can you try code from my original answer https://stackoverflow.com/revisions/68644780/1? it should work 100% – Phil Dukhov Aug 04 '21 at 13:02
  • It's the same with `TextAndButton`. I'm using compose for desktop, maybe it's behavior is different from mobile. – nima Aug 04 '21 at 13:51
  • 1
    @nima oh I haven't noticed the desktop tag. Yes, it's pretty raw so recomposition may not be ideal yet. android compose hasn't been focused much on performance until betas. Most probably it'll be improved for desktop in future releases, but you can clarify this in [kotlin slack](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up), #compose-desktop channel. I think until it's not much close to release that's the best place to ask questions, as there's not many experts at all and in slack you can communicate with the maintainers – Phil Dukhov Aug 04 '21 at 14:06
0

I don't prefer moving MutableState<> around as parameter just like i never use LiveData<> as parameter. Instead you could turn reading into lambda:

@Composable
fun TextField(value: ()->TextFieldValue, 
              onValueChange: (TextFieldValue)->Unit) {
    TextField(
        value = value(),
        onValueChange = { onValueChange(it) }
    )
} 
// call like
TextField(
        value = { textFieldValue.value },
        onValueChange = { textFieldValue.value = it }
)
Jemshit
  • 9,501
  • 5
  • 69
  • 106
  • I don't understand what this achieves – nima Sep 13 '21 at 07:40
  • @nima same as accepted answer, just different approach on extracting TextField into new Composable function – Jemshit Sep 13 '21 at 07:42
  • To convert a MutableState into a `() -> T` and `(T) -> Unit`, you can use `remember {{ mutableState.component1() }}` and `remember { mutableState.component2() }`. – EpicPandaForce Jan 12 '23 at 17:40
-2
Column {
    val list = remember { mutableStateListOf<String>() }
    var textFieldValue = remember { mutableStateOf(TextFieldValue("")) }
    var searchTerm = remember { textFieldValue.value.text.copy() }

    TextField(
        value = textFieldValue.value,
        onValueChange = { textFieldValue.value = it }
    )

    Button({
        searchTerm = textFieldValue.value.text.copy()
        list.clear()
        list.addAll(searchTerm.text.split(""))
    }) {
        Text("Search")
    }

    list.forEach {
        println("test")
        Text(it)
    }
}

Try this

Richard Onslow Roper
  • 5,477
  • 2
  • 11
  • 42
  • This has multiple compile errors. `textFieldValue` is a `MutableState` and doesn't have a copy method. `searchTerm` is val and cannot be assigned to. – nima Aug 03 '21 at 21:00
  • Still has compile errors. `textFieldValue.value.text` is a `String` and doesn't have a copy method. You don't actually need to copy a string in Kotlin, just assigning it to a new variable creates a new instance. – nima Aug 03 '21 at 21:51
  • Alright if you know about it, then just please make the necessary modifications and check the result. Thanks – Richard Onslow Roper Aug 03 '21 at 22:21
  • I don't see how this attempts to avoid any recompositions at all. – Simon Forsberg May 24 '23 at 20:07