Image cropping is a common feature in many mobile apps, especially those related to photo editing, social media, or profile customization. In this article, we will create a fully functional image cropper using Jetpack Compose, Android’s modern UI toolkit.
This guide walks you through the code, explaining each part in simple terms, so you can easily implement this feature in your app.
What We’ll Build
We’ll create an image cropper where users can:
- Drag corners or the entire cropping rectangle.
- See a green rectangle to visualize the cropping area.
- Click a button to crop the image and display the result.
Step 1: Setting Up the Image Cropper Layout
Before implementing the functionality, we need a basic layout to display the image and initialize the cropping rectangle. We also define the BoxWithConstraints
layout as the container for all components.
@Composable
fun ImageCropper() {
// Load or pass your ImageBitmap here
val imageBitmap: ImageBitmap = ImageBitmap.imageResource(id = R.drawable.image_to_crop)
// Create a mutable state for the image to update after cropping
var image by remember { mutableStateOf(imageBitmap) }
// Initialize offsets for the corners of the cropping rectangle
var topLeft by remember { mutableStateOf(Offset(400f, 400f)) }
var topRight by remember { mutableStateOf(Offset(800f, 400f)) }
var bottomLeft by remember { mutableStateOf(Offset(400f, 800f)) }
var bottomRight by remember { mutableStateOf(Offset(800f, 800f)) }
// State variables to track dragging of corners or the entire rectangle
var draggingCorner by remember { mutableStateOf<Corner?>(null) }
var draggingCenter by remember { mutableStateOf(false) }
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
// Display the image to crop
Image(
bitmap = image,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize()
)
}
}
Explanation:
ImageBitmap
:- The
imageBitmap
is the image we want to crop. It is loaded usingImageBitmap.imageResource
. ReplaceR.drawable.image_to_crop
with the actual resource ID of your image.
- The
- State for Image:
- The
image
state holds the current image to display. This will later be updated with the cropped image.
- The
- Offset Variables:
- We define four
Offset
states (topLeft
,topRight
,bottomLeft
,bottomRight
) to represent the corners of the cropping rectangle. These offsets will be dynamically updated during dragging.
- We define four
- Dragging State:
draggingCorner
keeps track of which corner is being dragged.draggingCenter
determines if the entire rectangle is being dragged.
BoxWithConstraints
:- Acts as the parent layout, centering the content and defining the space available for cropping.
- The
Image
Composable displays the image to crop, filling the available space.
Step 2: Adding Dragging Gestures for Cropping Rectangle
Now let’s handle user gestures to allow dragging of the corners or the entire cropping rectangle. The code to add dragging gestures and dragging corner is same that we will look into next step.
Step 3: Adding Corner Handles for Better User Interaction
To make it easier for users to drag the corners, we’ll add visual handles at each corner of the cropping rectangle. These handles will act as clear indicators for users to interact with.
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
// Detect dragging start for corners or rectangle center
draggingCorner = when {
offset.isNear(topLeft) -> Corner.TopLeft
offset.isNear(topRight) -> Corner.TopRight
offset.isNear(bottomLeft) -> Corner.BottomLeft
offset.isNear(bottomRight) -> Corner.BottomRight
else -> null
}
draggingCenter = draggingCorner == null && Rect(topLeft, bottomRight).contains(offset)
},
onDrag = { change, dragAmount ->
change.consume()
// Update offsets for corners or move the rectangle
when (draggingCorner) {
Corner.TopLeft -> {
topLeft += dragAmount
topRight = topRight.copy(y = topLeft.y)
bottomLeft = bottomLeft.copy(x = topLeft.x)
}
Corner.TopRight -> {
topRight += dragAmount
topLeft = topLeft.copy(y = topRight.y)
bottomRight = bottomRight.copy(x = topRight.x)
}
Corner.BottomLeft -> {
bottomLeft += dragAmount
topLeft = topLeft.copy(x = bottomLeft.x)
bottomRight = bottomRight.copy(y = bottomLeft.y)
}
Corner.BottomRight -> {
bottomRight += dragAmount
topRight = topRight.copy(x = bottomRight.x)
bottomLeft = bottomLeft.copy(y = bottomRight.y)
}
null -> if (draggingCenter) {
// Move the entire rectangle
topLeft += dragAmount
topRight += dragAmount
bottomLeft += dragAmount
bottomRight += dragAmount
}
}
},
onDragEnd = {
draggingCorner = null
draggingCenter = false
}
)
}
) {
// Draw the cropping rectangle
val rectSize = Size(
width = topRight.x - topLeft.x,
height = bottomLeft.y - topLeft.y
)
drawRect(
color = Color.Green,
topLeft = topLeft,
size = rectSize,
style = Stroke(width = 4f)
)
// Draw handles at each corner
val handleRadius = 20f
listOf(topLeft, topRight, bottomLeft, bottomRight).forEach { corner ->
drawCircle(
color = Color.Red,
center = corner,
radius = handleRadius
)
}
}
Explanation:
- Canvas:
- We use a
Canvas
to handle custom drawing (like the cropping rectangle) and detect gestures.
- We use a
detectDragGestures
:- The
detectDragGestures
modifier is added to listen for drag gestures:onDragStart
: Detects which corner or center is being dragged.onDrag
: Updates offsets to resize or reposition the rectangle.onDragEnd
: Resets dragging states when the user lifts their finger.
- The
- Dragging Logic:
- Each corner’s position is updated based on the drag amount. For example, dragging the
TopLeft
corner also adjusts theTopRight
(y-axis) andBottomLeft
(x-axis) to maintain the rectangle’s shape. - If the entire rectangle is dragged, all corners are moved by the same amount.
- Each corner’s position is updated based on the drag amount. For example, dragging the
- Drawing the Rectangle:
- The
drawRect
function draws the cropping rectangle using the calculated size (rectSize
).
- The
- Drawing Handles:
- We added
drawCircle
calls to render small circles (handles) at each corner of the cropping rectangle. The radius of the handles is set to20f
, and the color is red for better visibility. - List of Corners:
- A
listOf
containing the four corner offsets (topLeft
,topRight
,bottomLeft
,bottomRight
) is used to iterate and draw handles at the appropriate positions. - Pointer Input for Handles:
- The gesture detection logic remains the same. Handles serve as visual indicators but also act as draggable regions because of the
isNear
function. - Improved Usability:
- The handles provide a clear indication of where the user can interact with the cropping rectangle, enhancing the UI’s intuitiveness.
Step 4: Cropping the Image Based on the Rectangle
Once the user has positioned the cropping rectangle as desired, we need to crop the image within that rectangle. This step involves mapping the rectangle’s coordinates from the canvas to the image dimensions and creating a new cropped bitmap.
/**
* Extracts the cropped area from the original image based on the given crop rectangle.
*
* @param imageBitmap The original image bitmap.
* @param cropRect The crop rectangle in canvas coordinates.
* @param canvasWidth The width of the canvas.
* @param canvasHeight The height of the canvas.
* @return A Bitmap representing the cropped area.
*/
fun getCroppedBitmap(
imageBitmap: ImageBitmap,
cropRect: Rect,
canvasWidth: Float,
canvasHeight: Float
): Bitmap {
val bitmapWidth = imageBitmap.width.toFloat()
val bitmapHeight = imageBitmap.height.toFloat()
// Calculate scaling factors to fit the image within the canvas
val widthRatio = canvasWidth / bitmapWidth
val heightRatio = canvasHeight / bitmapHeight
val scaleFactor = min(widthRatio, heightRatio) // Maintain aspect ratio
// Calculate the actual displayed dimensions of the image on the canvas
val displayedImageWidth = bitmapWidth * scaleFactor
val displayedImageHeight = bitmapHeight * scaleFactor
// Calculate offsets to center the image on the canvas
val offsetX = (canvasWidth - displayedImageWidth) / 2
val offsetY = (canvasHeight - displayedImageHeight) / 2
// Map crop rectangle coordinates from canvas space to image space
val cropLeft = ((cropRect.left - offsetX) / scaleFactor).roundToInt().coerceIn(0, bitmapWidth.toInt())
val cropTop = ((cropRect.top - offsetY) / scaleFactor).roundToInt().coerceIn(0, bitmapHeight.toInt())
val cropRight = ((cropRect.right - offsetX) / scaleFactor).roundToInt().coerceIn(0, bitmapWidth.toInt())
val cropBottom = ((cropRect.bottom - offsetY) / scaleFactor).roundToInt().coerceIn(0, bitmapHeight.toInt())
// Calculate the cropped area's width and height
val cropWidth = (cropRight - cropLeft).coerceAtLeast(1)
val cropHeight = (cropBottom - cropTop).coerceAtLeast(1)
// Create a cropped bitmap from the original bitmap using the calculated coordinates
return Bitmap.createBitmap(
imageBitmap.asAndroidBitmap(),
cropLeft,
cropTop,
cropWidth,
cropHeight
)
}
Explanation:
- Scaling Factors:
- The
widthRatio
andheightRatio
represent how much the original image is scaled down to fit into the canvas. The smaller of these two ratios is used as thescaleFactor
to maintain the image’s aspect ratio.
- The
- Mapping Canvas Coordinates to Image Coordinates:
- The rectangle coordinates (
cropRect
) are initially in the canvas’s coordinate space. Using the scaling factor and offsets, we map these to the original image’s dimensions.
- The rectangle coordinates (
- Coerce to Valid Bounds:
- The calculated crop coordinates are clamped within the bounds of the original image using
coerceIn
. This ensures we don’t access pixels outside the image dimensions.
- The calculated crop coordinates are clamped within the bounds of the original image using
- Creating the Cropped Bitmap:
Bitmap.createBitmap
is used to extract the cropped area from the original image bitmap based on the mapped coordinates.
- Return Value:
- The method returns a new
Bitmap
that represents the cropped portion of the original image.
- The method returns a new
This function is called when the user taps the “Show Cropped Image” button, as shown below:
Button(
onClick = {
val croppedBitmap = getCroppedBitmap(
imageBitmap,
Rect(topLeft, bottomRight),
canvasWidth = constraints.maxWidth.toFloat(),
canvasHeight = constraints.maxHeight.toFloat()
)
// Update the UI with the cropped image
image = croppedBitmap.asImageBitmap()
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
) {
Text(text = "Show Cropped Image")
}
Step 5: Putting It All Together
Now that we’ve gone through the entire code piece by piece, let’s summarize how the different parts work together to create a fully functional image cropper in Jetpack Compose.
How It Works
- Display the Image:
- The image is loaded and displayed using the
Image
composable, ensuring it fits properly within the screen dimensions.
- The image is loaded and displayed using the
- Draw the Cropping Rectangle:
- A
Canvas
is layered over the image, where the cropping rectangle is drawn using the user-adjusted corner coordinates. Green handles are added at each corner for visual clarity.
- A
- User Interaction:
- Using
detectDragGestures
, the user can drag individual corners of the rectangle or the entire rectangle itself. This interaction dynamically updates the rectangle’s size and position.
- Using
- Cropped Image Generation:
- When the “Show Cropped Image” button is pressed, the cropping rectangle is mapped from canvas space to the original image’s dimensions. The cropped portion is extracted using
Bitmap.createBitmap()
and displayed back to the user.
- When the “Show Cropped Image” button is pressed, the cropping rectangle is mapped from canvas space to the original image’s dimensions. The cropped portion is extracted using
Key Takeaways
- Clean Design: The
Canvas
composable is perfect for handling custom drawing and user interactions in Jetpack Compose. - Dynamic Gesture Handling: The
detectDragGestures
function allows precise control over user interactions, enabling flexible behavior for corner dragging or moving the rectangle. - Image Transformation: By mapping coordinates between the canvas and the original image, we ensure that the cropping works regardless of the image scaling or aspect ratio.
- Performance: The use of
mutableStateOf
ensures that the UI reacts instantly to user interactions.
Full Code Summary
Here’s a brief rundown of the code structure:
- Display the Image:
- Load the image using
ImageBitmap.imageResource
and show it using theImage
composable.
- Load the image using
- Track State:
- Use
mutableStateOf
to keep track of the cropping rectangle corners (topLeft
,topRight
,bottomLeft
,bottomRight
).
- Use
- Canvas with Cropping Rectangle:
- Draw the rectangle and handle dragging gestures.
- Cropping Logic:
- Map the canvas rectangle to the original image’s dimensions, extract the cropped bitmap, and update the UI.
- User-Friendly Button:
- The button triggers the cropping process and displays the cropped image.
Final Thoughts
This approach demonstrates how Jetpack Compose simplifies UI development by combining custom drawing (Canvas
) with powerful state management and gesture handling. You can expand this example to include more features like:
- Adding a reset button to revert the rectangle to its default size.
- Allowing users to rotate the image before cropping.
- Saving the cropped image to the device storage.
Complete Code on GitHub Gist
You can find the full source code for this image cropper here on GitHub Gist.
Feel free to modify the code and integrate it into your projects! Let me know if you need additional enhancements or explanations.