Custom Image Cropper using Jetpack Compose Canvas without any libarary

Hello guys, in this article we will learn to make an Image Cropper using Jetpack compose canvas API. We will impliment an image cropper class without using any third party library.

The idea is to create a basic image cropper composable function in jetpack compose where user have an image to crop and 4 cropping handles to adjust the cropping rectangle, after the cropping, the cropped image will be set inplace of original Image.

first load the image from the res folder in form of imageBitmap then save this to image variable and remember it throughout recomposition. we will be showing initially this image to the image composable and at the last this variable will be replaced with the cropped image.

// Load or pass your ImageBitmap here
val imageBitmap: ImageBitmap =
    ImageBitmap.imageResource(id = R.drawable.car)

//create an image variable to show in image composable & initialise with imageBitmap
var image by remember {
    mutableStateOf(imageBitmap)
}

Then we need to create some variables to maintain all the 4 corners of cropping handles, variables to define the state of any dragging corner and last one to check if the whole cropping rectangle is being dragged or not. To create the state of corner first we need to create a enum class for each corner.

// States/Offsets for each corner
var topLeft by remember { mutableStateOf(Offset(200f, 200f)) }
var topRight by remember { mutableStateOf(Offset(600f, 200f)) }
var bottomLeft by remember { mutableStateOf(Offset(200f, 600f)) }
var bottomRight by remember { mutableStateOf(Offset(600f, 600f)) }

// Helper enum class to keep track of corners
enum class Corner {
    TopLeft, TopRight, BottomLeft, BottomRight
}

// Track which corner or center is being dragged
var draggingCorner by remember { mutableStateOf<Corner?>(null) }
var draggingCenter by remember { mutableStateOf(false) }

after this add a BoxwithConstraint composable to show the image and canvas.

//show the image to be crop
Image(
      bitmap = image,
      contentDescription = null,
      contentScale = ContentScale.Fit,
      modifier = Modifier.fillMaxSize()
)

Now add the Canvas composable where add the PointerInput modifier to get the dragGestures lamda. In detectDragGestures function we get following lamdas

  1. onDragStart – provide a Offset where from where the first drag detected
  2. onDrag – provides( change & dragAmount) where you can get the amount and direction of drag
  3. onDragEnd – where you can do clean up after the dragging ends

in onDragStart we will check two conditons to check weather the individual corners are dragged or whole cropping rectangle is dragged. We will create a function to check which corner is near the point where the drag is started.

// Check if the touch is near a specific point
fun Offset.isNear(point: Offset, threshold: Float = 80f): Boolean {
    return (this - point).getDistance() <= threshold
}

// in onDragStart lamda
onDragStart = { offset ->
    //check which corner is being dragged or not
    draggingCorner = when {
        offset.isNear(topLeft) -> Corner.TopLeft
        offset.isNear(topRight) -> Corner.TopRight
        offset.isNear(bottomLeft) -> Corner.BottomLeft
        offset.isNear(bottomRight) -> Corner.BottomRight
        else -> null
    }

    // is the cropping rectangle itself is being dragging or not
    draggingCenter = draggingCorner == null && Rect(
        topLeft,
        bottomRight
    ).contains(offset)
}

In onDrag lamda we will first consume all the changes and based on the condition that which corner is being dragged we will increase the length of the adjacent corners of the cropping rectangle. If no individual corner is dragged then we will move the whole rectangle.

onDrag = { change, dragAmount ->
    change.consume()

    //while dragging any corner or whole rectangle, keep updating the offset of all the corners
    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 by adjusting all corners
            topLeft += dragAmount
            topRight += dragAmount
            bottomLeft += dragAmount
            bottomRight += dragAmount
        }
    }
}

At the end, do the final cleanup and set the draggingCorner Offset to zero and the draggingCenter to false.

onDragEnd = {
    draggingCorner = null
    draggingCenter = false
}

Now come inside the canvas and get the size of the rectangle to draw and show the the actual cropping rectangle using the drawRect function

// Calculate the size of the crop rectangle
val rectSize = Size(
    width = topRight.x - topLeft.x,
    height = bottomLeft.y - topLeft.y
)

// Draw the crop rectangle
drawRect(
    color = Color.Green,
    topLeft = topLeft,
    size = rectSize,
    style = Stroke(width = 4f)
)

Then we need to create a extension function to draw a circular handle at each corner. This function will be a extention on the DrawScope and expect an offset as argument which will define the location of the circle.

// Draw a handle at the specified position
fun DrawScope.drawHandle(center: Offset) {
    drawCircle(
        color = Color.Green,
        radius = 25f,
        center = center
    )
}

// Draw corner handles
drawHandle(topLeft)
drawHandle(topRight)
drawHandle(bottomLeft)
drawHandle(bottomRight)

Now create a composable function which will return us the cropped image based on the actual image and the cropped rectangle. Let’s go through how to map coordinates from the canvas crop rectangle back to the original image using an example. This process is key for ensuring that the cropped area matches the selected region on the canvas precisely, regardless of how the image was scaled to fit the canvas.

Problem Recap

When we display an image on a canvas, it often doesn’t fit perfectly. It’s either scaled down to fit (if the image is larger) or centered with padding if it doesn’t exactly match the canvas size. To crop the exact area selected by the user, we need to “reverse-engineer” the crop rectangle back to the original image’s coordinates.

Example Setup

Suppose we have:

  • Original Image Size: 408×624 pixels
  • Canvas Size: 600×600 pixels

The image is larger than the canvas, so it’s scaled down to fit within it while maintaining the aspect ratio.

+---------------------+  <-- Canvas (600x600)
|    Padding          |
|    +-----------+    |  <-- Scaled Image (391x600)
|    |  Image    |    |
|    +-----------+    |
|    Padding          |
+---------------------+

Step 1: Calculate Scale Factor and Centering Offsets

  1. Scale Factor: To fit the image within the 600×600 canvas, we calculate the scale factor based on the original dimensions:
    • Width Ratio= canvasWidth / imageWidth = 600 / 408 = 1.47
    • Height Ratio= canvasHeight / imageHeight = 600 / 624 ≈ 0.96
    The smaller ratio (0.96) is chosen as the scale factor to make sure the entire image fits without stretching. So, we scale both width and height by 0.96.
  2. Scaled Image Size:
    • displayedImageWidth = imageWidth * scaleFactor = 408 * 0.96 ≈ 391
    • displayedImageHeight = imageHeight * scaleFactor = 624 * 0.96 ≈ 600
  3. Centering Offsets:
    • Since the scaled image width (391) is less than the canvas width (408), there will be padding on both sides to center it horizontally:
      • Horizontal Offset (X): (canvasWidth - displayedImageWidth) / 2 = (408 - 391) / 2 ≈ 8
    • Vertical Offset (Y) is 0 because the image height fits the canvas height perfectly.

This results in the image being centered horizontally on the canvas with some padding on the sides.


Step 2: Define Crop Rectangle on the Canvas

Let’s say the user draws a crop rectangle on the canvas with the following coordinates:

  • Top-Left Corner of Crop Rectangle on Canvas: (200, 250)
  • Bottom-Right Corner of Crop Rectangle on Canvas: (350, 450)

Original Image  |  Canvas            |  Cropped Area (mapped to original image)
----------------+--------------------+---------------------
                |                    |
                |   +-----------+    |
                |   | Crop Area |    |  --> Extracted part matches selection
                |   +-----------+    |

Step 3: Map the Crop Rectangle to the Original Image Coordinates

To map these canvas coordinates back to the original image, we need to remove the offset and scale up by the inverse of the scale factor.

Mapping Formula

For each corner of the crop rectangle:

  1. Mapped X (for left and right coordinates): Mapped X=(Canvas X−offsetX)​/scaleFactor
  2. Mapped Y (for top and bottom coordinates): Mapped Y=(Canvas Y−offsetY)/scaleFactor

Applying to Our Example

  1. Top-Left Corner of Crop Rectangle:
    • Canvas X: 200
    • Canvas Y: 250
    • Applying the mapping formulas:
      • Mapped X: (200−8)/0.96 ≈ 200
      • Mapped Y: (250−0)/0.96 ≈ 260
    So, the top-left corner on the original image is approximately (200, 260).
  2. Bottom-Right Corner of Crop Rectangle:
    • Canvas X: 350
    • Canvas Y: 450
    • Applying the mapping formulas:
      • Mapped X: (350−8)/0.96 ≈ 356
      • Mapped Y: (450−0)/0.96 ≈ 468
    So, the bottom-right corner on the original image is approximately (356,468).

Step 4: Extract the Cropped Bitmap

With the mapped coordinates, we can now crop the original image using these dimensions:

  • cropLeft = 200
  • cropTop = 260
  • cropWidth = 356 - 200 = 156
  • cropHeight = 468 - 260 = 208

Using these values, we can accurately crop the original image to get the section selected on the canvas. This ensures that the cropped area corresponds exactly to the user’s selection on the scaled and centered image.

/**
 * 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,   // The original ImageBitmap
    cropRect: Rect,             // The crop rectangle area on the canvas
    canvasWidth: Float,         // The width of the canvas where the image is displayed
    canvasHeight: Float         // The height of the canvas where the image is displayed
): 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) // Preserve aspect ratio

    // Calculate the actual displayed image dimensions within the canvas
    val displayedImageWidth = bitmapWidth * scaleFactor
    val displayedImageHeight = bitmapHeight * scaleFactor

    // Calculate the offset to center the image within the canvas
    val offsetX = (canvasWidth - displayedImageWidth) / 2
    val offsetY = (canvasHeight - displayedImageHeight) / 2

    // Map the crop rectangle coordinates from the canvas to the original image dimensions
    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 width and height
    val cropWidth = (cropRight - cropLeft).coerceAtLeast(1)  // Ensure minimum 1px width
    val cropHeight = (cropBottom - cropTop).coerceAtLeast(1)  // Ensure minimum 1px height

    // Create a cropped bitmap from the original bitmap using the calculated rectangle
    return Bitmap.createBitmap(
        imageBitmap.asAndroidBitmap(),
        cropLeft,
        cropTop,
        cropWidth,
        cropHeight
    )
}

Now at the end add a button composable to create the cropped image on button click. Write this code in the onClick

// Save Crop button
Button(
 onClick = {
   // get the cropped image from the cropping rectangle
    val croppedBitmap = getCroppedBitmap(
        imageBitmap,
        Rect(topLeft, bottomRight),
        canvasWidth = constraints.maxWidth.toFloat(),
        canvasHeight = constraints.maxHeight.toFloat()
    )

    //set the cropped image to the composable
    image = croppedBitmap.asImageBitmap()
},
modifier = Modifier
           .align(Alignment.BottomCenter)
           .padding(16.dp)
     ) {
         Text(text = "Show Cropped Image")
}

Full Source Code on GitHub: here

Leave a Comment

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

Scroll to Top