How can I detect a click with the view behind a Jetpack Compose Button?
Asked Answered
C

1

5

The below code is for Jetbrains Desktop Compose. It shows a card with a button on it, right now if you click the card "clicked card" will be echoed to console. If you click the button it will echo "Clicked button"

However, I'm looking for a way for the card to detect the click on the button. I'd like to do this without changing the button so the button doesn't need to know about the card it's on. I wish to do this so the card knows something on it's surface is handled and for example show a differently colored border..

The desired result is that when you click on the button the log will echo both the "Card clicked" and "Button clicked" lines. I understand why mouseClickable isn't called, button declares the click handled. So I'm expecting that I'd need to use another mouse method than mouseClickable. But I can't for the life of me figure out what I should be using.

@OptIn(ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalDesktopApi::class)
@Composable
fun example() {
    Card(
        modifier = Modifier
            .width(150.dp).height(64.dp)
            .mouseClickable { println("Clicked card") }
    ) {
        Column {
            Button({ println("Clicked button")}) { Text("Click me") }
        }
    }
}
Chevrette answered 22/8, 2021 at 0:39 Comment(0)
N
15

When button finds tap event, it marks it as consumed, which prevents other views from receiving it. This is done with consumeDownChange(), you can see detectTapAndPress method where this is done with Button here

To override the default behaviour, you had to reimplement some of gesture tracking. List of changes comparing to system detectTapAndPress:

  1. I use awaitFirstDown(requireUnconsumed = false) instead of default requireUnconsumed = true to make sure we get even a consumed even
  2. I use my own waitForUpOrCancellationInitial instead of waitForUpOrCancellation: here I use awaitPointerEvent(PointerEventPass.Initial) instead of awaitPointerEvent(PointerEventPass.Main), in order to get the event even if an other view will get it.
  3. Remove up.consumeDownChange() to allow the button to process the touch.

Final code:

suspend fun PointerInputScope.detectTapAndPressUnconsumed(
    onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
    onTap: ((Offset) -> Unit)? = null
) {
    val pressScope = PressGestureScopeImpl(this)
    forEachGesture {
        coroutineScope {
            pressScope.reset()
            awaitPointerEventScope {

                val down = awaitFirstDown(requireUnconsumed = false).also { it.consumeDownChange() }

                if (onPress !== NoPressGesture) {
                    launch { pressScope.onPress(down.position) }
                }

                val up = waitForUpOrCancellationInitial()
                if (up == null) {
                    pressScope.cancel() // tap-up was canceled
                } else {
                    pressScope.release()
                    onTap?.invoke(up.position)
                }
            }
        }
    }
}

suspend fun AwaitPointerEventScope.waitForUpOrCancellationInitial(): PointerInputChange? {
    while (true) {
        val event = awaitPointerEvent(PointerEventPass.Initial)
        if (event.changes.fastAll { it.changedToUp() }) {
            // All pointers are up
            return event.changes[0]
        }

        if (event.changes.fastAny { it.consumed.downChange || it.isOutOfBounds(size) }) {
            return null // Canceled
        }

        // Check for cancel by position consumption. We can look on the Final pass of the
        // existing pointer event because it comes after the Main pass we checked above.
        val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
        if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
            return null
        }
    }
}

P.S. you need to add implementation("androidx.compose.ui:ui-util:$compose_version") for Android Compose or implementation(compose("org.jetbrains.compose.ui:ui-util")) for Desktop Compose into your build.gradle.kts to use fastAll/fastAny.

Usage:

Card(
    modifier = Modifier
        .width(150.dp).height(64.dp)
        .clickable { }
        .pointerInput(Unit) {
            detectTapAndPressUnconsumed(onTap = {
                println("tap")
            })
        }
) {
    Column {
        Button({ println("Clicked button") }) { Text("Click me") }
    }
}
Nightfall answered 22/8, 2021 at 6:2 Comment(6)
I was hoping for a way to put an item on a background where the background and the foreground don't need to know about each other. And implementing your own button is not quite that. Especially because you'd also need to implement your own TextFields and every other control that listens to mouse events. However, I guess this is as good as compose is going to get. I think I'm going to have to find another way. Thank you..Chevrette
@PhilipDukhov How to change this code to prevent rapid clicks?Roney
@Chevrette Actually I was wrong, you don't need to reimplement all elements touch handling. See updated answerNightfall
@Roney Please see updated answer as it wan't correct. What do you mean by "rapid clicks"? Maybe you need long click detection instead of a plain one?Nightfall
I mean the same as throttleFirst we have in RxJavaRoney
@Roney I'm not familiar with RxJava, but it sounds like this is beyond the scope of this question, you should probably create a separate one.Nightfall

© 2022 - 2024 — McMap. All rights reserved.