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