I was following this tutorial (https://developer.android.com/codelabs/large-screens/advanced-stylus-support#4) which uses pointerInteropFilter
to handle the MotionEvent
s 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?
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)
}
)
}