Search code examples
android-jetpack-compose

When does recomposition occur?


I’m using an IconToggleButton to create a single-choice list, and the variable that tracks whether an item is selected is var choiceChecked: Pair<Int, MutableState>? = null. This variable stores the index of the selected item and its selected state.

When I click a button outside the list and print choiceChecked.first, it prints the correct value. It seems that even though choiceChecked doesn't use remember and MutableState, it can still retain its value after recomposition.

However, when I turn off the screen and turn it back on, the value of choiceChecked is lost. I speculate that the reason is that after selecting an item, choiceChecked recorded the value during that recomposition. Later, when I click the button to print choiceChecked.first, no recomposition occurs because the state hasn't changed, so it prints the correct value.

But when I turn off the screen and turn it back on, recomposition happens, and choiceChecked is lost. Is this correct?

Below is my code,Please forgive me for posting such long code, as I am unsure if other variables might affect recomposition. The code block that prints choiceChecked is commented with 'print the choiceChecked'. The code for selecting the item is inside the clickable. Please search for clickable.

    @Composable
    fun AlbumGrouping(
        showPopup: MutableState<Boolean>,
        isPopupVisible: MutableState<Boolean>,
        size: DpSize,
        application: MediaApplication,
        directoryIcon: Bitmap,
    ) {
        val width = remember { (size.width.value * 0.9f).dp }
        val height = remember { (size.height.value * 0.6f).dp }
        val density = LocalDensity.current
    
        //加载数据
        val scope = rememberCoroutineScope()
        val items: SnapshotStateList<Album> = remember { mutableStateListOf() }
        var queryJob by remember { mutableStateOf<Job?>(null) }
        var addJob by remember { mutableStateOf<Job?>(null) }
        val gridState = rememberLazyGridState()
    
        //控制显示
        val transition = updateTransition(targetState = showPopup.value, label = "popup")
        val alpha by transition.animateFloat(
            label = "popupOffset",
            transitionSpec = { tween(durationMillis = 500) }
        ) { state -> if (state) 1f else 0f }
    
        // 监听 showPopup 的变化,管理 Popup 显示状态
        LaunchedEffect(showPopup.value) {
            queryJob?.cancelAndJoin()
            if (showPopup.value) {
                queryJob = scope.launch(Dispatchers.IO) {
                    val result = application.mediaDatabase.albumDao.queryByParentId(-1)
                    if (result != null) items.addAll(result)
                }
                isPopupVisible.value = true
            } else {
                delay(500)
                isPopupVisible.value = false
                queryJob?.cancel()
            }
        }
    
        var choiceChecked: Pair<Int, MutableState<Boolean>>? = null
        if (isPopupVisible.value) {
            Popup(
                alignment = Alignment.TopCenter,
                onDismissRequest = {
                    showPopup.value = false
                },
                properties = PopupProperties(focusable = true)
            ) {
                Column(
                    modifier = Modifier
                        .size(width, height)
                        .graphicsLayer(alpha = alpha)
                        .background(Color(0xE6FFFFFF), shape = RoundedCornerShape(16.dp))
                ) {
                    LazyVerticalGrid(
                        state = gridState,
                        columns = GridCells.Fixed(3),
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxWidth()
                            .padding(16.dp)
                    ) {
                        items(items.size) { index ->
                            items[index].let { item ->
                                val checked = remember { mutableStateOf(false) }
                                Box(
                                    contentAlignment = Alignment.TopEnd,
                                    modifier = Modifier
                                        .padding(end = TinyPadding, top = TinyPadding)
                                        .clickable {
                                            if (checked.value) {
                                                checked.value = false
                                            } else {
                                                choiceChecked?.second?.value = false
                                                choiceChecked = index to checked
                                                checked.value = true
                                            }
                                        }
                                ) {
                                    Column {
                                        DisplayImage(
                                            bitmap = directoryIcon,
                                            context = application.baseContext,
                                            modifier = Modifier.aspectRatio(1f)
                                        )
                                        Text(
                                            text = item.name,
                                            style = MaterialTheme.typography.labelMedium,
                                            modifier = Modifier.padding(start = SmallPadding)
                                        )
                                    }
    
                                    IconToggleButton(
                                        checked = checked.value,
                                        colors = IconButtonDefaults.iconToggleButtonColors(
                                            containerColor = Color(0x80808080), // 选中时图标的颜色
                                            contentColor = Color.White,              // 未选中时图标的颜色
                                            checkedContainerColor = Color.White,    // 选中时背景颜色
                                            checkedContentColor = Color.DarkGray   // 未选中时背景颜色
                                        ),
                                        onCheckedChange = {
                                            if (checked.value) {
                                                checked.value = false
                                            } else {
                                                choiceChecked?.second?.value = false
                                                choiceChecked = index to checked
                                                checked.value = true
                                            }
                                        },
                                        modifier = Modifier
                                            .width(24.dp)
                                            .height(24.dp)
                                            .offset(x = (-10).dp, y = 10.dp)
                                    ) {
                                        if (checked.value) {
                                            Icon(
                                                Icons.Filled.CheckCircle,
                                                contentDescription = "选中",
                                                modifier = Modifier
                                                    .fillMaxSize()
                                                    .padding(0.dp)
                                            )
                                        } else {
                                            Icon(
                                                Icons.Filled.RadioButtonUnchecked,
                                                contentDescription = "未选中",
                                                modifier = Modifier
                                                    .fillMaxSize()
                                                    .padding(0.dp)
                                            )
                                        }
                                    }
                                }
                            }
                        }
                    }
                    val driver = remember { mutableStateOf(0.dp) }
                    Row(
                        horizontalArrangement = Arrangement.SpaceAround,
                        verticalAlignment = Alignment.CenterVertically,
                        modifier = Modifier
                            .wrapContentHeight()
                            .fillMaxWidth()
                            .onGloballyPositioned { layout ->
                                with(density) {
                                    driver.value = layout.size.height.toDp()
                                }
                            }
                            .background(Color(0xE6D3D3D3), shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp))
                    ) {
                        TextButton(onClick = { //print the choiceChecked
                            println("index${choiceChecked?.first}")
                        }) {
                            Text(stringResource(R.string.popup_select_grouping))
                        }
                        Text(
                            "", modifier = Modifier
                                .width(1.dp)
                                .height(driver.value)
                                .background(Color(0xE6FFFFFF))
                        )
                        TextButton(onClick = {
                            scope.launch(Dispatchers.IO) {
                                addJob?.join()
                                addJob = scope.launch {
                                    val groupingNum = items.size
                                    val album = Album(name = "分组${groupingNum + 1}")
                                    application.mediaDatabase.albumDao.insert(album)
                                    items.add(album)
                                }
                            }
                        }) {
                            Text(stringResource(R.string.popup_add_grouping))
                        }
                    }
                }
            }
        }
    }

Solution

  • I made a smaller sample to support my answer:

    @Composable
    fun RecompositionDemo() {
        val context = LocalContext.current
        var choiceChecked = 1
        Column {
            OutlinedButton(
                onClick = {
                    choiceChecked++
                }
            ) {
                Text("INCREASE")
            }
            OutlinedButton(
                onClick = {
                    Toast.makeText(context, "choiceChecked = $choiceChecked", Toast.LENGTH_SHORT).show()
                }
            ) {
                Text("PRINT")
            }
            Text("choiceChecked = $choiceChecked")
        }
    }
    

    While execution, pay close attention to a) the choiceChecked value shown by the Toast and b) the choiceChecked value shown by the Text Composable:

    Screen Recording

    What we can observe is

    • the Toast initially displays choiceChecked = 1
    • then we click the INCREASE OutlinedButton
    • the Text does not update to show the new value
    • the Toast however will correctly show choiceChecked = 2
    • after locking and unlocking the device, a recomposition happens which resets choiceChecked = 1

    But when I turn off the screen and turn it back on, recomposition happens, and choiceChecked is lost. Is this correct?

    This is correct.

    When I click a button outside the list and print choiceChecked.first, it prints the correct value. It seems that even though choiceChecked doesn't use remember and MutableState, it can still retain its value after recomposition.

    That is not correct. The reason while the Toast prints the correct choiceChecked values is because there is no recomposition triggered in the first place when doing choiceChecked++, and thus the value is not reset. At the same time, as no recomposition is happening, the Text Composable is not updating.

    Jetpack Compose can only detect changes on MutableState variables, that's why we use mutableStateOf to make sure the UI gets recomposed when a variable value changes. Additionally, we use remember so that any new value set to a variable survives the recomposition.