MiracetteNytten
MiracetteNytten

Reputation: 31

LazyList recomposes items every time there's a change in a list

I have a list of notifications in a ViewModel.

class Notification(
    val id: String,
    val title: String,
    val body: String,
    val time: String,
    isRead: Boolean = false,
) {
    var isRead by mutableStateOf(isRead)
}


class NotificationViewModel : ViewModel() {
    private val _notificationList = mutableStateListOf(
        Notification(
            "ID1",
            "Nice title",
            "Nice description",
            "18.03.2022 11:30"
        ),
        Notification(
            "ID2",
            "Nice title",
            "Nice description",
            "18.03.2022 11:30"
        ),
        Notification(
            "ID3",
            "Nice title",
            "Nice description",
            "18.03.2022 11:30"
        ),
    )

    val notificationList: List<Notification>
        get() = _notificationList


    fun deleteNotification(id: String) {
        val list = _notificationList
        val notification = list.find { id == it.id }

        if (notification != null) {
            list.remove(notification)
        }
    }

    fun setRead(id: String, isRead: Boolean = true) {
        val list = _notificationList
        val notification = list.find { id == it.id }

        if (notification != null) {
            notification.isRead = isRead
        }
    }

    fun setAllRead(isRead: Boolean = true) {
        val list = _notificationList
        if (list.isEmpty()) return
        if (list.none { it.isRead != isRead }) return

        list.forEachIndexed { _, n ->
            n.isRead = isRead
        }
    }
}

And I use it in a notification screen where I can mark notifications as read and delete them.

@Composable
fun NotificationScreen(viewModel: NotificationViewModel) {
    val list = viewModel.notificationList

    Surface(modifier = Modifier.fillMaxSize()) {
        LazyColumn {
            item(key = "MarkAsRead") {
                val setAllReadEnabled by remember(list) { derivedStateOf { list.find { !it.isRead } != null } }
                Button(
                    enabled = setAllReadEnabled,
                    onClick = {
                        viewModel.setAllRead()
                    },
                    shape = MaterialTheme.shapes.extraLarge
                ) {
                    Text(
                        text = "Mark everything as read",
                    )
                }
            }
            items(
                items = list,
                key = { it.id }
            ) { item ->
                NotificationCard(
                    title = item.title,
                    body = item.body,
                    time = item.time,
                    isRead = item.isRead,
                    onDismiss = {
                        viewModel.deleteNotification(item.id)
                    },
                    onIconClick = {
                        viewModel.setRead(item.id, true)
                    }
                )
            }
        }
    }
}

The problem is that when I delete an element from the list all the items in LazyColumn get recomposed, even where the button is located. Reports tell me that Notification, NotificationViewModel and NotificationCardColors are stable. NotificationScreen and NotificationCard are skippable. I have no idea why it's happening or what I'm doing wrong.

I tried absolutely everything that I could find, but nothing worked. Also, I have almost same issue with my calendar component, where every day in the calendar grid gets recomposed when a day was selected.

Upd. Here's NotificationCard:

@Composable
fun NotificationCard(
    modifier: Modifier = Modifier,
    title: String,
    body: String,
    time: String,
    isRead: Boolean = false,
    colors: NotificationCardColors = DefaultNotificationCardColors.notificationCardColors(),
    onDismiss: () -> Unit,
    onIconClick: () -> Unit,
) {
    val containerColor by colors.containerColor(isRead)
    val textColor by colors.textColor(isRead)
    val timeColor by colors.timeColor(isRead)

    val swipeableState = rememberSwipeableState(0)
    val width = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp.dp.toPx() }
    val anchors = mapOf(-width / 2 to -1, 0f to 0, width / 2 to 1)
    val currentValue = swipeableState.currentValue

    LaunchedEffect(key1 = currentValue) {
        when (currentValue) {
            -1, 1 -> onDismiss()
        }
    }

    Surface(
        modifier = modifier
            .swipeable(
                state = swipeableState,
                anchors = anchors,
                thresholds = { _, _ -> FractionalThreshold(0.8f) },
                orientation = Orientation.Horizontal,
            )
            .offset {
                IntOffset(
                    x = swipeableState.offset.value.dp
                        .toPx()
                        .toInt(), y = 0
                )
            },
        color = containerColor,
        contentColor = textColor,
        shape = MaterialTheme.shapes.large,
        shadowElevation = 5.dp,
    ) {
        Column(
            modifier = Modifier
                .padding(12.dp),
            verticalArrangement = spacedBy(12.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = SpaceBetween,
                verticalAlignment = CenterVertically
            ) {
                Text(
                    text = title,
                    style = MaterialTheme.typography.titleMedium
                )
                Text(
                    text = time,
                    style = MaterialTheme.typography.titleSmall,
                    color = timeColor,
                )
            }
            Row {
                Text(
                    modifier = Modifier.weight(.9f),
                    text = body,
                    style = MaterialTheme.typography.labelMedium,
                    softWrap = true,
                )
                Box(
                    modifier = Modifier.weight(.1f),
                    contentAlignment = Center,
                ) {
                    androidx.compose.animation.AnimatedVisibility(
                        visible = !isRead,
                        enter = Transitions.expandFadeIn(clip = false),
                        exit = Transitions.shrinkFadeOut(clip = false)
                    ) {
                        IconButton(modifier = Modifier.size(24.dp), onClick = onIconClick) {
                            Icon(id = R.drawable.ic_check, tint = timeColor)
                        }
                    }
                }
            }
        }
    }
}

Upvotes: 3

Views: 312

Answers (1)

Ben Trengrove
Ben Trengrove

Reputation: 8729

I can't spot any obvious error in your code, but how are you determining that every row is being recomposed? Are you sure it's not just the internal lazylist composables that are recomposing and that your notification card is being skipped correctly?

In the Layout inspector, you are looking for NotificationCard (in my screenshot I had a row called InterestsItem) and seeing if that is skipped. The composables above it are internal to lazy list and recomposition there is expected when the list changes.

Layout inspector showing recomposition

Upvotes: 2

Related Questions