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)
}
)
}
pointerInputby checking pointer position is in Composable bounds or if it was consumed by another gesture.