0

Passing a LazyListState as a variable into a Lazy Column is creating a weird bug I had never encountered.

val listState = rememberLazyListState()
LaunchedEffect(key1 = state.messages.size) {
    if (state.messages.isNotEmpty() && listState.firstVisibleItemIndex < 5) listState.animateScrollToItem(0)
}

Here, the Log Statement (printed out on every recomposition) gives the latest state of the first "msg" after every change that is made to that message.

But when the callback "onReply()" is invoked, the argument "msg" still has the old state.

How on earth could that happen when UI got updated but callback is fired with an old state?

LazyColumn(
    modifier = Modifier.fillMaxSize(),
    reverseLayout = true,
    state = listState
) {
    itemsIndexed(
        items = state.messages,
        key = { _, m -> m.msgId }
    ) { i, msg ->
          if (i == 0) {
              Log.i("ChatScreen", "Message with ID: ${msg.msgId} is ${msg.state.name}")
          }
          MessageItem(
              message = msg,
              onReply = {
                  viewModel.reply(msg)
              }
          )
     }
}

When variable listState was removed from LazyColumn or rememberLazyListState() is passed into, the bug goes away.

How can I solve the issue?

  • how `onReply` is called? if you're using it from `pointerInput`, you may need to wrap it with `rememberUpdatedState`, e.g. check out `Modifier.clickable` source code and how `onClick` is being used – Phil Dukhov Feb 22 '23 at 00:01
  • @PhilDukhov Exactly. I modified the code for pointerInput to update as state mutates and it works. But still, I have no idea, what it has to do with passing lazyListState as a variable into the column. – Affection77 Feb 22 '23 at 09:06

1 Answers1

0

The following code triggers recomposition each time your state.messages.size changes:

val listState = rememberLazyListState()
LaunchedEffect(key1 = state.messages.size) {
    if (state.messages.isNotEmpty() && listState.firstVisibleItemIndex < 5) listState.animateScrollToItem(0)
}

And because you're calling onReply from pointerInput directly, pointerInput captures the first value of onReply.

I guess that when you remove state, you also remove the LaunchedEffect, so there's no recomposition triggered, and you're not experiencing problems because of pointerInput.

So first of all, it's a good practice to wrap callbacks with rememberUpdatedState before using them in pointerInput - check out how Modifier.clickable source code and how onClick is being used there.

Second one, try to reduce number of recompositions as much as possible - especially with lazy views.

In this case you actually don't need to restart LaunchedEffect each time state.messages.size changes. Instead you can use snapshotFlow like this:

LaunchedEffect(Unit) {
    snapshotFlow { state.messages.size }
        .collectLatest { messagesSize ->
            if (messagesSize > 0 && listState.firstVisibleItemIndex < 5) {
                listState.animateScrollToItem(0)
            }
        }
}

It would work if state is of Compose State type, otherwise you need to also wrap it with rememberUpdatedState, e.g.

val updatedState by rememberUpdatedState(state)
LaunchedEffect(Unit) {
    snapshotFlow { updatedState.messages.size }
        ...
Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220