Skip to content

0.1.0

Choose a tag to compare

@skydoves skydoves released this 19 Sep 11:25
· 7 commits to main since this release

The first release of Crayfish, a Compose Multiplatform image cropper for Android, iOS, desktop and the web.

Install

dependencies {
    implementation("com.github.skydoves:crayfish:0.1.0")
}
Artifact What it adds
crayfish The cropper. Everything below lives here
crayfish-coil A Coil 3 Transformation that crops as part of a load
crayfish-landscapist The same for Landscapist, as an ImagePlugin
crayfish-activity An ActivityResultContract, for callers who want an Activity hop rather than a composable

crayfish publishes android, desktop for the JVM, iosArm64, iosSimulatorArm64, iosX64, macosArm64 and wasmJs. crayfish-activity is Android only. minSdk 21.


The cropper takes a reference, not a decoded bitmap

Every Compose cropper published so far takes an ImageBitmap, which means the caller has already decoded the whole photo before the cropper is even on screen. A 108 megapixel phone photo is 412 MiB decoded and a 200 megapixel one is 768 MiB, so on those the out of memory has already happened by the time the crop begins.

CropSource is a file path, a Uri, a byte array, a Painter or an ImageBitmap, and for the first three Crayfish owns the decode: it reads the header for dimensions, then decodes only the region it needs, at a sample size chosen to fit a stated DecodeBudget, through BitmapRegionDecoder on Android, ImageIO's setSourceRegion on the JVM, CGImageSourceCreateThumbnailAtIndex on Apple and createImageBitmap in the browser.

The end to end tests measure this rather than assert it, against a generated 12000x9000 JPEG and a 28000x2000 panorama. The control that makes the numbers mean something: a naive full decode of the same file exhausts the same budget in a child JVM, and the same probe is shown to succeed on a 4K image.

Free angle rotation, and a straighten slider

The image rotates about the content centre, at any angle rather than in 90 degree steps, and the crop rectangle stays where it is while the photo is zoomed the least amount that still fills it. rotateTo drives a straighten slider directly, because a slider reports where it is and not how far it has moved.

Exif, including the four mirrored orientations

All eight orientation values are read and applied, to the preview and to the output. The four mirrored ones (2, 4, 5, 7) are the values that produce an upright but reversed photo when a cropper normalises only the rotations. For HEIF the container's own transform boxes are reconciled against the Exif tag. Output is written with ORIENTATION_NORMAL, because the rotation is in the pixels by then.

The crop rectangle works without dragging

WCAG 2.2 SC 2.5.7 makes drag only interaction a Level AA failure, and a crop rectangle you can only resize by dragging does not comply. There are thirteen accessibility actions for moving the frame and each of its edges, a live state description, and arrow key and D-pad support. The on device tests read the real AccessibilityNodeInfo tree through UiAutomation, which is the tree TalkBack consumes, and invoke an action through the accessibility channel rather than through a touch gesture.

The photo holds still by default

Pinch, pan and twist are off unless a screen asks for them. A cropper where the photo moves under the frame is a cropper that fights the user on every drag, so CropGestures starts empty and CropGestures.Zoomable or .All switches them on.

A crop survives process death

CropRecipe encodes the transform, the normalized rectangle and the aspect ratio as eleven floats, which is also the format rememberSaveable uses. Android 16 ignores screenOrientation on any display 600dp or wider, so the portrait lock every existing cropper relies on is gone, and an unsaved crop rectangle is a lost edit.

No native code

The published artifacts contain no .so, so the Play Store's 16 KB page size requirement is a non event, unconditionally and with no work at the February 2027 cutoff. This is a gate rather than a sentence: verifyNoNativeCode opens every published AAR, JAR and klib and looks. 126 artifacts inspected on this release.

A baseline profile inside the AAR

The Android artifact carries its own baseline profile, so a consumer gets the decode, geometry and gesture paths compiled ahead of time without doing anything. It holds 787 rules, all within the five packages the library publishes.

Measured on a Galaxy S23, Android 16, five iterations per condition, the same build with the profile and without it:

without with
Cold start to first frame, median 312.2 ms 262.1 ms
Frame duration while dragging, P90 25.21 ms 11.67 ms
Frame overrun while dragging, P90 23.04 ms 5.61 ms

Frame overrun is how far past the deadline a frame landed, so 23.04 ms becoming 5.61 ms is the difference between a drag that visibly stutters and one that does not. The startup ranges do not overlap.


Verified

610 tests on the desktop JVM, 269 on macOS arm64, 269 on the iOS simulator, 263 in a browser through wasm, and 36 Android instrumentation tests. The Android ones are the tests that matter for the memory claims, because a Bitmap's pixels are native and a recycled one raises only at the moment something reads it, so every result there is read rather than counted.

The stress suite walks seven sources against eight crop rectangles each, including a 5000x5000 square, a 2000x28000 column and a source one pixel wide and 32767 tall, plus degenerate rectangles, concurrent decodes, a close landing mid decode, and a storm of activity recreations.

Known limits

  • EncodedFormat.WEBP_LOSSLESS is unsupported off Android, and on Android below API 30. Skia's encoder takes a single quality integer with no way to select lossless mode, and every value from 0 to 100 produces a lossy VP8 chunk. Writing a lossy file for a caller who asked for lossless is a silent quality loss, so the format is refused instead and crop returns CropResult.Failure(EncodeUnsupported).
  • A rotated or mirrored crop is decoded against half the budget. Turning the pixels writes a second buffer while the first is still alive, so the ceiling has to cover both. Such a crop comes back smaller than the same crop untransformed; raise budget if the resolution matters more than the peak.
  • The pre API 30 accessibility branch has never executed. It packs the state description into the node's extras and is written but untested, because no device below API 30 was available.

Docs

skydoves.github.io/crayfish, including a web demo you can crop in.