Search code examples
androidandroid-jetpack-composeandroid-jetpack-compose-list

Why do I sometimes need key() in lists?


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.


Solution

  • 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