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") }
        }
    }
}
Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
willow512
  • 122
  • 1
  • 7

1 Answers1

14

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") }
    }
}
Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • 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.. – willow512 Aug 22 '21 at 20:43
  • @PhilipDukhov How to change this code to prevent rapid clicks? – Dr.jacky Oct 22 '21 at 08:04
  • @willow512 Actually I was wrong, you don't need to reimplement all elements touch handling. See updated answer – Phil Dukhov Oct 22 '21 at 09:33
  • @Dr.jacky 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? – Phil Dukhov Oct 22 '21 at 09:35
  • I mean the same as `throttleFirst` we have in RxJava – Dr.jacky Oct 22 '21 at 10:37
  • 1
    @Dr.jacky 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. – Phil Dukhov Oct 22 '21 at 10:51