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.
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?
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
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