5

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?

Thracian
  • 43,021
  • 16
  • 133
  • 222
nyx69
  • 747
  • 10
  • 21

1 Answers1

6

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

Thracian
  • 43,021
  • 16
  • 133
  • 222