Better zooming behaviour in jetpack compose

Viewed 1314

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?

2 Answers

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

Related