Image Cropper in Jetpack Compose Using Canvas | No Third-Party Libraries | Android Tutorial

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:

  1. ImageBitmap:
    • The imageBitmap is the image we want to crop. It is loaded using ImageBitmap.imageResource. Replace R.drawable.image_to_crop with the actual resource ID of your image.
  2. State for Image:
    • The image state holds the current image to display. This will later be updated with the cropped image.
  3. 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.
  4. Dragging State:
    • draggingCorner keeps track of which corner is being dragged.
    • draggingCenter determines if the entire rectangle is being dragged.
  5. 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:

  1. Canvas:
    • We use a Canvas to handle custom drawing (like the cropping rectangle) and detect gestures.
  2. 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.
  3. Dragging Logic:
    • Each corner’s position is updated based on the drag amount. For example, dragging the TopLeft corner also adjusts the TopRight (y-axis) and BottomLeft (x-axis) to maintain the rectangle’s shape.
    • If the entire rectangle is dragged, all corners are moved by the same amount.
  4. Drawing the Rectangle:
    • The drawRect function draws the cropping rectangle using the calculated size (rectSize).
  5. Drawing Handles:
  6. We added drawCircle calls to render small circles (handles) at each corner of the cropping rectangle. The radius of the handles is set to 20f, and the color is red for better visibility.
  7. List of Corners:
  8. A listOf containing the four corner offsets (topLeft, topRight, bottomLeft, bottomRight) is used to iterate and draw handles at the appropriate positions.
  9. Pointer Input for Handles:
  10. The gesture detection logic remains the same. Handles serve as visual indicators but also act as draggable regions because of the isNear function.
  11. Improved Usability:
  12. 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:

  1. Scaling Factors:
    • The widthRatio and heightRatio represent how much the original image is scaled down to fit into the canvas. The smaller of these two ratios is used as the scaleFactor to maintain the image’s aspect ratio.
  2. 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.
  3. 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.
  4. Creating the Cropped Bitmap:
    • Bitmap.createBitmap is used to extract the cropped area from the original image bitmap based on the mapped coordinates.
  5. Return Value:
    • The method returns a new Bitmap that represents the cropped portion of the original image.

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

  1. Display the Image:
    • The image is loaded and displayed using the Image composable, ensuring it fits properly within the screen dimensions.
  2. 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.
  3. 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.
  4. 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.

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:

  1. Display the Image:
    • Load the image using ImageBitmap.imageResource and show it using the Image composable.
  2. Track State:
    • Use mutableStateOf to keep track of the cropping rectangle corners (topLeft, topRight, bottomLeft, bottomRight).
  3. Canvas with Cropping Rectangle:
    • Draw the rectangle and handle dragging gestures.
  4. Cropping Logic:
    • Map the canvas rectangle to the original image’s dimensions, extract the cropped bitmap, and update the UI.
  5. 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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top