How can I make Android Jetpack Compose AndroidView be replaced when the parameters that created it change?

6.7k views Asked by At

I have an app that shows several different views encapsulated in AndroidView. In the simple example to reproduce below, these are just TextView instances. The problem is that changing the text (in this case cycling through three different values) doesn't seem to update what the app is displaying.

sealed class AppView
data class ShowSomeText(val text: String) : AppView()
data class SomeOtherState(val data: Any?) : AppView()
data class ShowSomeText2(val text: String) : AppView()

class AppViewModel : ViewModel() {

    var currentView = MutableLiveData<AppView>(ShowSomeText("original text"))
    var currentViewWorkaround = MutableLiveData<AppView>(ShowSomeText("original text"))


    private val textRing = arrayOf("one", "two", "three")
    private var textRingPosition = 0

    fun incrementTextState() {
        val nextState = ShowSomeText(textRing[textRingPosition])
        currentView.postValue(nextState)

        val nextStateWorkaround = when(currentViewWorkaround.value) {
            is ShowSomeText -> ShowSomeText2(textRing[textRingPosition])
            else -> ShowSomeText(textRing[textRingPosition])
        }
        currentViewWorkaround.postValue(nextStateWorkaround)
        textRingPosition = (textRingPosition + 1) % textRing.size
    }
}

class MainActivity : AppCompatActivity() {

    private val viewModel = AppViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ViewContainer(viewModel)
        }
    }
}

@Composable
fun ViewContainer(viewModel: AppViewModel) {

    // Add this to gradle.build for the observeAsState function:
    //     implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
    val currentView: AppView by viewModel.currentView.observeAsState(ShowSomeText("starting text"))
    val currentViewWorkaround: AppView by viewModel.currentViewWorkaround.observeAsState(ShowSomeText("starting text"))

    Column {
        Button(onClick = viewModel::incrementTextState) {
            Text(
                text = "tap to change",
                style = TextStyle(fontSize = 12.em)
            )
        }
        Text("Compose Text")
        when (currentView) {
            is ShowSomeText -> createComposeTextView((currentView as ShowSomeText).text)
            is SomeOtherState -> Text("the other state")
        }
        Text("AndroidView wrapping TextView")
        when (currentView) {
            is ShowSomeText -> createAndroidViewForTextView((currentView as ShowSomeText).text)
            is SomeOtherState -> Text("the other state")
        }
        Text("AndroidView wrapping TextView with 2-state workaround")
        when (currentViewWorkaround) {
            is ShowSomeText -> createAndroidViewForTextView((currentViewWorkaround as ShowSomeText).text)
            is ShowSomeText2 -> createAndroidViewForTextView((currentViewWorkaround as ShowSomeText2).text)
            is SomeOtherState -> Text("the other state")
        }
    }

}

@Composable
fun createAndroidViewForTextView(text: String) {
    val context = ContextAmbient.current
    val tv = remember(text, context) {
        val x = TextView(context)
        x.text = text
        x.textSize = 48.0f
        x
    }
    AndroidView({ tv })
}

@Composable
fun createComposeTextView(text: String) {
    Text(text, style = TextStyle(fontSize = 12.em))
}

The first text is displayed via the Compose Text function and works, the second with a TextView wrapped an AndroidView Compose function and does not work, the third also uses the same AndroidView wrapper but triggers the change somehow by using another state variable.

Why doesn't the middle text update?

Full gist of a reproducing kt file with the hack fix: https://gist.github.com/okhobb/ba7791af4562ea672d0c52769a7cd8ba

============

UPDATE: Working code based on the accepted answer:

@Composable
fun TraditionalViewAsComposable(text: String){

    var updatableString by remember{mutableStateOf("")}
    updatableString = text

    AndroidView(
        factory={ TextView(it).apply {
            this.text = text
            this.textSize = 48.0f
        } },
        update={ it.text = updatableString }
    )
}
2

There are 2 answers

2
D. Kupra On BEST ANSWER

AndroidView() composables do not recompose by default upon statechange. You have to "opt in" to listen to state by explicitly defining an update parameter.

So the syntax would be something like:

@Composable
fun TraditionalViewAsComposable(initialString:String){
    var updatableString by remember{mutableStateOf("")}
    AndroidView(factory={it:Context->
        TraditionalView(it).apply{this:TraditionalView->
            this.property=initialString
        },
        update={it:TraditionalView->
            it.property=updatableString
            },
        modifier=Modifier
        )
}
2
2jan222 On

The index must be a mutable state like: textRingPosition = remember{ mutableStateOf<Int>(0) }.

As a general tip if your Compose Code contains a var something might be wrong and should be a state.

Explaination: If you run this snippet you press the button but the composable is not updated but in the console you see the value changed.

Surface(modifier = Modifier.padding(all = 16.dp).fillMaxSize()) {
    val textRing = arrayOf("one", "two", "three")
    var textRingPosition = 0
    val liveData = MutableLiveData<String>()
    val observeAsState: String by liveData.observeAsState(initial = textRing[textRingPosition])

    Column() {
        println("Composed with " + observeAsState + " and pos: " + textRingPosition)
        Text("Text: " + observeAsState)
        Button(onClick = {
            textRingPosition = textRingPosition.inc().rem(textRing.size)
            println("OnClick: " + textRingPosition)
            liveData.postValue(textRing[textRingPosition])
        }) {
            Text("change")
        }
    }
}

Output in Console:

1 | I/System.out: Composed with one and pos: 0
2 | I/System.out: OnClick: 1
3 | I/System.out: Composed with two and pos: 0
4 | I/System.out: OnClick: 1
5 | I/System.out: OnClick: 2
6 | I/System.out: Composed with three and pos: 0
7 | I/System.out: OnClick: 1
8 | I/System.out: Composed with two and pos: 0
9 | I/System.out: OnClick: 1

In line 4 you can see the index is changed to 1, but it should already be at 2 this is because the view recomposed in Line 3 and reset the index. Compose will not recompose here because the view does not need to update because the value of the current is the same as the new value from the state. In line 5 the index is 2 which changes the value of the state to another string, thus triggering a recomposition.

With a remember and mutable state for the index the component will recompose each change:

Surface(modifier = Modifier.padding(all = 16.dp).fillMaxSize()) {
    val textRing = arrayOf("one", "two", "three")
    var textRingPosition = 0
    val liveData = MutableLiveData<String>()
    val observeAsState: String by liveData.observeAsState(initial = textRing[textRingPosition])

    val rememberTextRingPosition = remember { mutableStateOf(0) }

    Column() {
        println("Composed with $observeAsState and pos: ${textRingPosition}expected value: ${rememberTextRingPosition.value}")
        Text("Text: $observeAsState")
        Button(onClick = {
            textRingPosition = textRingPosition.inc().rem(textRing.size)
            rememberTextRingPosition.value = rememberTextRingPosition.value.inc().rem(textRing.size)
            println("OnClick: var:$textRingPosition remember: ${rememberTextRingPosition.value}")
            liveData.postValue(textRing[textRingPosition])
        }) {
            Text("change")
        }
    }
}

Output:

1 | I/System.out: Composed with one and pos: 0expected value: 0
2 | I/System.out: OnClick: var:1 remember: 1
3 | I/System.out: Composed with two and pos: 0expected value: 1
4 | I/System.out: OnClick: var:1 remember: 2
5 | I/System.out: Composed with two and pos: 0expected value: 2
6 | I/System.out: OnClick: var:1 remember: 0
7 | I/System.out: Composed with two and pos: 0expected value: 0
8 | I/System.out: OnClick: var:1 remember: 1

As the log states, the component gets recomposed each time as it should be and the state is not reset because of the remember.