1

I'm using detectTransformGestures to help simplify pan and zoom operations (working fine).

I need to know when the user has finished panning or zooming (similar to ACTION_UP). Unfortunately, cannot find a way to do it.

Is there a "callback" or any other way to achieve this while keep using detectTransformGestures?

TareK Khoury
  • 12,721
  • 16
  • 55
  • 78

3 Answers3

1

There is currently no such feature. You can star this feature request to get it resolved faster and to be notified when it is done.

In the meantime, you can copy the source code of detectTransformGestures and add any events you want. For example, here's how you can add an event that will be called after the last finger releases the screen:

suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
    onAllGesturesEnd: () -> Unit,
) {
    val processingEvents = mutableListOf<PointerId>() // store all currently started gestures
    forEachGesture {
        awaitPointerEventScope {
            var rotation = 0f
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var lockedToPanZoom = false

            val input = awaitFirstDown(requireUnconsumed = false)
            processingEvents.add(input.id) // remember a newly started gesture
            do {
                val event = awaitPointerEvent()
                val canceled = event.changes.fastAny { it.positionChangeConsumed() }
                if (!canceled) {
                    val zoomChange = event.calculateZoom()
                    val rotationChange = event.calculateRotation()
                    val panChange = event.calculatePan()

                    if (!pastTouchSlop) {
                        zoom *= zoomChange
                        rotation += rotationChange
                        pan += panChange

                        val centroidSize = event.calculateCentroidSize(useCurrent = false)
                        val zoomMotion = abs(1 - zoom) * centroidSize
                        val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
                        val panMotion = pan.getDistance()

                        if (zoomMotion > touchSlop ||
                            rotationMotion > touchSlop ||
                            panMotion > touchSlop
                        ) {
                            pastTouchSlop = true
                            lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
                        }
                    }

                    if (pastTouchSlop) {
                        val centroid = event.calculateCentroid(useCurrent = false)
                        val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
                        if (effectiveRotation != 0f ||
                            zoomChange != 1f ||
                            panChange != Offset.Zero
                        ) {
                            onGesture(centroid, panChange, zoomChange, effectiveRotation)
                        }
                        event.changes.fastForEach {
                            if (it.positionChanged()) {
                                it.consumeAllChanges()
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.fastAny { it.pressed })
            processingEvents.remove(input.id) // remove gesture from the list when it has ended
            if (processingEvents.isEmpty()) { // if that's the last gesture - call the callback
                onAllGesturesEnd()
            }
        }
    }
}
Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
1

Answer from Pylyp Dukhov is correct but contains some unnecessary part.

I have a detailed answer about how ACTION_UP and onTouchEvent is implemented in Jetpack Compose here, you can check it to get familiar with touch system of Jetpack Compose which might look scary at the beginning.

In a nutshelll all touch events that has a loop like drag, detect gesturest, etc are like this.

val pointerModifier = Modifier
    .pointerInput(Unit) {
        forEachGesture {

            awaitPointerEventScope {
                
                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
                    event.changes.forEach { pointerInputChange: PointerInputChange ->
                        pointerInputChange.consumePositionChange()
                    }
                } while (event.changes.any { it.pressed })

                // ACTION_UP is here
            }
        }
}

When you are at the bottom of the loop which means you are already have all of your pointers up.

If you check the source code of detectTransformGestures you will see that it checks

 val canceled = event.changes.any { it.positionChangeConsumed() }

to start which means any other event consumed move/drag before this pointerInput, it works like when you want another touch to consume event when touch is for instance top right of the screen.

And while (!canceled && event.changes.any { it.pressed })

runs a loop until any is consumed or till at least one pointer is down

so just adding a callback below this while suffice.

suspend fun PointerInputScope.detectTransformGesturesAndEnd(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
    onGestureEnd: ()->Unit
) {
    forEachGesture {
        awaitPointerEventScope {
            var rotation = 0f
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var lockedToPanZoom = false

            awaitFirstDown(requireUnconsumed = false)
            do {
                val event = awaitPointerEvent()
                val canceled = event.changes.any { it.positionChangeConsumed() }
                if (!canceled) {
                    val zoomChange = event.calculateZoom()
                    val rotationChange = event.calculateRotation()
                    val panChange = event.calculatePan()

                    if (!pastTouchSlop) {
                        zoom *= zoomChange
                        rotation += rotationChange
                        pan += panChange

                        val centroidSize = event.calculateCentroidSize(useCurrent = false)
                        val zoomMotion = abs(1 - zoom) * centroidSize
                        val rotationMotion = abs(rotation * kotlin.math.PI.toFloat() * centroidSize / 180f)
                        val panMotion = pan.getDistance()

                        if (zoomMotion > touchSlop ||
                            rotationMotion > touchSlop ||
                            panMotion > touchSlop
                        ) {
                            pastTouchSlop = true
                            lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
                        }
                    }

                    if (pastTouchSlop) {
                        val centroid = event.calculateCentroid(useCurrent = false)
                        val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
                        if (effectiveRotation != 0f ||
                            zoomChange != 1f ||
                            panChange != Offset.Zero
                        ) {
                            onGesture(centroid, panChange, zoomChange, effectiveRotation)
                        }
                        event.changes.forEach {
                            if (it.positionChanged()) {
                                it.consumeAllChanges()
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.any { it.pressed })

            onGestureEnd()
        }
    }
}

And you need pointerIds in a loop when you want to track the first pointer that is pressed or if it's up the next one after that. This works for drawing apps to not draw line to second pointer's position when first one is down, also drag uses it too but it needs to be checked in a loop. adding and removing it before and after a loop doesn't do any work.

/*
   Simplified source code of drag

    suspend fun AwaitPointerEventScope.drag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
): Boolean {
    var pointer = pointerId
    while (true) {
        val change = awaitDragOrCancellation(pointer) ?: return false

        if (change.changedToUpIgnoreConsumed()) {
            return true
        }

        onDrag(change)
        pointer = change.id
    }
}
 */

usage is

@Composable
private fun TransformGesturesZoomExample() {

    val context = LocalContext.current

    var centroid by remember { mutableStateOf(Offset.Zero) }
    var zoom by remember { mutableStateOf(1f) }
    val decimalFormat = remember { DecimalFormat("0.0") }

    var transformDetailText by remember {
        mutableStateOf(
            "Use pinch gesture to zoom in or out.\n" +
                    "Centroid is position of center of touch pointers"
        )
    }

    val imageModifier = Modifier
        .fillMaxSize()
        .pointerInput(Unit) {
            detectTransformGesturesAndEnd(
                onGesture = { gestureCentroid, _, gestureZoom, _ ->
                    centroid = gestureCentroid
                    val newZoom = zoom * gestureZoom
                    zoom = newZoom.coerceIn(0.5f..5f)

                    transformDetailText = "Zoom: ${decimalFormat.format(zoom)}, centroid: $centroid"
                },
                onGestureEnd = {
                    Toast
                        .makeText(context, "Gesture End", Toast.LENGTH_SHORT)
                        .show()
                }
            )
        }
        .drawWithContent {
            drawContent()
            drawCircle(color = Color.Red, center = centroid, radius = 20f)
        }

        .graphicsLayer {
            scaleX = zoom
            scaleY = zoom
        }

    ImageBox(boxModifier, imageModifier, R.drawable.landscape1, transformDetailText, Blue400)
}

Result

Thracian
  • 43,021
  • 16
  • 133
  • 222
0
fun Modifier.onPointerUp(block: () -> Unit): Modifier = pointerInput(Unit) {
    forEachGesture {
        awaitPointerEventScope {
            awaitFirstDown()
            waitForUpOrCancellation()
            block()
        }
    }
}
deviant
  • 3,539
  • 4
  • 32
  • 47