Mr. Pine
Mr. Pine

Reputation: 352

Better zooming behaviour in jetpack compose

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?

Upvotes: 8

Views: 6209

Answers (4)

VIN
VIN

Reputation: 6957

In case anyone is struggling to migrate from forEachGesture to awaitEachGesture after upgrading to Compose 1.4.3, here is a working solution:

@Composable
fun ZoomableImage(
    modifier: Modifier = Modifier, //for image
    animateSnapBack: Boolean = true,
    containerModifier: Modifier = Modifier, //for box
    bitmap: ImageBitmap? = null,
    contentDescription: String? = null,
    contentScale: ContentScale = ContentScale.Fit,
    magnificationScale: Float = MAGNIFICATION_SCALE_DEFAULT,
    onZoomModeChanged: ((Boolean) -> Unit)? = null,
    scrollState: ScrollableState? = null,
    snapBack: Boolean = false,
    supportRotation: Boolean = false,
) {

    val coroutineScope = rememberCoroutineScope()

    var scale by remember { mutableStateOf(MAGNIFICATION_BASELINE) }
    var rotation by remember { mutableStateOf(ROTATION_BASELINE) }
    var offsetX by remember { mutableStateOf(OFFSET_X_BASELINE) }
    var offsetY by remember { mutableStateOf(OFFSET_Y_BASELINE) }

    // for animating scale, rotation and translation back to its original state
    var isInGesture by remember { mutableStateOf(false) }
    val snapBackScale by animateFloatAsState(
        if (isInGesture) scale else MAGNIFICATION_BASELINE,
        label = LABEL_ZOOMABLE_IMG_SNAP_BACK_SCALE
    )
    val snapBackRotation by animateFloatAsState(
        if (isInGesture) rotation else ROTATION_BASELINE,
        label = LABEL_ZOOMABLE_IMG_SNAP_BACK_ROTATION
    )
    val snapBackOffsetX by animateFloatAsState(
        if (isInGesture) offsetX else OFFSET_X_BASELINE,
        label = LABEL_ZOOMABLE_IMG_SNAP_BACK_OFFSET_X
    )
    val snapBackOffsetY by animateFloatAsState(
        if (isInGesture) offsetY else OFFSET_Y_BASELINE,
        label = LABEL_ZOOMABLE_IMG_SNAP_BACK_OFFSET_Y
    )

    Box(
        modifier = containerModifier
            .combinedClickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                onClick = { },
                onDoubleClick = {
                    if (scale >= MAGNIFICATION_THRESHOLD) {
                        onZoomModeChanged?.invoke(false)
                        scale = MAGNIFICATION_BASELINE
                        offsetX = OFFSET_X_BASELINE
                        offsetY = OFFSET_Y_BASELINE
                    } else {
                        scale = magnificationScale
                    }
                },
            )
            .pointerInput(Unit) {
                awaitEachGesture {
                    awaitFirstDown()
                    do {

                        isInGesture = true

                        val event = awaitPointerEvent()
                        scale *= event.calculateZoom()
                        if (scale > MAGNIFICATION_BASELINE) {
                            scrollState?.run {
                                coroutineScope.launch {
                                    setScrolling(false)
                                }
                            }

                            onZoomModeChanged?.invoke(true)

                            val pan = event.calculatePan()
                            offsetX += pan.x
                            offsetY += pan.y

                            rotation += event.calculateRotation()

                            scrollState?.run {
                                coroutineScope.launch {
                                    setScrolling(true)
                                }
                            }
                        } else if (!snapBack) {
                            // for the no snap back use case, the image should shift back into its container when it gets close enough to the original position
                            onZoomModeChanged?.invoke(false)
                            scale = MAGNIFICATION_BASELINE
                            offsetX = OFFSET_X_BASELINE
                            offsetY = OFFSET_Y_BASELINE
                            rotation = ROTATION_BASELINE
                        }
                    } while (event.changes.any { it.pressed })

                    // Gesture complete actions
                    if (snapBack) {
                        onZoomModeChanged?.invoke(false)

                        isInGesture = false

                        scale = MAGNIFICATION_BASELINE
                        offsetX = OFFSET_X_BASELINE
                        offsetY = OFFSET_Y_BASELINE
                        rotation = ROTATION_BASELINE
                    }
                }
            }
    ) {

        fun GraphicsLayerScope.manipulateImage() {
            if (!isInGesture && animateSnapBack) {
                scaleX = snapBackScale
                scaleY = snapBackScale
                if (supportRotation) {
                    rotationZ = snapBackRotation
                }
                translationX = snapBackOffsetX
                translationY = snapBackOffsetY
            } else {
                scaleX = maxOf(MAGNIFICATION_BASELINE, minOf(magnificationScale, scale))
                scaleY = maxOf(MAGNIFICATION_BASELINE, minOf(magnificationScale, scale))
                if (supportRotation) {
                    rotationZ = rotation
                }
                translationX = offsetX
                translationY = offsetY
            }
        }
        
        if (bitmap != null) {
            androidx.compose.foundation.Image(
                bitmap = bitmap,
                contentDescription = null,
                contentScale = contentScale,
                modifier = modifier.graphicsLayer { manipulateImage() },
            )
        }
    }
}

suspend fun ScrollableState.setScrolling(value: Boolean) {
    scroll(scrollPriority = MutatePriority.PreventUserInput) {
        when (value) {
            true -> Unit
            else -> awaitCancellation()
        }
    }
}

@Immutable
object ZoomableImageDimens {
    internal const val OFFSET_X_BASELINE = 1f
    internal const val OFFSET_Y_BASELINE = 1f
    internal const val MAGNIFICATION_BASELINE = 1f
    internal const val MAGNIFICATION_SCALE_DEFAULT = 2f
    internal const val ROTATION_BASELINE = 0f
    internal const val MAGNIFICATION_THRESHOLD = 2f
}

Upvotes: 1

sdml
sdml

Reputation: 13

var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var rotation by remember { mutableFloatStateOf(0f) }    
fun Offset.rotateBy(angle: Double): Offset {
  return Offset((x*cos(angle) - y*sin(angle)).toFloat(), (x*sin(angle) + y*cos(angle)).toFloat())
}
val state = rememberTransformableState { zoomChange, offsetChange, rotChange ->
    offset = offset.rotateBy(rotChange*PI/180) + offsetChange/scale
    scale *= zoomChange; rotation += rotChange
    if (scale > 10f) scale = 10f; if (scale < .1f) scale = .1f
}
Box(modifier = Modifier.fillMaxSize().transformable(state)) {
    AsyncImage(model = yourModel, contentDescription = null,
        contentScale = ContentScale.None,
        modifier = Modifier.wrapContentSize(unbounded = true)
        .graphicsLayer(scaleX = scale, scaleY = scale, rotationZ = rotation,
        translationX = scale*offset.x, translationY = scale*offset.y)
    )
}

Upvotes: 1

Mr. Pine
Mr. Pine

Reputation: 352

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
            }
    )
}

Upvotes: 10

voghDev
voghDev

Reputation: 5791

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

Upvotes: 2

Related Questions