4

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?

Mr. Pine
  • 239
  • 3
  • 13

2 Answers2

10

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
            }
    )
}
Mr. Pine
  • 239
  • 3
  • 13
  • How does onTransformGesture get access to the properties offset, scale, rotation, imageCenter and transformOffset? – arne.jans Mar 31 '22 at 06:02
  • Could you please put those two code-snippets together as a whole Composeable-function so that the code compiles? – arne.jans Mar 31 '22 at 06:05
  • @arne.jans I have been working on a library implementing this but haven't got to finally publishing it in the last few days. You can look at the source code that implements it [here](https://github.com/Mr-Pine/AndroidUtilityLibraries/blob/master/libraries/zoomables/src/main/kotlin/de/mr_pine/zoomables/Zoomables.kt#L51). I'll edit this answer when it's published – Mr. Pine Mar 31 '22 at 10:07
  • 1
    I am looking forward to trying it out, sounds promising! – arne.jans Mar 31 '22 at 15:07
  • I have tried out your Zoomables-composable yesterday in a HorizontalPager with GlideImage as content and have some questions and feedback to your awesome library! Would you be open to me creating one or more issues on your github-repo? – arne.jans Apr 01 '22 at 09:41
  • @Mr.Pine would you mind adding algorithm or reasoning behind your offset calculation? What exactly do you do, and why do you do it? I know about centroids, didn't get hypothenuses and how do you change zoom and rotation after getting offset? And would you mind checking this question?https://stackoverflow.com/questions/72922205/how-to-have-natural-pan-and-zoom-with-modifier-graphicslayer-pointerinput – Thracian Aug 07 '22 at 17:07
  • @Thracian Sure, I'll try to explain it as good as I can. I've also had a quick look at your question and, while I've only skimmed through it for now, it seems like the exact use case I made the library linked above (link should work now) for – Mr. Pine Aug 07 '22 at 21:21
  • @Mr.Pine i checked your code, i'm still trying to figure out your algorithm and did some sample without State yet offset changes so fast. Here is gist file https://gist.github.com/SmartToolFactory/67909c6115b0c3e3a1800b4b3a7cba8e – Thracian Aug 09 '22 at 17:25
  • Is it possible to have natural zoom by translating inside `Modifier.graphicsLayer` and not without current center of Composable? In gist file also another sample that works when `TransformationOrigin` is set to (0,0) but i don't want to change origin because i'm doing other operations that require me to do from center – Thracian Aug 09 '22 at 17:27
1

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

voghDev
  • 5,641
  • 2
  • 37
  • 41
  • Looks like a good solution if you only need basic zoom and pan behaviour. In this case you could also use the [transformable Modifier](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).transformable(androidx.compose.foundation.gestures.TransformableState,kotlin.Boolean,kotlin.Boolean)) which handles the gestures for you, supports rotating (but only around the center of the Composable) and doesn't interfere with double tapping – Mr. Pine Feb 24 '22 at 10:28
  • 1
    That was the first thing I tried @Mr.Pine, but transformable seems not to implement panning, only zooming in/out with Pinch gesture. I like how the code looked with transformable, but unfortunately I couldn't move up/down/left/right using pan gesture. If you have a working example that uses `transformable`, I'd be pleased to try it in my devices – voghDev Mar 01 '22 at 11:27
  • So I tried it today while making my solution into a library and panning is supported by transformable. I tried [this](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).transformable(androidx.compose.foundation.gestures.TransformableState,kotlin.Boolean,kotlin.Boolean)) example in the documentation where pan is in a separate offset modifier. [This example](https://developer.android.com/jetpack/compose/gestures?hl=en#multitouch) suggest it should also work in the graphicsLayer modifier – Mr. Pine Mar 02 '22 at 22:18