How to detect the end of transform gesture in Jetpack Compose?

Viewed 1110

I am able to detect transform gestures just fine using Modifier.detectTransformGesture(), as per the below simplified example:

Box(
  Modifier
    .pointerInput(Unit) {
        detectTransformGestures(
          onGesture = { _, pan, gestureZoom, gestureRotate ->
            // do something
          }
        )
    }
)

But I want to know when the gesture has been completed by the user, so that I can perform some more (computationally intensive) operations.

I couldn't find any leads for that. I tried with Modifier.transformable and TransformableState which does have a property called isTransformInProgress, but I couldn't figure out how to access it in the callback:

val state = rememberTransformableState {
  // How do I access state.isTransformInProgress ?
}

// I can access it here
Text(if(state.isTransformInProgress) "transforming" else "not transforming")
3 Answers

If you need to check the end of transform when using Modifier.transformable, you can access isTransformInProgress using LaunchedEffect:

val state = rememberTransformableState {

}

LaunchedEffect(state.isTransformInProgress) {
    if (!state.isTransformInProgress) {
       // do what you need
    }
}

I can think of two variants, both came after investigating the source code:

  1. Subscribe to events in parallel the same way detectTransformGestures does:
fun Modifier.pointerInputDetectTransformGestures(
    panZoomLock: Boolean = false,
    isTransformInProgressChanged: (Boolean) -> Unit,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
): Modifier {
    return pointerInput(Unit) {
        detectTransformGestures(
            panZoomLock = panZoomLock,
            onGesture = { offset, pan, gestureZoom, gestureRotate ->
                isTransformInProgressChanged(true)
                onGesture(offset, pan, gestureZoom, gestureRotate)
            }
        )
    }
        .pointerInput(Unit) {
            forEachGesture {
                awaitPointerEventScope {
                    awaitFirstDown(requireUnconsumed = false)
                    do {
                        val event = awaitPointerEvent()
                        val canceled = event.changes.any { it.consumed.positionChange }
                    } while (!canceled && event.changes.any { it.pressed })
                    isTransformInProgressChanged(false)
                }
            }
        }
}
  1. Copy implementation of pointerInputDetectTransformGestures(it has no internal references), and add needed logic there, like this:
suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
    isTransformInProgressChanged: (Boolean) -> Unit,
) {
    forEachGesture {
        awaitPointerEventScope {
            var rotation = 0f
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var lockedToPanZoom = false
            var startGestureNotified = false // added

            awaitFirstDown(requireUnconsumed = false)
            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)
                            if (!startGestureNotified) { // notify first gesture sent
                                isTransformInProgressChanged(true) 
                                startGestureNotified = true
                            }
                        }
                        event.changes.fastForEach {
                            if (it.positionChanged()) {
                                it.consumeAllChanges()
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.fastAny { it.pressed })
            isTransformInProgressChanged(false) // notify last finger is up
        }
    }
}

In any case you would have to check out of any changes in new versions of compose to update your code(have no idea how likely it'll be).

With the second method at least you can be sure it won't just broke, because you're not depending on their implementation.

You can use this to detect when user starts transform gesture, when it ends and have option to consume transform events to enable gesture propagation to scroll or other drag events.

/**
 * A gesture detector for rotation, panning, and zoom. Once touch slop has been reached, the
 * user can use rotation, panning and zoom gestures. [onGesture] will be called when any of the
 * rotation, zoom or pan occurs, passing the rotation angle in degrees, zoom in scale factor and
 * pan as an offset in pixels. Each of these changes is a difference between the previous call
 * and the current gesture. This will consume all position changes after touch slop has
 * been reached. [onGesture] will also provide centroid of all the pointers that are down.
 *
 * After gesture started  when last pointer is up [onGestureEnd] is triggered.
 *
 * @param consume flag consume [PointerInputChange]s this gesture uses. Consuming
 * returns [PointerInputChange.isConsumed] true which is observed by other gestures such
 * as drag, scroll and transform. When this flag is true other gesture don't receive events
 * @param onGestureStart callback for notifying transform gesture has started with initial
 * pointer
 * @param onGesture callback for passing centroid, pan, zoom, rotation and  main pointer and
 * pointer size to caller. Main pointer is the one that touches screen first. If it's lifted
 * next one that is down is the main pointer.
 * @param onGestureEnd callback that notifies last pointer is up and gesture is ended
 *
 */
suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    consume: Boolean = true,
    onGestureStart: (PointerInputChange) -> Unit = {},
    onGesture: (
        centroid: Offset,
        pan: Offset,
        zoom: Float,
        rotation: Float,
        mainPointer: PointerInputChange,
        changes: List<PointerInputChange>
    ) -> Unit,
    onGestureEnd: (PointerInputChange) -> Unit = {}
) {
    forEachGesture {
        awaitPointerEventScope {
            var rotation = 0f
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var lockedToPanZoom = false


            // Wait for at least one pointer to press down, and set first contact position
            val down: PointerInputChange = awaitFirstDown(requireUnconsumed = false)
            onGestureStart(down)

            var pointer = down
            // Main pointer is the one that is down initially
            var pointerId = down.id

            do {
                val event = awaitPointerEvent()

                // If any position change is consumed from another PointerInputChange
                val canceled =
                    event.changes.any { it.isConsumed }

                if (!canceled) {

                    // Get pointer that is down, if first pointer is up
                    // get another and use it if other pointers are also down
                    // event.changes.first() doesn't return same order
                    val pointerInputChange =
                        event.changes.firstOrNull { it.id == pointerId }
                            ?: event.changes.first()

                    // Next time will check same pointer with this id
                    pointerId = pointerInputChange.id
                    pointer = pointerInputChange

                    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,
                                pointer,
                                event.changes
                            )
                        }

                        if (consume) {
                            event.changes.forEach {
                                if (it.positionChanged()) {
                                    it.consume()
                                }
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.any { it.pressed })
            onGestureEnd(pointer)
        }
    }
}

also it let's you to get mainPointer, first pointer initially then the one that is returned from pointer list with id, and all pointers that are down which let's you to detect how many fingers user invoking gesture with

Modifier.pointerInput(Unit) {
  detectTransformGestures(
    onGestureStart = {
      transformDetailText = "GESTURE START"
    },
    onGesture = { gestureCentroid: Offset,
                  gesturePan: Offset,
                  gestureZoom: Float,
                  gestureRotate: Float,
                  mainPointerInputChange: PointerInputChange,
                  pointerList: List<PointerInputChange> ->
     
    },
    onGestureEnd = {
      borderColor = Color.LightGray
      transformDetailText = "GESTURE END"
    }
  )
}

Full library code that has onEvent counterpart, touch delegate and demo is available in github repository.

Related