• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

icapps / flutter-custom-image-crop / 6183199936

14 Sep 2023 08:57AM UTC coverage: 0.0%. Remained the same
6183199936

push

github

web-flow
Merge pull request #44 from icapps/feature/keep-ratio-while-cropping

Feature/keep ratio while cropping

128 of 128 new or added lines in 4 files covered. (100.0%)

0 of 362 relevant lines covered (0.0%)

0.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/lib/src/widgets/custom_image_crop_widget.dart
1
import 'dart:async';
2
import 'dart:ui' as ui;
3

4
import 'package:custom_image_crop/custom_image_crop.dart';
5
import 'package:custom_image_crop/src/calculators/calculate_crop_fit_params.dart';
6
import 'package:custom_image_crop/src/calculators/calculate_on_crop_params.dart';
7
import 'package:custom_image_crop/src/clippers/inverted_clipper.dart';
8
import 'package:flutter/material.dart';
9
import 'package:gesture_x_detector/gesture_x_detector.dart';
10
import 'package:vector_math/vector_math_64.dart' as vector_math;
11

12
/// An image cropper that is customizable.
13
/// You can rotate, scale and translate either
14
/// through gestures or a controller
15
class CustomImageCrop extends StatefulWidget {
16
  /// The image to crop
17
  final ImageProvider image;
18

19
  /// The controller that handles the cropping and
20
  /// changing of the cropping area
21
  final CustomImageCropController cropController;
22

23
  /// The color behind the cropping area
24
  final Color backgroundColor;
25

26
  /// The color in front of the cropped area
27
  final Color overlayColor;
28

29
  /// The shape of the cropping area.
30
  /// Possible values:
31
  /// - [CustomCropShape.Circle] Crop area will be circular.
32
  /// - [CustomCropShape.Square] Crop area will be a square.
33
  /// - [CustomCropShape.Ratio] Crop area will have a specified aspect ratio.
34
  final CustomCropShape shape;
35

36
  /// Ratio of the cropping area.
37
  /// If [shape] is set to [CustomCropShape.Ratio], this property is required.
38
  /// For example, to create a square crop area, use [Ratio(width: 1, height: 1)].
39
  /// To create a rectangular crop area with a 16:9 aspect ratio, use [Ratio(width: 16, height: 9)].
40
  final Ratio? ratio;
41

42
  /// How to fit image inside visible space
43
  final CustomImageFit imageFit;
44

45
  /// The percentage of the available area that is
46
  /// reserved for the cropping area
47
  final double cropPercentage;
48

49
  /// The path drawer of the border see [DottedCropPathPainter],
50
  /// [SolidPathPainter] for more details or how to implement a
51
  /// custom one
52
  final CustomPaint Function(Path, {Paint? pathPaint}) drawPath;
53

54
  /// Custom paint options for drawing the cropping border.
55
  ///
56
  /// If [paint] is provided, it will be used for customizing the appearance
57
  /// of the cropping border.
58
  ///
59
  /// If [paint] is not provided, default values will be used:
60
  /// - Color: [Colors.white]
61
  /// - Stle [PaintingStyle.stroke]
62
  /// - Stroke Join [StrokeJoin.round]
63
  /// - Stroke Width: 4.0
64
  final Paint? pathPaint;
65

66
  /// The radius for rounded corners of the cropping area (only applicable to rounded rectangle shapes).
67
  final double borderRadius;
68

69
  /// Whether to allow the image to be rotated.
70
  final bool canRotate;
71

72
  /// Determines whether scaling gesture is disabled.
73
  ///
74
  /// By default, scaling is enabled.
75
  /// Set [canScale] to `false` to disable scaling.
76
  final bool canScale;
77

78
  /// Determines whether moving gesture overlay is disabled.
79
  ///
80
  /// By default, moving is enabled.
81
  /// Set [canMove] to `false` to disable move.
82
  final bool canMove;
83

84
  /// The paint used when drawing an image before cropping
85
  final Paint imagePaintDuringCrop;
86

87
  /// This widget is used to specify a custom progress indicator
88
  final Widget? customProgressIndicator;
89

90
  /// Whether to clip the area outside of the path when cropping
91
  /// By default, the value is `true`
92
  final bool clipShapeOnCrop;
93

94
  /// A custom image cropper widget
95
  ///
96
  /// Uses a `CustomImageCropController` to crop the image.
97
  /// With the controller you can rotate, translate and/or
98
  /// scale with buttons and sliders. This can also be
99
  /// achieved with gestures
100
  ///
101
  /// Use a `shape` with `CustomCropShape.Circle` or
102
  /// `CustomCropShape.Square`
103
  ///
104
  /// You can increase the cropping area using `cropPercentage`
105
  ///
106
  /// Change the cropping border by changing `drawPath`,
107
  /// we've provided two default painters as inspiration
108
  /// `DottedCropPathPainter.drawPath` and
109
  /// `SolidCropPathPainter.drawPath`
110
  CustomImageCrop({
×
111
    required this.image,
112
    required this.cropController,
113
    this.overlayColor = const Color.fromRGBO(0, 0, 0, 0.5),
114
    this.backgroundColor = Colors.white,
115
    this.shape = CustomCropShape.Circle,
116
    this.imageFit = CustomImageFit.fitCropSpace,
117
    this.cropPercentage = 0.8,
118
    this.drawPath = DottedCropPathPainter.drawPath,
119
    this.pathPaint,
120
    this.canRotate = true,
121
    this.canScale = true,
122
    this.canMove = true,
123
    this.clipShapeOnCrop = true,
124
    this.customProgressIndicator,
125
    this.ratio,
126
    this.borderRadius = 0,
127
    Paint? imagePaintDuringCrop,
128
    Key? key,
129
  })  : this.imagePaintDuringCrop = imagePaintDuringCrop ??
130
            (Paint()..filterQuality = FilterQuality.high),
×
131
        assert(
132
          !(shape == CustomCropShape.Ratio && ratio == null),
×
133
          "If shape is set to Ratio, ratio should not be null.",
134
        ),
135
        super(key: key);
×
136

137
  @override
×
138
  _CustomImageCropState createState() => _CustomImageCropState();
×
139
}
140

141
class _CustomImageCropState extends State<CustomImageCrop>
142
    with CustomImageCropListener {
143
  CropImageData? _dataTransitionStart;
144
  late Path _path;
145
  late double _width, _height;
146
  ui.Image? _imageAsUIImage;
147
  ImageStream? _imageStream;
148
  ImageStreamListener? _imageListener;
149

150
  @override
×
151
  void initState() {
152
    super.initState();
×
153
    widget.cropController.addListener(this);
×
154
  }
155

156
  @override
×
157
  void didChangeDependencies() {
158
    super.didChangeDependencies();
×
159
    _getImage();
×
160
  }
161

162
  @override
×
163
  void didUpdateWidget(CustomImageCrop oldWidget) {
164
    super.didUpdateWidget(oldWidget);
×
165
    if (oldWidget.image != widget.image) _getImage();
×
166
  }
167

168
  void _getImage() {
×
169
    final oldImageStream = _imageStream;
×
170
    _imageStream = widget.image.resolve(createLocalImageConfiguration(context));
×
171
    if (_imageStream?.key != oldImageStream?.key) {
×
172
      if (_imageListener != null) {
×
173
        oldImageStream?.removeListener(_imageListener!);
×
174
      }
175
      _imageListener = ImageStreamListener(_updateImage);
×
176
      _imageStream?.addListener(_imageListener!);
×
177
    }
178
  }
179

180
  void _updateImage(ImageInfo imageInfo, _) {
×
181
    setState(() {
×
182
      _imageAsUIImage = imageInfo.image;
×
183
    });
184
  }
185

186
  @override
×
187
  void dispose() {
188
    if (_imageListener != null) {
×
189
      _imageStream?.removeListener(_imageListener!);
×
190
    }
191
    widget.cropController.removeListener(this);
×
192
    super.dispose();
×
193
  }
194

195
  @override
×
196
  Widget build(BuildContext context) {
197
    final image = _imageAsUIImage;
×
198
    if (image == null) {
199
      return Center(
×
200
        child: widget.customProgressIndicator ?? CircularProgressIndicator(),
×
201
      );
202
    }
203
    return LayoutBuilder(
×
204
      builder: (context, constraints) {
×
205
        _width = constraints.maxWidth;
×
206
        _height = constraints.maxHeight;
×
207
        final cropFitParams = calculateCropFitParams(
×
208
          cropPercentage: widget.cropPercentage,
×
209
          imageFit: widget.imageFit,
×
210
          imageHeight: image.height,
×
211
          imageWidth: image.width,
×
212
          screenHeight: _height,
×
213
          screenWidth: _width,
×
214
          aspectRatio: (widget.ratio?.width ?? 1) / (widget.ratio?.height ?? 1),
×
215
        );
216
        final scale = data.scale * cropFitParams.additionalScale;
×
217
        _path = _getPath(
×
218
          cropWidth: cropFitParams.cropSizeWidth,
×
219
          cropHeight: cropFitParams.cropSizeHeight,
×
220
          width: _width,
×
221
          height: _height,
×
222
          borderRadius: widget.borderRadius,
×
223
        );
224
        return XGestureDetector(
×
225
          onMoveStart: onMoveStart,
×
226
          onMoveUpdate: onMoveUpdate,
×
227
          onScaleStart: onScaleStart,
×
228
          onScaleUpdate: onScaleUpdate,
×
229
          child: Container(
×
230
            width: _width,
×
231
            height: _height,
×
232
            color: widget.backgroundColor,
×
233
            child: Stack(
×
234
              children: [
×
235
                Positioned(
×
236
                  left: data.x + _width / 2,
×
237
                  top: data.y + _height / 2,
×
238
                  child: Transform(
×
239
                    transform: Matrix4.diagonal3(
×
240
                        vector_math.Vector3(scale, scale, scale))
×
241
                      ..rotateZ(data.angle)
×
242
                      ..translate(-image.width / 2, -image.height / 2),
×
243
                    child: Image(
×
244
                      image: widget.image,
×
245
                    ),
246
                  ),
247
                ),
248
                IgnorePointer(
×
249
                  child: ClipPath(
×
250
                    clipper: InvertedClipper(_path, _width, _height),
×
251
                    child: Container(
×
252
                      color: widget.overlayColor,
×
253
                    ),
254
                  ),
255
                ),
256
                widget.drawPath(_path, pathPaint: widget.pathPaint),
×
257
              ],
258
            ),
259
          ),
260
        );
261
      },
262
    );
263
  }
264

265
  void onScaleStart(_) {
×
266
    _dataTransitionStart = null; // Reset for update
×
267
  }
268

269
  void onScaleUpdate(ScaleEvent event) {
×
270
    final scale =
271
        widget.canScale ? event.scale : (_dataTransitionStart?.scale ?? 1.0);
×
272

273
    final angle = widget.canRotate ? event.rotationAngle : 0.0;
×
274

275
    if (_dataTransitionStart != null) {
×
276
      addTransition(
×
277
        _dataTransitionStart! -
×
278
            CropImageData(
×
279
              scale: scale,
280
              angle: angle,
281
            ),
282
      );
283
    }
284
    _dataTransitionStart = CropImageData(
×
285
      scale: scale,
286
      angle: angle,
287
    );
288
  }
289

290
  void onMoveStart(_) {
×
291
    _dataTransitionStart = null; // Reset for update
×
292
  }
293

294
  void onMoveUpdate(MoveEvent event) {
×
295
    if (!widget.canMove) return;
×
296

297
    addTransition(CropImageData(x: event.delta.dx, y: event.delta.dy));
×
298
  }
299

300
  Path _getPath({
×
301
    required double cropWidth,
302
    required double cropHeight,
303
    required double width,
304
    required double height,
305
    required double borderRadius,
306
    bool clipShape = true,
307
  }) {
308
    if (!clipShape) {
309
      return Path()
×
310
        ..addRect(
×
311
          Rect.fromCenter(
×
312
            center: Offset(width / 2, height / 2),
×
313
            width: cropWidth,
314
            height: cropHeight,
315
          ),
316
        );
317
    }
318

319
    switch (widget.shape) {
×
320
      case CustomCropShape.Circle:
×
321
        return Path()
×
322
          ..addOval(
×
323
            Rect.fromCircle(
×
324
              center: Offset(width / 2, height / 2),
×
325
              radius: cropWidth / 2,
×
326
            ),
327
          );
328
      case CustomCropShape.Ratio:
×
329
        return Path()
×
330
          ..addRRect(
×
331
            RRect.fromRectAndRadius(
×
332
              Rect.fromCenter(
×
333
                center: Offset(width / 2, height / 2),
×
334
                width: cropWidth,
335
                height: cropHeight,
336
              ),
337
              Radius.circular(borderRadius),
×
338
            ),
339
          );
340
      default:
341
        return Path()
×
342
          ..addRRect(
×
343
            RRect.fromRectAndRadius(
×
344
              Rect.fromCenter(
×
345
                center: Offset(width / 2, height / 2),
×
346
                width: cropWidth,
347
                height: cropHeight,
348
              ),
349
              Radius.circular(borderRadius),
×
350
            ),
351
          );
352
    }
353
  }
354

355
  @override
×
356
  Future<MemoryImage?> onCropImage() async {
357
    if (_imageAsUIImage == null) {
×
358
      return null;
359
    }
360
    final imageWidth = _imageAsUIImage!.width;
×
361
    final imageHeight = _imageAsUIImage!.height;
×
362
    final pictureRecorder = ui.PictureRecorder();
×
363
    final canvas = Canvas(pictureRecorder);
×
364
    final onCropParams = caclulateOnCropParams(
×
365
      cropPercentage: widget.cropPercentage,
×
366
      imageFit: widget.imageFit,
×
367
      imageHeight: imageHeight,
368
      imageWidth: imageWidth,
369
      screenHeight: _height,
×
370
      screenWidth: _width,
×
371
      dataScale: data.scale,
×
372
      aspectRatio: (widget.ratio?.width ?? 1) / (widget.ratio?.height ?? 1),
×
373
    );
374
    final clipPath = Path.from(_getPath(
×
375
      cropWidth: onCropParams.cropSizeWidth,
×
376
      cropHeight: onCropParams.cropSizeHeight,
×
377
      width: onCropParams.cropSizeWidth,
×
378
      height: onCropParams.cropSizeHeight,
×
379
      borderRadius: widget.borderRadius,
×
380
      clipShape: widget.clipShapeOnCrop,
×
381
    ));
382
    final matrix4Image = Matrix4.diagonal3(vector_math.Vector3.all(1))
×
383
      ..translate(
×
384
        onCropParams.translateScale * data.x + onCropParams.cropSizeWidth / 2,
×
385
        onCropParams.translateScale * data.y + onCropParams.cropSizeHeight / 2,
×
386
      )
387
      ..scale(onCropParams.scale)
×
388
      ..rotateZ(data.angle);
×
389
    final bgPaint = Paint()
×
390
      ..color = widget.backgroundColor
×
391
      ..style = PaintingStyle.fill;
×
392
    canvas.drawRect(
×
393
      Rect.fromLTWH(
×
394
        0,
395
        0,
396
        onCropParams.cropSizeWidth,
×
397
        onCropParams.cropSizeHeight,
×
398
      ),
399
      bgPaint,
400
    );
401
    canvas.save();
×
402
    canvas.clipPath(clipPath);
×
403
    canvas.transform(matrix4Image.storage);
×
404
    canvas.drawImage(
×
405
      _imageAsUIImage!,
×
406
      Offset(-imageWidth / 2, -imageHeight / 2),
×
407
      widget.imagePaintDuringCrop,
×
408
    );
409
    canvas.restore();
×
410

411
    // Optionally remove magenta from image by evaluating every pixel
412
    // See https://github.com/brendan-duncan/image/blob/master/lib/src/transform/copy_crop.dart
413

414
    // final bytes = await compute(computeToByteData, <String, dynamic>{'pictureRecorder': pictureRecorder, 'cropWidth': cropWidth});
415

416
    ui.Picture picture = pictureRecorder.endRecording();
×
417
    ui.Image image = await picture.toImage(
×
418
      onCropParams.cropSizeWidth.floor(),
×
419
      onCropParams.cropSizeHeight.floor(),
×
420
    );
421

422
    // Adding compute would be preferrable. Unfortunately we cannot pass an ui image to this.
423
    // A workaround would be to save the image and load it inside of the isolate
424
    final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
×
425
    return bytes == null ? null : MemoryImage(bytes.buffer.asUint8List());
×
426
  }
427

428
  @override
×
429
  void addTransition(CropImageData transition) {
430
    setState(() {
×
431
      data += transition;
×
432
      // For now, this will do. The idea is that we create
433
      // a path from the data and check if when we combine
434
      // that with the crop path that the resulting path
435
      // overlap the hole (crop). So we check if all pixels
436
      // from the crop contain pixels from the original image
437
      data.scale = data.scale.clamp(0.1, 10.0);
×
438
    });
439
  }
440

441
  @override
×
442
  void setData(CropImageData newData) {
443
    setState(() {
×
444
      data = newData;
×
445
      // The same check should happen (once available) as in addTransition
446
      data.scale = data.scale.clamp(0.1, 10.0);
×
447
    });
448
  }
449
}
450

451
enum CustomCropShape {
452
  Circle,
453
  Square,
454
  Ratio,
455
}
456

457
enum CustomImageFit {
458
  fillCropSpace,
459
  fitCropSpace,
460
  fillCropWidth,
461
  fillCropHeight,
462
  fitVisibleSpace,
463
  fillVisibleSpace,
464
  fillVisibleHeight,
465
  fillVisibleWidth,
466
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc