Answer from Pylyp Dukhov is correct but contains some unnecessary part.
I have a detailed answer about how ACTION_UP
and onTouchEvent
is implemented in Jetpack Compose here, you can check it to get familiar with touch system of Jetpack Compose which might look scary at the beginning.
In a nutshelll all touch events that has a loop like drag, detect gesturest, etc are like this.
val pointerModifier = Modifier
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
// ACTION_DOWN here
do {
//This PointerEvent contains details including
// event, id, position and more
val event: PointerEvent = awaitPointerEvent()
// ACTION_MOVE loop
// Consuming event prevents other gestures or scroll to intercept
event.changes.forEach { pointerInputChange: PointerInputChange ->
pointerInputChange.consumePositionChange()
}
} while (event.changes.any { it.pressed })
// ACTION_UP is here
}
}
}
When you are at the bottom of the loop which means you are already have all of your pointers up.
If you check the source code of detectTransformGestures
you will see that it checks
val canceled = event.changes.any { it.positionChangeConsumed() }
to start which means any other event consumed move/drag before this pointerInput, it works like when you want another touch to consume event when touch is for instance top right of the screen.
And while (!canceled && event.changes.any { it.pressed })
runs a loop until any is consumed or till at least one pointer is down
so just adding a callback below this while suffice.
suspend fun PointerInputScope.detectTransformGesturesAndEnd(
panZoomLock: Boolean = false,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
onGestureEnd: ()->Unit
) {
forEachGesture {
awaitPointerEventScope {
var rotation = 0f
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var lockedToPanZoom = false
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val canceled = event.changes.any { it.positionChangeConsumed() }
if (!canceled) {
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)
}
event.changes.forEach {
if (it.positionChanged()) {
it.consumeAllChanges()
}
}
}
}
} while (!canceled && event.changes.any { it.pressed })
onGestureEnd()
}
}
}
And you need pointerIds in a loop when you want to track the first pointer that is pressed or if it's up the next one after that. This works for drawing apps to not draw line to second pointer's position when first one is down, also drag uses it too but it needs to be checked in a loop. adding and removing it before and after a loop doesn't do any work.
/*
Simplified source code of drag
suspend fun AwaitPointerEventScope.drag(
pointerId: PointerId,
onDrag: (PointerInputChange) -> Unit
): Boolean {
var pointer = pointerId
while (true) {
val change = awaitDragOrCancellation(pointer) ?: return false
if (change.changedToUpIgnoreConsumed()) {
return true
}
onDrag(change)
pointer = change.id
}
}
*/
usage is
@Composable
private fun TransformGesturesZoomExample() {
val context = LocalContext.current
var centroid by remember { mutableStateOf(Offset.Zero) }
var zoom by remember { mutableStateOf(1f) }
val decimalFormat = remember { DecimalFormat("0.0") }
var transformDetailText by remember {
mutableStateOf(
"Use pinch gesture to zoom in or out.\n" +
"Centroid is position of center of touch pointers"
)
}
val imageModifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGesturesAndEnd(
onGesture = { gestureCentroid, _, gestureZoom, _ ->
centroid = gestureCentroid
val newZoom = zoom * gestureZoom
zoom = newZoom.coerceIn(0.5f..5f)
transformDetailText = "Zoom: ${decimalFormat.format(zoom)}, centroid: $centroid"
},
onGestureEnd = {
Toast
.makeText(context, "Gesture End", Toast.LENGTH_SHORT)
.show()
}
)
}
.drawWithContent {
drawContent()
drawCircle(color = Color.Red, center = centroid, radius = 20f)
}
.graphicsLayer {
scaleX = zoom
scaleY = zoom
}
ImageBox(boxModifier, imageModifier, R.drawable.landscape1, transformDetailText, Blue400)
}
Result
