Code7G
Code7G

Reputation: 67

Why are view model operations not working in a .clickable modifier in the current stable version of Android Jetpack Compose?

I have a mutableTodosList that I want to edit from my composables:

class ViewModelll : ViewModel() {
    var mutableTodosList = mutableStateOf(emptyList<ToDoItemData>())
    var todosList: List<ToDoItemData> = mutableTodosList.value

    init { // One-time initialization
        mutableTodosList.value = listOf(
            ToDoItemData(
                1, "Example 1",
            ),
            ToDoItemData(2, "Create a todo list"),
            // ... more items
        )
    }

    fun addTodoItems(todoItems: List<ToDoItemData>) {
        val newList = mutableTodosList.value.toMutableList().apply {
            for (todoItem in todoItems) {
                add(todoItem)
            }
        }
        mutableTodosList.value = newList
    }

    fun modifyTodoItem(todoItemId: Int, todoItemParameterIndex: Int, modification: Any) {
        val todoItem = findTodoItemById(todoItemId)
        val newList = mutableTodosList.value.toMutableList().apply {
            when (todoItemParameterIndex) {
                0 -> this[indexOf(todoItem)].mainText = modification as String
                12 -> this[indexOf(todoItem)].isChecked = modification as Boolean

                else -> throw IllegalArgumentException("Invalid parameter index")
            }
        }
        mutableTodosList.value = newList
    }

    fun removeTodoItem(todoItemId: Int) {
        val newList = mutableTodosList.value.toMutableList().apply {
            remove(findTodoItemById(todoItemId))
        }
        mutableTodosList.value = newList
    }
}

This is my GoalsScreen:

@Composable
fun GoalsScreen(navController: NavController) {
    val viewModel: ViewModelll = viewModel()

    Box(
        modifier = Modifier.fillMaxSize(),
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize(),
            verticalArrangement = Arrangement.Top,
        ) {
            ToDoList(
                modifier = Modifier
                    .width(500.dp)
                    .height(200.dp),
                todos = viewModel.mutableTodosList,
            )
            Text(
                text = "Click this to check",
                modifier = Modifier.clickable {
                    viewModel.modifyTodoItem(
                        todoItemId = 2,
                        todoItemParameterIndex = 12,
                        modification = !viewModel.mutableTodosList.value[1].isChecked,
                    )
                },
            )
            Text("isChecked1: ${viewModel.mutableTodosList.value[0].isChecked}")
            Text("isChecked2: ${viewModel.mutableTodosList.value[1].isChecked}")
        }
    }
}

@Composable
fun ToDoList(
    modifier: Modifier,
    todos: MutableState<List<ToDoItemData>> = remember { mutableStateOf(emptyList()) },
) {
    Box(modifier = modifier) {
        if (backgroundImage != null) {
            Image(
                modifier = Modifier.fillMaxSize(),
                painter = backgroundImage,
                contentDescription = null,
                contentScale = ContentScale.Crop,
            )
        }

        LazyColumn(
            modifier = Modifier.fillMaxSize()
        ) {
            items(
                todos.value
            ) { todo ->
                ToDoItem(
                    todo = todo,
                )
            }
        }
    }
}

@Composable
fun GlassToDoItem(
    todo: ToDoItemData,
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(vertical = 4.dp, horizontal = 4.dp)
            .clip(RoundedCornerShape(16.dp))
            .clickable { },
        verticalArrangement = Arrangement.Top,
    ) {
        Row(
            modifier = Modifier
                .fillMaxSize(),
            horizontalArrangement = Arrangement.Start,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CircularCheckbox(
                todo = todo,
                modifier = Modifier
                    .size(45.dp)
                    .padding(10.dp),
            )
            Spacer(modifier = Modifier.width(5.dp))
            Text(
                text = todo.mainText,
                fontSize = 17.sp,
            )
        }
    }
}

@Composable
fun ToDoItem(
    todo: ToDoItemData,
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(vertical = 4.dp, horizontal = 4.dp)
            .clip(RoundedCornerShape(16.dp))
            .background(Color.White)
            .clickable { },
        verticalArrangement = Arrangement.Top,
    ) {
        Row(
            modifier = Modifier
                .fillMaxSize(),
            horizontalArrangement = Arrangement.Start,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CircularCheckbox(
                todo = todo,
                modifier = Modifier
                    .size(45.dp)
                    .padding(10.dp),
            )
            Spacer(modifier = Modifier.width(5.dp))
            Text(
                text = todo.mainText,
                fontSize = 17.sp,
            )
        }
    }
}

@Composable
fun CircularCheckbox(
    todo: ToDoItemData,
    modifier: Modifier,
    animationDurationMiliseconds: Int = 700,
    unCheckedCircleColor: Color = Color.Blue,
    unCheckedLineSize: Dp = 2.dp,
    checkedCircleColor: Color = Color.Blue,
    checkedLineSize: Dp = 2.dp,
    checkmarkSize: Dp = 20.dp,
    checkmarkColor: Color = Color.Blue,
) {
    val viewModel: ViewModelll = viewModel()

    Box(modifier = modifier)
    {
        if (todo.isChecked) {
            CircularCheckedCheckbox(
                modifier = Modifier
                    .fillMaxSize()
                    .clickable(
                        onClick = {
                            viewModel.modifyTodoItem(
                                todoItemId = todo.id,
                                todoItemParameterIndex = 12,
                                modification = !todo.isChecked,
                            )
                        }
                    ),
                circleColor = checkedCircleColor,
                lineSize = checkedLineSize,
                checkmarkSize = checkmarkSize,
                checkmarkColor = checkmarkColor,
            )
        } else {
            CircularUncheckedCheckbox(
                modifier = Modifier
                    .fillMaxSize()
                    .clickable(
                        onClick = {
                            viewModel.modifyTodoItem(
                                todoItemId = todo.id,
                                todoItemParameterIndex = 12,
                                modification = !todo.isChecked,
                            )
                        }
                    ),
                circleColor = unCheckedCircleColor,
                lineSize = unCheckedLineSize,
            )
        }
    }
}

@Composable
fun CircularCheckedCheckbox(
    modifier: Modifier,
    circleColor: Color = Color.Blue,
    lineSize: Dp = 2.dp,
    checkmarkSize: Dp = 20.dp,
    checkmarkColor: Color = Color.Blue,
) {
    Canvas(modifier = modifier) {
        val cSize = checkmarkSize.toPx()
        val checkmark = Path().apply {
            moveTo(cSize / 3f, cSize / 2f)
            lineTo(cSize / 2f, cSize * 3f / 4f)
            lineTo(cSize * 2f / 3f, cSize / 4f)
        }

        drawPath(
            path = checkmark,
            color = checkmarkColor,
        )

        drawArc(
            color = circleColor,
            startAngle = 0f,
            sweepAngle = 360f,
            useCenter = true,
            style = Stroke(width = lineSize.toPx()),
        )
    }
}

@Composable
fun CircularUncheckedCheckbox(
    modifier: Modifier,
    circleColor: Color = Color.Blue,
    lineSize: Dp = 2.dp,
) {
    Canvas(modifier = modifier) {
        drawArc(
            color = circleColor,
            startAngle = 0f,
            sweepAngle = 360f,
            useCenter = true,
            style = Stroke(width = lineSize.toPx()),
        )
    }
}

data class ToDoItemData(
    val id: Int,
    var mainText: String,

    var isChecked: Boolean = false,
)

The app does not update the isChecked value of the ToDoItemData in the viewModel's mutableTodosList to its opposite. Therefore this also doesn't update (re-compose) the UI to make the CircularCheckbox change to a Checked or UnChecked version of itself, or so I think, from what I have observed through:

Debugging: When the CircularCheckedCheckbox or CircularUnCheckedCheckbox are clicked: The value of the isChecked value does not change for the ToDoItemData in the viewModel's mutableTodosList, however the isChecked value of todo of the CircularCheckbox composable function does change, though it obviously doesn't update the UI as the todo is not a mutableStateOf, unlike the mutableTodosList from the viewModel.

Debugging trough code: I added some additional Text elements to show the state of the isChecked value in the app:

Text(
    "Click this to check",
    modifier = Modifier.clickable {
        viewModel.modifyTodoItem(
            todoItemId = 2,
            todoItemParameterIndex = 12,
            modification = !viewModel.mutableTodosList.value[1].isChecked,
        )
    }
)
Text("isChecked1: ${viewModel.mutableTodosList.value[0].isChecked}")
Text("isChecked2: ${viewModel.mutableTodosList.value[1].isChecked}")

With this, the same result is shown as in the debug.

While searching for a solution to this problem I couldn't find any answers on the web, and all AI LLMs also don't know the answer.

Upvotes: 1

Views: 105

Answers (1)

Jan B&#237;na
Jan B&#237;na

Reputation: 7278

This is a typical problem of mutable data structures in compose. When you reassign the same value to MutableState, recomposition won't be scheduled. From the docs:

If value is written to with the same value, no recompositions will be scheduled.

By default, StructuralEqualityPolicy is used to compare the old and new value. You can change that when calling mutableStateOf() (but don't do that if you don't have a good reason to).

When you do this:

val newList = mutableTodosList.value.toMutableList().apply {
  this[index].mainText = modification
}

you create a new instance of the list with the same objects inside. When you add/remove an item from this list, the original list is not affected. But when you modify an item in this list, the item in the original list is also modified (it's the same item). This means that the original and new list are still structurally equal and thus recomposition is not scheduled.

You should instead create a copy of ToDoItemData with your modifications. The best way to do this is by using a data class. It could look like this:

data class ToDoItemData(val mainText: String)

val list = mutableStateOf(listOf(ToDoItemData("original")))
val newList = list.value.toMutableList().apply {
    this[0] = this[0].copy(mainText = "modified")
}
list.value = newList

With this, the original list keeps the original instance of ToDoItemData and the new list has a new instance of ToDoItemData with changed mainText, so the lists are not structurally equal.

Upvotes: 1

Related Questions