1

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?

3
  • You can actually use both if you use the one i posted in the link. If you don't consume events you can pass them to Modifier.pointerInputFilter from pointerInput or you can change propagation direction with pass param. stackoverflow.com/a/76021552/5457853. It also returns list of PointerInputChange while gesture is being invoked but i don't think PointerInputChange has canceled flags yet. Commented Aug 26, 2024 at 19:22
  • Thanks @Thracian! This is not an app that I have the time to work on very often, and I was finally able to confirm that this solution works. If you post an answer I can mark it as accepted. Commented Aug 30, 2024 at 16:37
  • This is a very good and interesting question and tutorial in the link is what i have been looking for some time for hobby project, unfortunately i haven't had time to check it thoroughly yet. I upvoted your question. If you provide the answer i can upvote it as well. And for the cancel part i think it's possible to do it with pointerInput by checking pointer position is in Composable bounds or if it was consumed by another gesture. Commented Aug 30, 2024 at 18:27

1 Answer 1

0

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)
            }
    )
}
Sign up to request clarification or add additional context in comments.

1 Comment

@Thracian was finally able to get a solution that I was happy with posted!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.