Android Jetpack Compose Image Crop Simply

Alparslan Güney
5 min readAug 6, 2024

--

Hello, in this post, I will explain how to crop a photo using Android Jetpack Compose. First, note that the project and code in this post are only to show you how to perform the crop operation in the simplest way. I hope it helps and guides you.

If you wanted to crop a physical photo, what would you do? You would probably take the photo in your hand and cut the desired part with scissors. We are going to do something almost the same with code. We will take our target photo and create another photo with a specific part of it. The first thing we need to do is to draw our target photo in the center of the Canvas.

val image = ImageBitmap.imageResource(id = R.drawable.cat)

Canvas(
modifier = Modifier.fillMaxSize()
) {

val canvasWidth = size.width
val canvasHeight = size.height

drawImage(
image = image,
dstOffset = IntOffset(
x = canvasWidth.div(2)
.minus(image.width.div(2)).toInt(),
y = canvasHeight.div(2)
.minus(image.height.div(2)).toInt()
)
)
}

Yes, drawing a photo with Canvas is that easy. Now, what we need is to be able to move our photo. To do this, we need to detect movements such as scrolling and zooming in/out on the screen. So, let’s create a Box to detect the movement across the entire surface and use the pointerInput method.

Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, rotation ->
/* no-op */
}
}
) {
Canvas(
modifier = Modifier
.fillMaxSize()
) {

val canvasWidth = size.width
val canvasHeight = size.height

drawImage(
image = image,
dstOffset = IntOffset(
x = canvasWidth.div(2)
.minus(image.width.div(2)).toInt(),
y = canvasHeight.div(2)
.minus(image.height.div(2)).toInt()
)
)
}
}

If you apply the code above, you’ll see that when you scroll and zoom on the screen, it returns parameters such as pan, zoom, and rotation. This is exactly what we need. First, let’s store these values using remember, because we will need them. Later, we will use the withTransform method inside the Canvas to apply scaling and translation to the photo we draw.

var scale by remember {
mutableFloatStateOf(1f)
}

var transform by remember {
mutableStateOf(Offset(0f, 0f))
}

BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
val constraintWidth = constraints.maxWidth
val constraintHeight = constraints.maxHeight
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = scale.times(zoom)
transform = Offset(
transform.x.plus(pan.x),
transform.y.plus(pan.y)
)
}
}
) {

val canvasWidth = size.width
val canvasHeight = size.height

Canvas(
modifier = Modifier
.fillMaxSize()
) {
withTransform(
{
translate(
left = transform.x,
top = transform.y
)
scale(
scaleX = scale,
scaleY = scale
)
}
) {
drawImage(
image = image,
dstOffset = IntOffset(
x = canvasWidth.div(2)
.minus(image.width.div(2)).toInt(),
y = canvasHeight.div(2)
.minus(image.height.div(2)).toInt()
)
)
}
}
}
}

What we did above is simply shaping the photo with the values we obtained. Yes, it’s quite simple. Now we have a photo that we can move. Next, we need to draw a rectangle that will define the area where we want to crop. This rectangle will show exactly where we will cut the photo. To do this, let’s first define the size of the cropping area outside of the Canvas block.

val density = LocalDensity.current.density

val cropZoneSize = remember {
Size(
width = 215.times(density),
height = 275.times(density)
)
}

BoxWithConstraints(
modifier = modifier.fillMaxWidth()
) {
val constraintWidth = constraints.maxWidth
val constraintHeight = constraints.maxHeight

val rectDraw = remember {
Rect(
offset = Offset(
x = constraintWidth.div(2)
.minus(cropZoneSize.width.div(2)),
y = constraintHeight.div(2)
.minus(cropZoneSize.height.div(2))
),
size = cropZoneSize
)
}
Box
Canvas
...
}

Then we can create our cropping area within the Canvas. To do this, add the following lines under the drawImage

with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
drawRect(Color(0x75000000))
drawRoundRect(
topLeft = rectDraw.topLeft,
size = rectDraw.size,
cornerRadius = CornerRadius(30f, 30f),
color = Color.Transparent,
blendMode = BlendMode.Clear
)
restoreToCount(checkPoint)
}

What we did above is draw a semi-transparent black rectangle that covers the entire canvas, and then draw another rectangle on top of it that represents the area we want to crop. To make the cropping area visible, we use BlendMode.Clear to cut out the area below this rectangle.

After applying all this code, you should have a movable photo and a rectangle showing the cropping area. Now, it’s time to crop the photo. To do this, let’s add a button that will appear at the bottom of the screen, and write the crop operations in its onClick method.

val scope = rememberCoroutineScope()

Button(
modifier = modifier,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent
),
shape = MaterialTheme.shapes.small,
content = {
// button content
},
onClick = {
coroutineScope.launch {
val matrix = Matrix()

matrix.postScale(
scale,
scale
)

val scaledBitmap = Bitmap.createBitmap(
targetBitmap.asAndroidBitmap(),
0,
0,
targetBitmap.width,
targetBitmap.height,
matrix,
true
)

val cropX = scaledBitmap.width.minus(cropSize.width).div(2).minus(transform.x).toInt()
val cropY = scaledBitmap.height.minus(cropSize.height).div(2).minus(transform.y).toInt()

val croppedBitmap = Bitmap.createBitmap(
scaledBitmap,
cropX,
cropY,
cropSize.width.toInt(),
cropSize.height.toInt()
)
}
}
)

When you click the button we added above, you will get a cropped bitmap. What we did earlier was to scale the photo to its zoomed state using the value from the scale variable and recreate it, then crop the scaled image to the size of the crop area. While doing this, we determine which part of the image to crop using cropX and cropY. To find the cropX and cropY values, we actually locate the exact center of the cropping area on the image and then shift it by the transform.x and transform.y values that we detected with input.

We have one remaining issue: if you try to crop outside the image’s crop zone, you might encounter an exception like java.lang.IllegalArgumentException: x must be >= 0. This happens because we are trying to create a bitmap that does not fit within the available area. To solve this, we need to ensure that the image does not move outside the crop zone. To do this, we need to adjust our code to account for the width and height of the crop zone compared to the width and height of the image. Subtracting the crop zone's dimensions from the image's dimensions will give us the maximum area we can move. With half of this value, we can find the maximum area we can transform from the right and left sides.

detectTransformGestures { _, pan, zoom, _ ->

val transformRestrictionTransformXMax = image.width.times(scale).minus(cropZoneSize.width).div(2)
val transformRestrictionTransformXMin = transformRestrictionTransformXMax.unaryMinus()

val transformRestrictionTransformYMax = image.height.times(scale).minus(cropZoneSize.height).div(2)
val transformRestrictionTransformYMin = transformRestrictionTransformYMax.unaryMinus()

scale = scale
.times(zoom)
.coerceIn(
minimumValue = 1f,
maximumValue = 10f
)
transform = Offset(
transform.x
.plus(pan.x)
.coerceIn(
minimumValue = transformRestrictionTransformXMin,
maximumValue = transformRestrictionTransformXMax
),
transform.y
.plus(pan.y)
.coerceIn(
minimumValue = transformRestrictionTransformYMin,
maximumValue = transformRestrictionTransformYMax
)
)
}

I’m sorry for the long text :) I tried to be as detailed as possible. I have shared the complete code on my GitHub account; you can find the link below. Feel free to provide feedback about my code. I hope it has been helpful. To test it, simply clone and run the project.

https://github.com/alparslanguney/composecropper

--

--