The default zoom behaviour as explained in the compose documentation interferes with dragGestures and rotates and scales around the center of the zoomable and not your fingers
Is there a better way to do this?
The default zoom behaviour as explained in the compose documentation interferes with dragGestures and rotates and scales around the center of the zoomable and not your fingers
Is there a better way to do this?
I made the code from this solution into a library: de.mr-pine.utils:zoomables
You have to use pointerInputScope with detectTransformGestures and this function as your onGesture:
fun onTransformGesture(
centroid: Offset,
pan: Offset,
zoom: Float,
transformRotation: Float
) {
offset += pan
scale *= zoom
rotation += transformRotation
val x0 = centroid.x - imageCenter.x
val y0 = centroid.y - imageCenter.y
val hyp0 = sqrt(x0 * x0 + y0 * y0)
val hyp1 = zoom * hyp0 * (if (x0 > 0) {
1f
} else {
-1f
})
val alpha0 = atan(y0 / x0)
val alpha1 = alpha0 + (transformRotation * ((2 * PI) / 360))
val x1 = cos(alpha1) * hyp1
val y1 = sin(alpha1) * hyp1
transformOffset =
centroid - (imageCenter - offset) - Offset(x1.toFloat(), y1.toFloat())
offset = transformOffset
}
Here's an example of how to rotate/scale around the touch inputs which also supports swiping and double-tapping to reset zoom/zoom in:
val scope = rememberCoroutineScope()
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}
var dragOffset by remember { mutableStateOf(Offset.Zero) }
var imageCenter by remember { mutableStateOf(Offset.Zero) }
var transformOffset by remember { mutableStateOf(Offset.Zero) }
Box(
Modifier
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale != 1f) {
scope.launch {
state.animateZoomBy(1 / scale)
}
offset = Offset.Zero
rotation = 0f
} else {
scope.launch {
state.animateZoomBy(2f)
}
}
}
)
}
.pointerInput(Unit) {
val panZoomLock = true
forEachGesture {
awaitPointerEventScope {
var transformRotation = 0f
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var lockedToPanZoom = false
var drag: PointerInputChange?
var overSlop = Offset.Zero
val down = awaitFirstDown(requireUnconsumed = false)
var transformEventCounter = 0
do {
val event = awaitPointerEvent()
val canceled = event.changes.fastAny { it.positionChangeConsumed() }
var relevant = true
if (event.changes.size > 1) {
if (!canceled) {
val zoomChange = event.calculateZoom()
val rotationChange = event.calculateRotation()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
zoom *= zoomChange
transformRotation += rotationChange
pan += panChange
val centroidSize =
event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
val rotationMotion =
abs(transformRotation * PI.toFloat() * centroidSize / 180f)
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop ||
rotationMotion > touchSlop ||
panMotion > touchSlop
) {
pastTouchSlop = true
lockedToPanZoom =
panZoomLock && rotationMotion < touchSlop
}
}
if (pastTouchSlop) {
val eventCentroid = event.calculateCentroid(useCurrent = false)
val effectiveRotation =
if (lockedToPanZoom) 0f else rotationChange
if (effectiveRotation != 0f ||
zoomChange != 1f ||
panChange != Offset.Zero
) {
onTransformGesture(
eventCentroid,
panChange,
zoomChange,
effectiveRotation
)
}
event.changes.fastForEach {
if (it.positionChanged()) {
it.consumeAllChanges()
}
}
}
}
} else if (transformEventCounter > 3) relevant = false
transformEventCounter++
} while (!canceled && event.changes.fastAny { it.pressed } && relevant)
do {
val event = awaitPointerEvent()
drag = awaitTouchSlopOrCancellation(down.id) { change, over ->
change.consumePositionChange()
overSlop = over
}
} while (drag != null && !drag.positionChangeConsumed())
if (drag != null) {
dragOffset = Offset.Zero
if (scale !in 0.92f..1.08f) {
offset += overSlop
} else {
dragOffset += overSlop
}
if (drag(drag.id) {
if (scale !in 0.92f..1.08f) {
offset += it.positionChange()
} else {
dragOffset += it.positionChange()
}
it.consumePositionChange()
}
) {
if (scale in 0.92f..1.08f) {
val offsetX = dragOffset.x
if (offsetX > 300) {
onSwipeRight()
} else if (offsetX < -300) {
onSwipeLeft()
}
}
}
}
}
}
}
) {
ZoomComposable(
modifier = Modifier
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.graphicsLayer(
scaleX = scale - 0.02f,
scaleY = scale - 0.02f,
rotationZ = rotation
)
.onGloballyPositioned { coordinates ->
val localOffset =
Offset(
coordinates.size.width.toFloat() / 2,
coordinates.size.height.toFloat() / 2
)
val windowOffset = coordinates.localToWindow(localOffset)
imageCenter = coordinates.parentLayoutCoordinates?.windowToLocal(windowOffset)
?: Offset.Zero
}
)
}
This is a really simple Zoomable Image.
@Composable
fun ZoomableImage() {
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
Modifier
.size(600.dp)
) {
Image(
painter = rememberImagePainter(data = "https://picsum.photos/600/600"),
contentDescription = "A Content description",
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = if (scale > 1f) offset.x else 0f,
translationY = if (scale > 1f) offset.y else 0f
)
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan: Offset, zoom: Float, _ ->
offset += pan
scale = (scale * zoom).coerceIn(0.5f, 4f)
}
)
}
)
}
}
Only zooming and panning are supported. Rotation and double tap is not.
For a slightly smoother panning, you can apply a small multiplier to pan
, like:
offset += pan * 1.5f
I've also added coerceIn
to avoid zooming in/out until bounds that will look weird. Feel free to remove coerceIn
if you need to. You can also remove the containing Box
and the Alignment
.
translation (panning) will only be applied if we've zoomed previously. That looks more natural IMHO.
Feedback and improvements are welcome