androidandroid-jetpack-composeandroid-jetpack-compose-gesture

Android Compose why does Checkbox need onCheckChanged when parent is clickable?


I am trying to have a checkbox with some trailing text. The idea is that whether you select the checkbox itself, or the text, the value is toggled. For brevity in code I've done everything in the composable.

It works as expected when selecting the text. But when I specifically press the checkbox, nothing happens. I see the ripple effect from pressing the checkbox but it is obviously ignoring the row's clickable function and hitting onCheckChange = {}

I'm not entirely fluent in composable scopes so I thought it might be due to Row() being an inline function so I tried applying the same modifier to the Surface but that doesn't work either.

It isn't a big deal having the same call from the 2 spots, I would just like to know why.

@Composable
fun CheckboxInRowWithText() {
    var checkedState by remember { mutableStateOf(false) }
    Surface {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    checkedState = !checkedState
                },
            verticalAlignment = Alignment.CenterVertically) {
            Checkbox(checked = checkedState, onCheckedChange = {} )

            Text(text = "Clicking this Row will toggle checkbox")
        }
    }
}

Solution

  • The reason you can't click an ancestor Composable is not because it's under another, touch propagation goes from descendant to ancestor by default because of PointerEventPass which is Main.

    The reason Modifier.clickable doesn't allow parent to get event is, it calls PointerEventChange.consume but you can write your own click functions to propagate from child to parent or parent to child or consume conditionally.

    You can check out this answer for more details

    You are basically doing nothing on checkBoxChange that's why it doesn't change, set checkedState inside onCheckedChange.

    @Composable
    fun CheckboxInRowWithText() {
    
        val context = LocalContext.current
    
        var checkedState by remember { mutableStateOf(false) }
        Surface {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable {
                        Toast
                            .makeText(context, "Row clicked", Toast.LENGTH_SHORT)
                            .show()
                        checkedState = !checkedState
                    },
                verticalAlignment = Alignment.CenterVertically) {
                Checkbox(checked = checkedState, onCheckedChange = {
                    Toast.makeText(context, "Checkbox check", Toast.LENGTH_SHORT).show()
                    checkedState = it
                }
                )
    
                Text(text = "Clicking this Row will toggle checkbox")
            }
        }
    }
    

    Also you can build it like this which will show ripple even when you click CheckBox.

    @Composable
     fun CheckBoxWithTextRippleFullRow(
        label: String,
        state: Boolean,
        onStateChange: (Boolean) -> Unit
    ) {
    
        // Checkbox with text on right side
        Row(modifier = Modifier
            .fillMaxWidth()
            .height(40.dp)
            .clickable(
                role = Role.Checkbox,
                onClick = {
                    onStateChange(!state)
                }
            )
            .padding(8.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Checkbox(
                checked = state,
                onCheckedChange = null
            )
            Spacer(modifier = Modifier.width(8.dp))
            Text(text = label)
        }
    }
    

    Result

    enter image description here

    Click propagation sample

    You can click below a Composable even with a Button which is a Surface with Modifier.clickable under the hood.

    Let's create our own custom touch only for the parent below Button

    private fun Modifier.customTouch(
        pass: PointerEventPass,
        onDown: () -> Unit,
        onUp: () -> Unit
    ) = this.then(
        Modifier.pointerInput(pass) {
            awaitEachGesture {
                awaitFirstDown(pass = pass)
                onDown()
                waitForUpOrCancellation(pass)
                onUp()
            }
        }
    )
    

    And assign it to parent with PointerEventPass.Initial with no consume call and will result as

    enter image description here

    @Preview
    @Composable
    private fun GesturePropagationTest() {
    
        val context = LocalContext.current
    
        Box(
            modifier = Modifier
                .customTouch(
                    pass = PointerEventPass.Initial,
                    onDown = {
                        Toast
                            .makeText(context, "Parent down", Toast.LENGTH_SHORT)
                            .show()
                    }, onUp = {
                        Toast
                            .makeText(context, "Parent up", Toast.LENGTH_SHORT)
                            .show()
                    }
                )
                .padding(bottom = 50.dp)
                .fillMaxSize()
                .padding(20.dp),
            contentAlignment = Alignment.BottomCenter
        ) {
            Button(
                onClick = {
                Toast.makeText(context, "Button is clicked", Toast.LENGTH_SHORT).show()
            }) {
                Text("Click Button")
            }
        }
    }