Android compose Canvas scale image with gesture offset its far away from image

I created like crop image, to circle with canvas, it works well on crop rotation, zoom, and moving the image, but the problem comes when I try to move the image to full right or left, while on scale 1,4 the offset of the photo too much, so the image not inside the circle anymore, but when I scale more big number like 3 its more far away from the image, I want to make user when the moving image its not going to offset too far until completely not in the circle, but I still want the user can reach the edge of the image.

This the Compose file

@Composable
fun ZoomableImage(
    modifier: Modifier = Modifier,
    imageUrl: Uri,
    maxWidthVal: Int = 0,
    maxHeightVal: Int = 0,
) {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset(0f, 0f)) }
    var rotation by remember { mutableStateOf(0f) }
    var croppedImage: Bitmap? by remember { mutableStateOf(null) }
    val minScale = 1f
    val maxScale = 3f
    var canvasSize by remember { mutableStateOf(Size(0f, 0f)) }

    var originalImageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }

    val context = LocalContext.current
    val imageLoader = ImageLoader(context)

    val request = ImageRequest.Builder(context)
        .allowHardware(false)
        .data(imageUrl)
        .target { result ->
            originalImageBitmap = result.current.toBitmap().asImageBitmap()
        }
        .build()

    LaunchedEffect(request) {
        withContext(Dispatchers.IO) {
            imageLoader.execute(request)
        }
    }

    Box(
        modifier = modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, rotationChange ->
                    // Zoom
                    scale *= zoom
                    scale = scale.coerceIn(minScale, maxScale)
                    // Pan (move)
                    offset = if (scale > minScale) {
                        val offsetX = (offset.x + pan.x * scale).coerceIn(
                            -maxWidthVal * (scale - 1),
                            maxWidthVal * (scale - 1)
                        )
                        val offsetY = (offset.y + pan.y * scale).coerceIn(
                            -maxHeightVal * (scale - 1),
                            maxHeightVal * (scale - 1)
                        )
                        Log.d("CropImage", "ZoomableImage: move $offsetX, $offsetY scale $scale")
                        Offset(offsetX, offsetY)
                    } else {
                        Offset(0f, 0f)
                    }
                    // Rotation cap to 360 degrees, cause if we spin more its big than 360
                    // make canvas fail to build up so we mod by 360
                    rotation = (rotation + rotationChange) % 360
                    Log.d("CropImage", "ZoomableImage: rotation $rotation")
                    croppedImage = null
                }
            }
            .background(MaterialTheme.colorScheme.onSurface),
        contentAlignment = Alignment.Center
    ) {
        if (croppedImage == null) {
            if (originalImageBitmap == null) return
            Canvas(
                modifier = Modifier
                    .fillMaxSize()
                    .onGloballyPositioned {
                        canvasSize = Size(it.size.width.toFloat(), it.size.height.toFloat())
                    }
                    .clickable {
                        croppedImage = getCroppedBitmap(
                            originalImageBitmap!!.asAndroidBitmap(),
                            scale,
                            scale,
                            offset.x,
                            offset.y,
                            rotation,
                            canvasSize
                        )
                    },
                onDraw = {
                    val circlePath = Path().apply {
                        addOval(Rect(center, radius = size.width / 2))
                    }

                    val centerX = center.x - ((originalImageBitmap!!.width) / 2) + offset.x // calculate to make photo center
                    val centerY = center.y - ((originalImageBitmap!!.height) /  2) + offset.y // calculate to make photo center

                    rotate(rotation, Offset(center.x, center.y)) {
                        scale(scale, Offset(center.x, center.y)) {
                            drawImage(originalImageBitmap!!, topLeft = Offset(centerX, centerY))
                        }
                    }

                    // Draw the white border
                    val borderPaint = Paint().asFrameworkPaint()
                    borderPaint.color = Color.White.toArgb()
                    // borderPaint.style = Paint().style.Stroke
                    borderPaint.strokeWidth = 4f // Adjust the width as needed

                    drawPath(
                        circlePath,
                        color = Color.White,
                        colorFilter = ColorFilter.tint(Color.White),
                        style = Stroke(4f)
                    )

                    clipPath(circlePath, clipOp = ClipOp.Difference) {
                        drawRect(SolidColor(Color.Black.copy(alpha = 0.8f)))
                    }
                })
        }
    }

    croppedImage?.let {
        Image(
            painter = rememberAsyncImagePainter(it),
            contentDescription = null,
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color.Blue)
                .clickable { croppedImage = null },
        )
    }
}

This function for crop image to circle base on the position of image

fun getCroppedBitmap(
    bitmap: Bitmap,
    scaleX: Float,
    scaleY: Float,
    translationX: Float,
    translationY: Float,
    rotationZ: Float,
    canvasSize: Size,
): Bitmap {
    Log.d("CropImage", "ZoomableImage: scale: $scaleX, translationX: $translationX, translationY: $translationY, rotationZ: $rotationZ, canvasSize: $canvasSize")
    val center = Offset(canvasSize.width.toFloat() / 2, canvasSize.height.toFloat() / 2)
    val circle = Rect(center = Offset(0f, 0f), radius = 500f)
    val output = Bitmap.createBitmap(
        canvasSize.width.toInt(),
        canvasSize.height.toInt(),
        Bitmap.Config.ARGB_8888
    )
    val canvas = Canvas(output.asImageBitmap())
    val color = -0xbdbdbe
    val paint = Paint()
    paint.isAntiAlias = true
    paint.color = Color(color = color)
    val radius = canvasSize.width / 2
    val scaleValue = canvasSize.height / bitmap.height
    Log.d("CropImage", "getCroppedBitmap: original radius ${canvasSize.width / 2} with size $canvasSize, new radius: $radius")
    Log.d("CropImage", "getCroppedBitmap: scaleValue: $scaleValue")
    Log.d("CropImage", "getCroppedBitmap: translationX: ${translationX / scaleValue}, translationY: ${translationY / scaleValue}")
    canvas.drawCircle(
        center = center, radius = radius,
        paint = paint
    )


    canvas.save()
    canvas.rotate(rotationZ, center.x, center.y)
    canvas.scale(scaleX, scaleY, center.x, center.y)
    val imageBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true).asImageBitmap()
    var offsetX = center.x - ((bitmap.width) /  2) // calculate to make photo center
    var offsetY = center.y - ((bitmap.height) /  2 ) // calculate to make photo center
    offsetX += (translationX)
    offsetY += (translationY)

    paint.blendMode = BlendMode.SrcIn
    Log.d("CropImage", "getCroppedBitmap: offsetX: $offsetX offsetY: $offsetY")
    canvas.save()
    canvas.drawImage(imageBitmap, Offset(offsetX, offsetY), paint)


    canvas.restore()
    return output
}

This is how to call the view

ZoomableImage(
     imageUrl = "https://picsum.photos/seed/picsum/2000/1500".toUri(),
     maxWidthVal = 2000,
     maxHeightVal = 1500
)

its my math wrong or am I doing it the wrong way, if you suggest using lib, we can’t do it cause we plan to build something different, and this one is a basic feature that needs to work on it

Best Regards

Leave a Comment