Nishan Khadka
Nishan Khadka

Reputation: 414

Detect if user event is a press or double tap in Jetpack Compose

I have a composable function as shown below and I want to perform a unique action when a tap gesture is detected: for instance,

  1. when tap gesture is pressed perform addition
  2. when tap gesture is released perform substraction
  3. when tap gesture is double-tap perform multiplication

But everytime I double tap, the Press function gets called as well, and I get a log like this:

  • I am press
  • I am press
  • Ə am Double Tapped
 Box(modifier = modifier.fillMaxSize()) {
            Column(
                modifier = modifier
                    .size(200.dp)
                    .align(Alignment.Center)
                    .pointerInput(interactionSource) {
                        detectTapGestures(
                            onPress = {
                                      Timber.d("I am press")
                            },
                            onDoubleTap = {
                                          Timber.d("Ə am Double Tapped")
                            },
                        )
                    }){
                Icon(
                    modifier = modifier.size(100.dp),
                    imageVector = ImageVector.vectorResource(id = R.drawable.ic_launcher),
                    tint = Color.White,
                    contentDescription = "Press to detect"
                )
            }

Upvotes: 3

Views: 4907

Answers (3)

CoolMind
CoolMind

Reputation: 28839

In my case I had this:

val interactionSource = remember { MutableInteractionSource() }

AsyncImage(
    modifier = modifier
        // 1) Either this listener...
        .combinedClickable(
            interactionSource = interactionSource,
            indication = null,
            onClick = { },
            onDoubleClick = { ... },
        )
        // 2) ... or this
        .pointerInput(Unit) {
            detectTapGestures(
                onDoubleTap = { ... }
            )
        }
        .pointerInput(Unit) {
            awaitEachGesture {
                awaitFirstDown()
                // ACTION_DOWN here
                do {
                    //This PointerEvent contains details including
                    // event, id, position and more
                    val event: PointerEvent = awaitPointerEvent()
                    // ACTION_MOVE loop
                    ...
                    // Consuming event prevents other gestures or scroll to intercept

                    // --- Comment 3 line below to make onDoubleClick work ---
                    event.changes.forEach { pointerInputChange: PointerInputChange ->
                        pointerInputChange.consume()
                    }
                } while (event.changes.any { it.pressed })
                // ACTION_UP is here
            }
        },

onDoubleClick didn't receive events. Then I removed 3 lines that were meant in the code.

Upvotes: 2

Thracian
Thracian

Reputation: 67149

You need to write your own tap, press gesture for this kind of behavior. Because onPress is invoked at first awaitFirstDown and followed by tap even if you move your finger inside the composable and lift it inside it no matter how much time has passed. Default tap is not actually a tap rather an onUp gesture compared to onTouchEvent for Views. Tap does not get invoked after the time out threshold if you also observe onLongPress. After the threshold onLongPress is invoked instead of tap.

If you observe onDoubleTap, tap changes again. It's not invoked if it's a double tap.

onPress-> onDoubleTap events get invoked this time. Also another exception here, if second tap is long press then long press is invoked if that's available.

In the source code below as you can see

   suspend fun PointerInputScope.detectTapGestures(
        onDoubleTap: ((Offset) -> Unit)? = null,
        onLongPress: ((Offset) -> Unit)? = null,
        onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
        onTap: ((Offset) -> Unit)? = null
    ) = coroutineScope {
        // special signal to indicate to the sending side that it shouldn't intercept and consume
        // cancel/up events as we're only require down events
        val pressScope = PressGestureScopeImpl(this@detectTapGestures)
    
        awaitEachGesture {

// šŸ”„ This is first contact of First Pointer
            val down = awaitFirstDown()
            down.consume()
            launch {
                pressScope.reset()
            }

// šŸ”„ onPress is invoked here before further checks

            if (onPress !== NoPressGesture) launch {
                pressScope.onPress(down.position)
            }
            val longPressTimeout = onLongPress?.let {
                viewConfiguration.longPressTimeoutMillis
            } ?: (Long.MAX_VALUE / 2)
            var upOrCancel: PointerInputChange? = null
            try {
                // wait for first tap up or long press
                upOrCancel = withTimeout(longPressTimeout) {
                    waitForUpOrCancellation()
                }
                if (upOrCancel == null) {
                    launch {
                        pressScope.cancel() // tap-up was canceled
                    }
                } else {
                    upOrCancel.consume()
                    launch {
                        pressScope.release()
                    }
                }
            } catch (_: PointerEventTimeoutCancellationException) {
                onLongPress?.invoke(down.position)
                consumeUntilUp()
                launch {
                    pressScope.release()
                }
            }
    
            if (upOrCancel != null) {
                // tap was successful.
                if (onDoubleTap == null) {
                    onTap?.invoke(upOrCancel.position) // no need to check for double-tap.
                } else {
                    // check for second tap
                    val secondDown = awaitSecondDown(upOrCancel)
    
                    if (secondDown == null) {
                        onTap?.invoke(upOrCancel.position) // no valid second tap started
                    } else {
                        // Second tap down detected
                        launch {
                            pressScope.reset()
                        }
                        if (onPress !== NoPressGesture) {
                            launch { pressScope.onPress(secondDown.position) }
                        }
    
                        try {
                            // Might have a long second press as the second tap
                            withTimeout(longPressTimeout) {
                                val secondUp = waitForUpOrCancellation()
                                if (secondUp != null) {
                                    secondUp.consume()
                                    launch {
                                        pressScope.release()
                                    }
                                    onDoubleTap(secondUp.position)
                                } else {
                                    launch {
                                        pressScope.cancel()
                                    }
                                    onTap?.invoke(upOrCancel.position)
                                }
                            }
                        } catch (e: PointerEventTimeoutCancellationException) {
                            // The first tap was valid, but the second tap is a long press.
                            // notify for the first tap
                            onTap?.invoke(upOrCancel.position)
    
                            // notify for the long press
                            onLongPress?.invoke(secondDown.position)
                            consumeUntilUp()
                            launch {
                                pressScope.release()
                            }
                        }
                    }
                }
            }
        }
    }

Upvotes: 3

Gabe Sechan
Gabe Sechan

Reputation: 93668

That's expected. Taps come in immediately. So it will send the tap message on the first tap, then again when the second happens, then it will figure it was a double tap. There's two ways to fix this. The first is to make it so that the tap does X, and the double tap does X and Y. Then you can do X in onTap, and Y in onDoubleTap. The other is not to use onDoubleTap. Set a timer in onTap for as long as you think a double tap should take. Wait for the timer to finish. If you see a second tap by then, its a double tap. Otherwise it's a single. Note that this will make your app appear less responsive, as you have to wait after single taps to see if its part of a double.

Upvotes: 1

Related Questions