crop_view 0.1.0
crop_view: ^0.1.0 copied to clipboard
An aspect-locked image cropper for Flutter. The crop window stays fixed while the image pans, zooms and rotates behind it, so the crop is always fully covered. Crops to JPEG (with a quality factor) or [...]
crop_view #
An image cropper for Flutter with a draggable crop box.
The image pans, zooms and rotates behind the box, and the box is always kept
inside the image — so a crop can never include anything that isn't part of the picture.
Why crop_view? #
- 🎯 Draggable, resizable crop box with corner and edge handles.
- 🔒 Locked aspect ratio (square, 16:9, …) or free-form — your choice.
- 🔄 Pan, zoom and 90° rotation, with the box always clamped to the image.
- 🖼️ JPEG (with a quality factor) or PNG output.
- 🧩 Bring your own toolbar. No buttons, no strings, no icons baked in — it drops into any design system and needs no localization.
- 🪶 Pure Dart & Flutter. No platform channels, no JavaScript. It behaves identically on Android, iOS, web, Windows, macOS and Linux — and, unlike croppers built on a browser library, its crop maths are unit-tested.
Installation #
dependencies:
crop_view: ^0.1.0
import 'package:crop_view/crop_view.dart';
Quick start #
class EditPhoto extends StatefulWidget {
const EditPhoto({super.key, required this.bytes});
final Uint8List bytes;
@override
State<EditPhoto> createState() => _EditPhotoState();
}
class _EditPhotoState extends State<EditPhoto> {
// Pass an aspectRatio to lock the shape (1 = square); omit it for a free crop.
final controller = CropController(aspectRatio: 1);
@override
void dispose() {
controller.dispose(); // always dispose the controller
super.dispose();
}
Future<void> _done() async {
final Uint8List? jpeg = await controller.crop(
maxEdge: 1080,
format: const CropFormat.jpeg(quality: 85),
);
if (jpeg != null && mounted) Navigator.pop(context, jpeg);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(
child: CropView(imageBytes: widget.bytes, controller: controller),
),
// Your toolbar — style it however you like.
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(onPressed: controller.rotateLeft, icon: const Icon(Icons.rotate_left)),
IconButton(onPressed: controller.rotateRight, icon: const Icon(Icons.rotate_right)),
IconButton(onPressed: controller.zoomIn, icon: const Icon(Icons.zoom_in)),
IconButton(onPressed: controller.zoomOut, icon: const Icon(Icons.zoom_out)),
IconButton(onPressed: controller.reset, icon: const Icon(Icons.restore)),
FilledButton(onPressed: _done, child: const Text('Done')),
],
),
],
),
);
}
}
💡 The widget draws only the image, the crop box and its handles. Everything else — buttons, labels, layout — is yours, so it matches your app instead of fighting it.
Recipes #
Free-form crop (any shape) #
final controller = CropController(); // no aspectRatio
A specific ratio #
CropController(aspectRatio: 16 / 9); // widescreen
CropController(aspectRatio: 3 / 4); // portrait
PNG output (keeps transparency) #
final png = await controller.crop(
maxEdge: 2048,
format: const CropFormat.png(),
);
JPEG has no alpha channel, so transparent pixels are composited onto white; PNG keeps them transparent.
Get a dart:ui image instead of bytes #
final ui.Image image = (await controller.cropToImage(maxEdge: 1024))!;
// …use it, then image.dispose();
React to state (enable/disable your buttons) #
CropController is a ChangeNotifier:
AnimatedBuilder(
animation: controller,
builder: (_, __) => IconButton(
onPressed: controller.isTransformed ? controller.reset : null,
icon: const Icon(Icons.restore),
),
);
Supply an already-decoded image, or decode your own way #
CropView(
imageBytes: bytes,
controller: controller,
decoder: (bytes) async => myAlreadyDecodedUiImage,
);
API at a glance #
CropView |
The crop surface. Props for colours, maxDecodedDimension, loadingBuilder, errorBuilder, decoder. |
CropController |
rotateLeft · rotateRight · zoomIn · zoomOut · reset · crop · cropToImage · isReady · isTransformed. |
CropFormat |
CropFormat.jpeg(quality: 85) · CropFormat.png(). |
crop's result has its longest side capped at maxEdge and is never upscaled past
the cropped region's own resolution. Large sources are downscaled to
CropView.maxDecodedDimension (2160px default) while decoding, to bound memory.
Notes #
CropController.aspectRatiois fixed for the controller's life — create a new controller for a different ratio.- Always
dispose()the controller with your widget.
Example #
A complete, runnable example lives in example/.
License #
MIT © srad