Why do I sometimes need key() in lists?

Viewed 209

I have a component with some mutable state list. I pass an item of that, and a callback to delete the item, to another component.

@Composable
fun MyApp() {
  val myItems = mutableStateListOf("1", "2", "3")
  LazyColumn {
    items(myItems) { item ->
      MyComponent(item) { toDel -> myItems.remove(toDel) }
    }
  }
}

The component calls the delete callback in a clickable Modifier.

@Composable
fun MyComponent(item: String, delete: (String) -> Unit = {}) {
  Column {
    Box(
      Modifier
        .size(200.dp)
        .background(MaterialTheme.colors.primary)
        .clickable { delete(item) }
    ) {
      Text(item, fontSize = 40.sp)
    }
  }
}

This works fine. But when I change the clickable for my own Modifier with pointerInput() then there's a problem.

fun Modifier.myClickable(delete: () -> Unit) =
  pointerInput(Unit) {
    awaitPointerEventScope { awaitFirstDown() }
    delete()
  }

@Composable
fun MyComponent(item: String, delete: (String) -> Unit = {}) {
  Column {
    Box(
      Modifier
        .size(200.dp)
        .background(MaterialTheme.colors.primary)
        .myClickable { delete(item) } // NEW
    ) {
      Text(item, fontSize = 40.sp)
    }
  }
}

If I click on the first item, it removes it. Next, if I click on the newest top item, the old callback for the now deleted first item is called, despite the fact that the old component has been deleted.

I have no idea why this happens. But I can fix it. I use key():

@Composable
fun MyApp() {
  val myItems = mutableStateListOf("1", "2", "3")
  LazyColumn {
    items(myItems) { item ->
      key(item) { // NEW
        MyComponent(item) { toDel -> myItems.remove(toDel) }
      }
    }
  }
}

So why do I need key() when I use my own modifier? This is also the case in this code from jetpack, and I don't know why.


As the accepted answer says, Compose won't recalculate my custom Modifier because pointerEvent() doesn't have a unique key.

fun Modifier.myClickable(key:Any? = null, delete: () -> Unit) =
  pointerInput(key) {
    awaitPointerEventScope { awaitFirstDown() }
    delete()
  }

and

    Box(
      Modifier
        .size(200.dp)
        .background(MaterialTheme.colors.primary)
        .myClickable(key = item) { delete(item) } // NEW
    ) {
      Text(item, fontSize = 40.sp)
    }

fixes it and I don't need to use key() in the outer component. I'm still unsure why I don't need to send a unique key to clickable {}, however.

2 Answers

Compose is trying to cache as many work as it can by localizing scopes with keys: when they haven't changes since last run - we're using cached value, otherwise we need to recalculate it.

By setting key for lazy item you're defining a scope for all remember calculations inside, and many of system functions are implemented using remember so it changes much. Item index is the default key in lazy item

So after you're removing first item, first lazy item gets reused with same context as before

And now we're coming to your myClickable. You're passing Unit as a key into pointerInput(It has a remember inside too). By doing this you're saying to recomposer: never recalculate this value until context changes. And the context of first lazy item hasn't changed, e.g. key is still same index, that's why lambda with removed item remains cached inside that function

When you're specifying lazy item key equal to item, you're changing context of all lazy items too and so pointerInput gets recalculated. If you pass your item instead of Unit you'll have the same effect

So you need to use key when you need to make use your calculations are not gonna be cached between lazy items in a bad way

Check out more about lazy column keys in the documentation

Jetpack compose optimizes the re-compose by only recomposing Widget which value has been changed.

In your Custom implementation of Modifier.myClickable when item list is changing due to deletion, only the inner Text(item, fontSize = 40.sp) will be recomposed since item has changed and it is the only one which is reading item. The outer Box() is not recomposed, hence it is holding the previous callback. But When you add key(item), the outer box will also be re-composed as the key value has changed. Hence it is working after adding the key.

So why is was working with Modifier.clickable { delete(item) }?

I think Compose kept track of change in the callback clickable { delete(item) }. So when the callback changed due to item deletion, it recomposed MyComponent, Hence is was working with clickable

Related