Introducing Crayfish: An Image Cropper for Compose Multiplatform

skydovesJaewoong Eum (skydoves)||14 min read

Introducing Crayfish: An Image Cropper for Compose Multiplatform

Sooner or later, almost every app lets a user pick a photo and trim it: a profile picture, a cover image, a receipt, a product shot. On Android that has usually meant reaching for a View based cropper, launching a separate Activity, and getting a bitmap back through onActivityResult, none of which fits a screen written in Compose, and none of which runs on iOS, desktop, or the web. Crayfish is an image cropper built on Compose Multiplatform, so the same few lines crop a photo on Android, iOS, desktop, and the web. It crops a Painter and hands one back, so an image that is already on screen goes straight into a crop and straight back into an Image.

In this article, you'll explore Crayfish from the outside in: the one line dialog, the Cropper composable and its state, the three kinds of crop source, the knobs for aspect ratios, gestures, styles, and shapes, the recipe that makes a crop editable later, and the modules that connect Crayfish to Activities, Coil, and Landscapist.

AndroidiOSDesktop
Crayfish on AndroidCrayfish on iOSCrayfish on Desktop

Getting started: One dependency for every target

On an Android project, Crayfish is a single dependency:

dependencies {
    implementation("com.github.skydoves:crayfish:0.1.1")
}

On a Kotlin Multiplatform project, the same artifact goes into commonMain, and Gradle picks the right variant for each target:

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("com.github.skydoves:crayfish:0.1.1")
        }
    }
}

Crayfish publishes android, desktop for the JVM, iosArm64, iosSimulatorArm64, macosArm64, and wasmJs. It carries no native code and depends on nothing beyond Compose and kotlinx.coroutines, so there is nothing to align for the Play Store's 16 KB page size requirement and no HTTP client or image loader pulled into your dependency graph. The cropper you write in commonMain is the cropper every platform gets.

The one line crop: A dialog you call from a coroutine

The fastest way to crop is rememberImageCropper. It gives you a cropper you can call from a coroutine, and ImageCropperDialog shows it whenever a crop is in progress:

val cropper = rememberImageCropper()
val scope = rememberCoroutineScope()
var cropped by remember { mutableStateOf<ImageBitmap?>(null) }

Button(
  onClick = {
    scope.launch {
      when (val result = cropper.cropToImage(source)) {
        is CropImage.Success -> cropped = result.image
        is CropImage.Cancelled -> Unit
        is CropImage.Failure -> showError(result.reason)
      }
    }
  },
) {
  Text("Crop a photo")
}

ImageCropperDialog(cropper)

Notice the shape of the call site. cropToImage suspends until the user confirms or cancels, so the whole interaction reads top to bottom as a single when, with no callback registered somewhere else and no result code to match. Cancelling the calling coroutine dismisses the dialog, so a screen that leaves composition takes its crop with it.

The result is an ImageBitmap, which means drawing it is the ordinary Image composable:

cropped?.let { image ->
  Image(
    bitmap = image,
    contentDescription = "Cropped photo",
    modifier = Modifier.size(120.dp).clip(CircleShape),
  )
}

Nothing is encoded on that path, so there is no decode step between the crop and the screen. CropImage.Success.painter carries the same pixels for anything that takes a Painter. When the crop is headed for an upload or a file instead, crop returns encoded bytes:

when (val result = cropper.crop(CropSource.FilePath(path))) {
  is CropResult.Success -> upload(result.bytes)
  is CropResult.Cancelled -> Unit
  is CropResult.Failure -> showError(result.reason)
}

The dialog takes the same styling as the full composable, so a circular avatar crop stays a one liner:

ImageCropperDialog(
  cropper = cropper,
  shape = CropShape.Circle,
  accessibility = CropAccessibility(contentDescription = "Profile photo crop area"),
)

Crop sources: Painter, file, or loader

A cropper is only as convenient as the inputs it accepts, and Crayfish accepts what a Compose app already has. Whatever put the picture on screen gave you a Painter or an ImageBitmap, and either one is a crop source. That means a URL loaded by Coil, Landscapist, or anything else needs nothing in between:

val painter = rememberAsyncImagePainter(url)
val source = rememberCropSource(painter, cacheKey = url)

if (source != null) {
  val state = rememberCropState(source)
  Cropper(state = state, modifier = Modifier.fillMaxSize())
}

cacheKey identifies the image. Crayfish keys the saved crop state on it, so a value that is stable across process death, such as the URL or a content Uri, keeps the user's crop where they left it.

For large originals, a painter is the wrong input, because it is already a full set of decoded pixels. Crayfish also takes references to the encoded image, opens them itself, and decodes only the region under the crop frame, so a 100 megapixel camera photo never has to fit in memory:

CropSource.FilePath("/storage/emulated/0/DCIM/Camera/IMG_0001.jpg")

CropSource.Bytes(bytes, cacheKey = uri.toString())

CropSource.Loader(cacheKey = url) { httpClient.get(url).readRawBytes() }

CropSource.Loader takes a suspending function and treats what comes back exactly like CropSource.Bytes. It runs once, when the cropper opens the source, and returning null reports that the image could not be fetched. Crayfish carries no HTTP client of its own, so the loader is where yours goes. The choice comes down to what you are holding:

  • A Painter or ImageBitmap already on screen: use rememberCropSource. It costs nothing extra, since those pixels are already in memory.
  • A camera original, a large photo, or a Uri: use CropSource.FilePath or CropSource.Bytes. Only the cropped region is ever decoded.
  • A URL for an image that may be huge: use CropSource.Loader with your own HTTP client. It costs one fetch of the encoded bytes and never a full decode.

Your own screen: Cropper and CropState

The dialog covers the common case. When the crop belongs inside a screen you are designing yourself, Cropper is the composable behind it, and CropState is the state you drive it with:

val state = rememberCropState(
  source = CropSource.FilePath(path),
  initialAspectRatio = AspectRatio.Square,
)

Cropper(
  state = state,
  modifier = Modifier.fillMaxSize(),
)

This follows the state hoisting pattern you already use with rememberLazyListState or rememberScrollState. The composable draws, and the state owns everything a toolbar needs, including the loading status of the source:

when (val status = state.status) {
  is CropStatus.Loading -> CircularProgressIndicator()
  is CropStatus.Ready -> Text("${status.imageSize.width} x ${status.imageSize.height}")
  is CropStatus.Failed -> Text("Could not open this image")
}

Row {
  IconButton(onClick = { state.rotateBy(-90f) }) { Icon(Icons.Default.RotateLeft, null) }
  IconButton(onClick = { state.toggleFlipHorizontal() }) { Icon(Icons.Default.Flip, null) }
  IconButton(onClick = { state.reset() }) { Icon(Icons.Default.Refresh, null) }
}

rotateBy takes any angle, not just quarter turns, and rotateToNearestQuarterTurn snaps back to the nearest of 0, 90, 180, and 270. Every operation ends with the crop rectangle back inside the image, so the frame always shows exactly which pixels the crop will select. Rotations and flips also reach the output: after rotateBy(90f) the result comes back turned.

The crop rectangle and the transform survive configuration changes and process death. That matters more than it used to, because apps that target Android 16 can no longer lock their orientation on large screens, so a tablet or a foldable rotates the cropper whether the app asked for it or not.

When the user is done, the state produces the result directly. cropToImage returns pixels for an Image, and crop returns encoded bytes in the format you choose:

when (val result = state.cropToImage()) {
  is CropImage.Success -> Image(bitmap = result.image, contentDescription = null)
  is CropImage.Cancelled -> Unit
  is CropImage.Failure -> showError(result.reason)
}

val encoded = state.crop(EncodeOptions(format = EncodedFormat.PNG))

crop also takes a DecodeBudget that caps how much the decode may allocate, which is useful when the result is going somewhere small, such as an 8 MiB and 1024 pixel cap for an avatar.

Customizing the cropper: Ratios, gestures, styles, and shapes

Every part of the cropper that a design might want to change is a parameter with a default, so you override only what your screen needs.

Aspect ratios

Pass an AspectRatio as the opening ratio, or assign state.aspectRatio to reshape the crop rectangle in place. A row of chips is all a ratio picker takes:

Row {
  listOf(
    "Free" to AspectRatio.Free,
    "1:1" to AspectRatio.Square,
    "3:4" to AspectRatio.Portrait3x4,
    "16:9" to AspectRatio.Widescreen16x9,
  ).forEach { (label, ratio) ->
    FilterChip(
      selected = state.aspectRatio == ratio,
      onClick = { state.aspectRatio = ratio },
      label = { Text(label) },
    )
  }
}

AspectRatio.Fixed(ratio) takes any width divided by height, and Landscape4x3 and Portrait9x16 round out the presets. Setting the ratio reshapes the rectangle immediately rather than waiting for the next drag.

Gestures

By default the photo holds still. It is fitted like an ordinary Image, and every gesture belongs to the crop rectangle. Most croppers do the opposite and let the photo pan, zoom, and twist under the frame, but then nothing on screen holds still long enough to aim an edge at. A fitted photo makes what is inside the frame the continuous answer to "what will I get", and it gives up no resolution, because the output is decoded from the source rather than from the preview.

When a screen wants the richer model, it is one parameter:

Cropper(
  state = state,
  modifier = Modifier.fillMaxSize(),
  gestures = CropGestures.Zoomable,
)

CropGestures.Zoomable adds pinch, two finger pan, double tap to zoom, and fling, and CropGestures.All adds the two finger twist on top. The flags are independent, so CropGestures(zoom = true) is a photo you can zoom but not drag.

Styles

CropStyle controls the chrome around the frame. Every value has a default:

Cropper(
  state = state,
  modifier = Modifier.fillMaxSize(),
  style = CropStyle(
    scrimColor = Color.Black.copy(alpha = 0.7f),
    frameColor = Color.White,
    gridMode = CropGridMode.OnTouch,
    handleTouchRadius = 24.dp,
  ),
)

CropGridMode is Never, Always, or OnTouch. handleTouchRadius sets the touch target rather than the drawn size, so the handles stay easy to grab without being drawn larger.

Shapes

The mask shape and the output aspect ratio are separate parameters, so a circular mask does not force a square output and a square ratio does not force a rectangular mask. The overlay is a composable slot, and CropOverlay takes the shape:

Cropper(
  state = state,
  modifier = Modifier.fillMaxSize(),
  overlay = { cropState ->
    CropOverlay(state = cropState, shape = CropShape.Circle)
  },
)

CropShape is Rectangle, RoundedRectangle(cornerRadius), Circle, or Custom, which builds a Path from the crop rectangle's size. The shape is cut out of the result as well as drawn over the viewfinder: set state.shape = CropShape.Circle and cropToImage comes back with transparent corners. crop cuts only when the encoded format keeps an alpha channel, so PNG and WebP keep the transparency, and a JPEG stays rectangular instead of being flattened onto a color nobody chose.

Straighten

A straighten slider reports an absolute angle, so rotateTo takes one:

var degrees by remember { mutableStateOf(0f) }

Slider(
  value = degrees,
  onValueChange = { degrees = it; state.rotateTo(it) },
  valueRange = -45f..45f,
)

The crop rectangle stays where the user put it, and the photo zooms the least amount that still fills it. Sliding out and back leaves the crop exactly where it started, instead of shrinking the frame a little on every degree.

Crops you can edit again: CropRecipe

Most croppers hand back a bitmap and forget how they got there, so "let me fix the crop from last week" means starting over. Crayfish never needed the pixels to describe a crop. It held a reference to the source and a rectangle over it, and a CropRecipe is that description written down as eleven floats:

database.save(photoId, state.recipe.encodeToString())

val recipe = CropRecipe.decodeFromString(database.load(photoId))
val state = rememberCropState(source, initialRecipe = recipe)

The original is never rewritten, and a different recipe over the same source is a different crop with nothing lost between them. decodeFromString returns null for anything it does not recognize, including a recipe from a future version, because opening a crop somewhere the user never put it is worse than opening on the default. Process death uses the same eleven numbers, so there is one format rather than two that drift apart.

Correct by default: Exif, accessibility, and performance

A few things a cropper has to get right have no parameters at all, because there is no reason to get them wrong.

  • Exif orientation: Crayfish reads the orientation tag and applies it to both the preview and the output. All eight values are handled, including the four mirrored ones that produce an upright but reversed photo when a cropper handles only the rotations. For HEIF, the container's own transform properties are reconciled against the Exif tag.
  • Accessibility: WCAG 2.2 SC 2.5.7 makes drag only interaction a Level AA failure, so the crop frame and each of its edges also move through accessibility actions, arrow keys, and a D-pad, with a live description of where the frame sits. CropAccessibility replaces the strings and the step size without touching anything else.
  • Baseline profile: the Android artifact ships one, so the decode, geometry, and gesture paths are compiled ahead of time from the first launch of any app that depends on Crayfish.

The baseline profile is measurable. On a Galaxy S23 running Android 16, with five iterations per condition, cold start to the first frame dropped from 312 ms to 262 ms at the median, and the P90 frame overrun while dragging the crop frame dropped from 23.0 ms to 5.6 ms. Frame overrun is how far past its deadline a frame landed, so that second number is the difference between a drag that visibly stutters and one that does not.

Beyond Compose screens: Activity, Coil, and Landscapist

Crayfish also meets the parts of an app that are not a Compose screen doing the cropping.

crayfish-activity is for Views, Fragments, or anything that would rather launch a cropper than embed one. Register the contract once and launch it whenever there is something to crop:

private val cropper = registerForActivityResult(CropImageContract()) { result ->
  when (result) {
    is CropImageResult.Success -> imageView.setImageURI(result.uri)
    is CropImageResult.Cancelled -> Unit
    is CropImageResult.Failure -> showError(result.reason)
  }
}

cropper.launch(CropImageRequest(source = photoUri, aspectRatio = 1f, mask = CropMask.Circle))

There is nothing to declare in your manifest. The result comes back as a content:// Uri rather than as bytes, because an activity result travels through a Binder transaction with a hard size ceiling that an ordinary photo would exceed.

crayfish-coil and crayfish-landscapist apply a stored crop while the image loader loads the original, so showing a crop needs no second file and no second decode. With Coil, CropTransformation goes on the request, built from the region of a crop result:

val region = result.region
val request = remember(region) {
  ImageRequest.Builder(context)
    .data(url)
    .transformations(CropTransformation(region))
    .build()
}

AsyncImage(model = request, contentDescription = null)

With Landscapist, the same CropTransformation is a Landscapist Transformation added through the request builder. In both modules the transformation's cache key includes the region, so two crops of one image are two cache entries rather than one that changes under whichever screen asks second. Paired with CropRecipe, this gives you a non destructive workflow: keep the original, store the recipe, and let the image loader apply the rectangle every time it draws.

Conclusion

If you are adding Crayfish to an app, start with the dialog. rememberImageCropper and one when cover most profile photo and cover image flows, and the call site stays readable because the crop is just a suspending function. Move to Cropper and CropState when the crop needs to live inside a screen of your own, and choose the source by what you are holding: a painter for what is already on screen, a file, bytes, or a loader for originals that are too large to decode whole. Every customization after that is a parameter with a default, so a screen changes only what its design actually asks for.

What I wanted from Crayfish was a cropper that behaves like the rest of Compose: state you hoist, composables you arrange, results you can draw immediately, and the same code on every platform the app runs on. The details that took the most care, such as large photos, mirrored Exif values, and a frame you can move without dragging, are exactly the ones you should never have to think about when you call it.

As always, happy coding!

— Jaewoong (skydoves)