androidandroid-jetpack-composeuser-input

How do I use both multi-touch gestures and cancellable actions in Jetpack Compose?


I was following this tutorial (https://developer.android.com/codelabs/large-screens/advanced-stylus-support#4) which uses pointerInteropFilter to handle the MotionEvents directly and detect ACTION_CANCEL and FLAG_CANCELED for motion events that the OS has detected as being unintended (e.g. palm rejection). I got that working, but I would like the same composable to support multi-touch gestures (zooming and panning). I looked at the sample code on that page (https://developer.android.com/develop/ui/compose/touch-input/pointer-input/multi-touch), and at the bottom it mentions "If you need to combine zooming, panning and rotation with other gestures, you can use the PointerInputScope.detectTransformGestures detector.

Following that link (https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/package-summary#(androidx.compose.ui.input.pointer.PointerInputScope).detectTransformGestures(kotlin.Boolean,kotlin.Function4)), it uses pointerInput to handle input rather than pointerInteropFilter. Looking at the API for PointerEvent and PointerInputChange, though, I don't see anything about detecting cancelled actions, and it doesn't seem like I can use both pointerInput and pointerInteropFilter on the same Composable (but I might be wrong about that).

Obviously I'd prefer not to implement zooming/panning myself from scratch, so is there something I'm missing about how to combine both of these behaviors?


Solution

  • The following library had exactly the solution I needed:

    https://github.com/SmartToolFactory/Compose-Extended-Gestures/

    Specifically, I used the function from their library with the signature

    suspend fun PointerInputScope.detectPointerTransformGestures(
        panZoomLock: Boolean = true,
        numberOfPointers: Int = 1,
        pass: PointerEventPass = PointerEventPass.Main,
        requisite: PointerRequisite = PointerRequisite.None,
        consume: Boolean = true,
        onGestureStart: (PointerInputChange) -> Unit = {},
        onGesture:
            (
            centroid: Offset,
            pan: Offset,
            zoom: Float,
            rotation: Float,
            mainPointer: PointerInputChange,
            changes: List<PointerInputChange>
        ) -> Unit,
        onGestureEnd: (PointerInputChange) -> Unit = {},
        onGestureCancel: () -> Unit = {},
    )
    

    because I only want it to handle multi-pointer gestures; any single-point gestures should be handled by my existing input handler. The library also has a demo of how to use the functions, but there's a bug in the implementation (which I opened an issue for in their repo), so here's how I'm using it. Note that I don't need rotation, so I omitted it:

    var zoom by remember { mutableFloatStateOf(1f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    Box(
        Modifier
            .fillMaxSize()
            // This handles multi-pointer gestures.
            .pointerInput(Unit) {
                detectPointerTransformGestures(
                    numberOfPointers = 1,
                    requisite = PointerRequisite.GreaterThan,
                    pass = PointerEventPass.Initial,
                    onGesture = { gestureCentroid: Offset,
                                  gesturePan: Offset,
                                  gestureZoom: Float,
                                  _,
                                  _,
                                  changes: List<PointerInputChange> ->
    
                        val oldScale = zoom
                        val newScale = zoom.coerceIn(1f..5f)
    
                        // The parameter gestureCentroid uses a coordinate system where
                        // (0, 0) is the top left corner of the screen, but zooming uses
                        // a coordinate system where (0, 0) is in the middle of the screen.
                        // To properly handle adjusting the offset when zooming, first
                        // translate the centroid to the right coordinate system.
                        val width = MyApplication.getScreenWidth()
                        val height = MyApplication.getScreenHeight()
                        val centroidToScreenCenter = gestureCentroid.minus(Offset(width / 2f, height / 2f))
    
                        // The first term handles panning (negative because panning
                        // fingers moving to the right means panning to the left), and
                        // the second term handles zooming towards the centroid.
                        offset = (offset - gesturePan / oldScale) +
                                (centroidToScreenCenter / oldScale - centroidToScreenCenter / newScale)
                        loadedDocument.stylusState.zoom = newScale
    
                        // Consume touch when multiple fingers down. This prevents events from
                        // being passed on to later listeners while a gesture is being invoked.
                        val size = changes.size
                        if (size > 1) {
                            changes.forEach { it.consume() }
                        }
                    }
                )
            }
    )
    {
        Image(
            painter = BitmapPainter(/* Get image in my project */),
            contentDescription = "Image description",
            modifier
                .fillMaxSize()
                .graphicsLayer {
                    translationX = -loadedDocument.stylusState.translationOffset.x * loadedDocument.stylusState.zoom
                    translationY = -loadedDocument.stylusState.translationOffset.y * loadedDocument.stylusState.zoom
                    scaleX = loadedDocument.stylusState.zoom
                    scaleY = loadedDocument.stylusState.zoom
                }
                // This second listener handles all single-pointer input events.
                .pointerInteropFilter {
                    processMotionEvent(it)
                }
        )
    }