Drag Image to the bounds of the Box - Jetpack Compose

Viewed 1047

I'm trying to create a Zoomable Image in Jetpack Compose. I've enabled zoom in/out functionality, but I'm not sure how should I set the limit on translationX property so that I cannot move the image horizontally outside of the Box bounds? Any solutions?

enter image description here

Example:

@Composable
fun ZoomableImage(
    painter: Painter
) {
    val scale = remember { mutableStateOf(1f) }
    var offsetX by remember { mutableStateOf(0f) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colors.welcomeScreenBackgroundColor)
            .pointerInput(Unit) {
                detectTransformGestures { centroid, pan, zoom, rotation ->
                    scale.value *= zoom
                }
            },
        contentAlignment = Alignment.Center
    ) {
        Image(
            modifier = Modifier
                .pointerInput(Unit) {
                    detectHorizontalDragGestures { change, dragAmount ->
                        offsetX += dragAmount
                    }
                }
                .graphicsLayer(
                    translationX = offsetX,
                    scaleX = maxOf(1f, minOf(3f, scale.value)),
                    scaleY = maxOf(1f, minOf(3f, scale.value))
                ),
            contentDescription = "Image",
            painter = painter,
            contentScale = ContentScale.Fit
        )
    }
}
2 Answers

I'm not sure if this is a good idea, but you could use onPlace.

And a WIP example:

@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
fun ZoomableImage(
    painter: Painter,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    onLongPress: ((Offset) -> Unit)? = null,
    onTap: ((Offset) -> Unit)? = null
) {
    val scope = rememberCoroutineScope()

    var layout: LayoutCoordinates? = null

    var scale by remember { mutableStateOf(1f) }
    var translation by remember { mutableStateOf(Offset.Zero) }
    val transformableState = rememberTransformableState { zoomChange, panChange, _ ->
        scale *= zoomChange
        translation += panChange.times(scale)
    }

    Box(
        modifier = modifier
            .clipToBounds()
            .transformable(state = transformableState)
            .pointerInput(Unit) {
                detectTapGestures(
                    onLongPress = onLongPress,
                    onDoubleTap = {
                        val maxScale = 2f
                        val midScale = 1.5f
                        val minScale = 1f
                        val targetScale = when {
                            scale >= minScale -> midScale
                            scale >= midScale -> maxScale
                            scale >= maxScale -> minScale
                            else -> minScale
                        }
                        scope.launch {
                            transformableState.animateZoomBy(targetScale / scale)
                        }
                    },
                    onTap = onTap
                )
            }
            .pointerInput(Unit) {
                forEachGesture {
                    awaitPointerEventScope {
                        val down = awaitFirstDown(requireUnconsumed = false)
                        drag(down.id) {
                            if (layout == null) return@drag
                            val maxX = layout!!.size.width * (scale - 1) / 2f
                            val maxY = layout!!.size.height * (scale - 1) / 2f
                            val targetTranslation = (it.positionChange() + translation)
                            if (targetTranslation.x > -maxX && targetTranslation.x < maxX &&
                                targetTranslation.y > -maxY && targetTranslation.y < maxY
                            ) {
                                translation = targetTranslation
                                it.consumePositionChange()
                            }
                        }
                    }
                }
            }
    ) {
        Image(
            painter = painter,
            contentDescription = contentDescription,
            modifier = Modifier
                .matchParentSize()
                .onPlaced { layout = it }
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    translationX = translation.x,
                    translationY = translation.y
                ),
            contentScale = ContentScale.Fit
        )

        LaunchedEffect(transformableState.isTransformInProgress) {
            if (!transformableState.isTransformInProgress) {
                if (scale < 1f) {
                    val originScale = scale
                    val originTranslation = translation
                    AnimationState(initialValue = 0f).animateTo(
                        1f,
                        SpringSpec(stiffness = Spring.StiffnessLow)
                    ) {
                        scale = originScale + (1 - originScale) * this.value
                        translation = originTranslation * (1 - this.value)
                    }
                } else {
                    if (layout == null) return@LaunchedEffect
                    val maxX = layout!!.size.width * (scale - 1) / 2f
                    val maxY = layout!!.size.height * (scale - 1) / 2f
                    val target = Offset(
                        translation.x.coerceIn(-maxX, maxX),
                        translation.y.coerceIn(-maxY, maxY)
                    )
                    AnimationState(
                        typeConverter = Offset.VectorConverter,
                        initialValue = translation
                    ).animateTo(target, SpringSpec(stiffness = Spring.StiffnessLow)) {
                        translation = this.value
                    }
                }
            }
        }
    }
}

If you only need to constraint pan inside your image instead of animating back to desired levels it's very simple. Constraint it using your Composable size and zoom level as

val maxX = (size.width * (zoom - 1) / 2f)
val maxY = (size.height * (zoom - 1) / 2f)

offset = newOffset
offset = Offset(
    newOffset.x.coerceIn(-maxX, maxX),
    newOffset.y.coerceIn(-maxY, maxY)

-max, +max is the range and you set it between -max to +max when your TransformOrigin for graphicsLayer is (0.5,0.5) which is the default value. Be mindful about moving range when you change TransformOrigin

Full Implementation

States

var zoom by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var size by remember { mutableStateOf(IntSize.Zero) }

Modifier

val imageModifier = Modifier
        .fillMaxSize()
        .aspectRatio(4/3f)
        .clipToBounds()
        .pointerInput(Unit) {
            detectTransformGestures(
                onGesture = { _, gesturePan, gestureZoom, _ ->

                val newScale = (zoom * gestureZoom).coerceIn(1f, 3f)
                val newOffset = offset + gesturePan
                zoom = newScale


                val maxX = (size.width * (zoom - 1) / 2f)
                val maxY = (size.height * (zoom - 1) / 2f)

                offset = Offset(
                    newOffset.x.coerceIn(-maxX, maxX),
                    newOffset.y.coerceIn(-maxY, maxY)
                )
                }
            )
        }
        .onSizeChanged {
            size = it
        }
        .graphicsLayer {
            translationX = offset.x
            translationY = offset.y
            scaleX = zoom
            scaleY = zoom
        }
Related