chrgue
chrgue

Reputation: 639

Jetpack Compose: Row of "AndroidView" with dynamic list

In a ViewModel I maintain a list of strings. If onAddTag or onRemoveTag will be performed a mutableState will be refresh. Which causes a recomposition.

The problem is that if I remove a element (eg the third) with onRemoveTag the updated list arrivies in my "Custom Composable" (debugged) but the rendering only removes the last item from the list.

Example:

In the Documentation of LazyListScope.items there is this key parameter which says:

key - a factory of stable and unique keys representing the item. Using the same key for multiple items in the list is not allowed. Type of the key should be saveable via Bundle on Android. If null is passed the position in the list will represent the key. When you specify the key the scroll position will be maintained based on the key, which means if you add/remove items before the current visible item the item with the given key will be kept as the first visible one.

Maybe the Row does similar things and it can't properly decide which element needs to be remove and which one stays with the positional information only. But how to fix that?

I do not want do use a LazyRow here because I only have a few items to render. Additional to that I want to understand the problem! :)

Code

ViewModel


class MyViewModel : ViewModel() {
   var selectedTags = mutableStateOf(listOf<String>())

   fun onRemoveTag(tag: String) {
      selectedTags.value = selectedTags.value.toMutableList().apply { remove(tag) }
   }

   fun onAddTag(tag: String) {
      selectedTags.value = selectedTags.value.toMutableList().apply { add(tag) }
   }
}
  

MainScreen

fun MainScreen(viewModel: MyViewModel) {
   val selectedTags by remember { viewModel.selectedTags }

   TagRow(tags = selectedTags)
}

Custom Composable

@Composable
fun TagRow(
    tags: List<String>,
    modifier: Modifier = Modifier
) {
    Row(modifier = modifier) {
        tags.forEach {
            Text(text = it)
        }
    }
}

Hope somebody knows how to fix that!

Regards, Chris

EDIT

According to @Philip's feedback I prepared a self-contained example:

@Composable
fun StackOverflowPreview() {
    val tags = remember { mutableStateListOf("1", "2", "3") }

    Row {
        tags.forEach {
            AndroidView(factory = { context ->
                EmojiTextView(context).apply {
                    textAlignment = View.TEXT_ALIGNMENT_CENTER
                    setTextColor(android.graphics.Color.BLACK)
                    layoutParams =
                        LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
                    text = it
                }
            })
        }
    }

    LaunchedEffect(key1 = tags) {
        delay(2000)
        tags.remove("1")
        
        delay(2000)
        tags.remove("2")
        
        delay(2000)
        tags.remove("3")
    }
}

Here you can see that I use EmojiTextView. If I replace that view with a basic Text composable. It works like expected. With the EmojiTextView the items will always remove from right to left.

EDIT 2

I was able to narrow down my problem to the AndroidView-integration:

@Preview
@Composable
fun StackOverflowPreview() {
    val tags = remember { mutableStateListOf("1", "2", "3") }

    Row {
        tags.forEach { tag ->
            // does NOT work
            // AndroidView(factory = { TextView(it).apply { text = tag } })

            // works
            AndroidView(factory = { TextView(it) }, update = { v -> v.text = tag })
        }
    }

    LaunchedEffect(key1 = tags) {
        delay(2000)
        tags.remove("1")

        delay(2000)
        tags.remove("2")

        delay(2000)
        tags.remove("3")
    }
}

Upvotes: 3

Views: 3994

Answers (2)

chrgue
chrgue

Reputation: 639

To finish up this thread here is an working example.

@Preview
@Composable
fun StackOverflowPreview() {
    val tags = remember { mutableStateListOf("1", "2", "3") }

    Row {
        tags.forEach { tag ->
            AndroidView(factory = { TextView(it) }, update = { v -> v.text = tag })
        }
    }

    LaunchedEffect(key1 = tags) {
        delay(2000)
        tags.remove("1")

        delay(2000)
        tags.remove("2")

        delay(2000)
        tags.remove("3")
    }
}

As @Philip mentioned in the comments the AndroidView will be just instantiated once and needs a way to update its internal state on recomposition.

Upvotes: 0

Richard Onslow Roper
Richard Onslow Roper

Reputation: 6863

In the MainScreen Composable, you are wrapping the val assignment with remember. It causes the list to not be updated on recompositions, so remove that remember over there. Then, you have also used a not-so-good approach in the initialization of the selectedTags variable in the viewmodel. You can directly use delegation inside that, as you have used in your main activity (or composable, as it seems). In your viewmodel, you can initialise the variable as val selectedTags by mutableStateOf(listOf<String>()), and then there's no need for using the .value prefix everywhere. Rest assured that recompositions will be triggered still. You just need to initialise with mutableStateOf().

Also, a better approach of declaring mutable lists, as of Compose 1.0.1, is to use the pre-defined mutableStateListOf(...) with which you so not require to use delegation (the 'by' keyword), and can use it just like a regular list object, without any .value calls.

Just implement these and you should be fine

Upvotes: 0

Related Questions