kotlinandroid-jetpack-composepinchzoominterceptandroid-jetpack-compose-gesture

Jetpack Compose Intercept pinch/zoom in child layout


I want to have a box with colums of rows filled with further children that accept clicks ("CL") and long clicks ("LO") to be zoomable and draggable. Using pointerInput with detectTransforgestures I can transform the child layout as I want.

var zoom by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }

val outer = (1..60).toList().chunked(6)

Box(Modifier
.fillMaxSize()
.pointerInput(Unit) {

    //zoom in/out and move around
    detectTransformGestures { gestureCentroid, gesturePan, gestureZoom, _ ->
        val oldScale = zoom
        val newScale = (zoom * gestureZoom).coerceIn(0.5f..5f)
        offset =
            (offset + gestureCentroid / oldScale) - (gestureCentroid / newScale + gesturePan / oldScale)
        zoom = newScale
    }
}) {

    Box(
        Modifier

            .graphicsLayer {
                translationX = -offset.x * zoom
                translationY = -offset.y * zoom
                scaleX = zoom
                scaleY = zoom
            }
            .background(Color.Cyan)
    ) {

        Column {

            outer.forEach { inner ->

                Row {

                    inner.forEach { tile ->

                        var text by remember {
                            mutableStateOf(tile.toString())
                        }

                        Text(text,
                            Modifier
                                .padding(8.dp)
                                .combinedClickable(
                                    onClick = {
                                        text = "CL"
                                    },
                                    onLongClick = {
                                        text = "LO"
                                    }
                                )
                                .background(Color.Green)
                                .padding(8.dp)
                        )
                        
                    }
                }
            }
        }
    }
}

The problem being now that the clickable children (marked green) seem to swallow tap gestures, so when trying to pinch two fingers, I'm unable to zoom back out, if my fingers hit the buttons (as signaled by the ripple) instead of the blue or white area.

enter image description here

Is there any way to not make the clickable children consume this type of event or maybe intercepts it, so that they don't even receive multitouch events like pinch?


Solution

  • In jetpack Compose default PointerEventPass is Main which as you can see in this answer, gestures propagate from descendant to ancestor while you want transform gesture to propagate from ancestor to descendant.

    You need to use PointerEventPass.Initial for this. Using Final won't work when you touch the buttons because they will consume events. Now, Cyan background will allow pinch gestures before buttons consume them also you can consume events when number of pointers is bigger than 1 to not set click or long click as

    // 🔥Consume touch when multiple fingers down
    // This prevents click and long click if your finger touches a
    // button while pinch gesture is being invoked
    val size = changes.size
    if (size>1){
        changes.forEach { it.consume() }
    }
    

    Result

    enter image description here

    The code you should use for transform is

    suspend fun PointerInputScope.detectTransformGestures(
        panZoomLock: Boolean = false,
        consume: Boolean = true,
        pass: PointerEventPass = PointerEventPass.Main,
        onGestureStart: (PointerInputChange) -> Unit = {},
        onGesture: (
            centroid: Offset,
            pan: Offset,
            zoom: Float,
            rotation: Float,
            mainPointer: PointerInputChange,
            changes: List<PointerInputChange>
        ) -> Unit,
        onGestureEnd: (PointerInputChange) -> Unit = {}
    ) {
        awaitEachGesture {
            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,
                pass = pass
            )
            onGestureStart(down)
    
            var pointer = down
            // Main pointer is the one that is down initially
            var pointerId = down.id
    
            do {
                val event = awaitPointerEvent(pass = pass)
    
                // If any position change is consumed from another PointerInputChange
                // or pointer count requirement is not fulfilled
                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 * 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,
                                pointer,
                                event.changes
                            )
                        }
    
                        if (consume) {
                            event.changes.forEach {
                                if (it.positionChanged()) {
                                    it.consume()
                                }
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.any { it.pressed })
            onGestureEnd(pointer)
        }
    }
    

    Usage

    @OptIn(ExperimentalFoundationApi::class)
    @Preview
    @Composable
    private fun TouchComposable() {
        var zoom by remember { mutableStateOf(1f) }
        var offset by remember { mutableStateOf(Offset.Zero) }
    
        val outer = (1..60).toList().chunked(6)
    
        Box(
            Modifier
                .fillMaxSize()
                .pointerInput(Unit) {
    
                    //zoom in/out and move around
                    detectTransformGestures(
                        pass = PointerEventPass.Initial,
                        onGesture = { gestureCentroid: Offset,
                                      gesturePan: Offset,
                                      gestureZoom: Float,
                                      _,
                                      _,
                                      changes: List<PointerInputChange> ->
    
                            val oldScale = zoom
                            val newScale = (zoom * gestureZoom).coerceIn(0.5f..5f)
                            offset =
                                (offset + gestureCentroid / oldScale) - (gestureCentroid / newScale + gesturePan / oldScale)
                            zoom = newScale
    
    
    // 🔥Consume touch when multiple fingers down
    // This prevents click and long click if your finger touches a
    // button while pinch gesture is being invoked
                            val size = changes.size
                            if (size > 1) {
                                changes.forEach { it.consume() }
                            }
                        }
                    )
                }) {
    
            Box(
                Modifier
    
                    .graphicsLayer {
                        translationX = -offset.x * zoom
                        translationY = -offset.y * zoom
                        scaleX = zoom
                        scaleY = zoom
                    }
                    .background(Color.Cyan)
            ) {
    
                Column {
    
                    outer.forEach { inner ->
    
                        Row {
    
                            inner.forEach { tile ->
    
                                var text by remember {
                                    mutableStateOf(tile.toString())
                                }
    
                                Text(text,
                                    Modifier
                                        .padding(8.dp)
                                        .combinedClickable(
                                            onClick = {
                                                text = "CL"
                                            },
                                            onLongClick = {
                                                text = "LO"
                                            }
                                        )
                                        .background(Color.Green)
                                        .padding(8.dp)
                                )
    
                            }
                        }
                    }
                }
            }
        }
    
    }
    

    You can also find this gesture and some other gestures in this library

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

    And more about gestures in this tutorial

    https://github.com/SmartToolFactory/Jetpack-Compose-Tutorials