From 876b42e274743e577bc4fbecd587881b5828654e Mon Sep 17 00:00:00 2001 From: hm21 Date: Tue, 11 Aug 2026 11:33:44 +0200 Subject: [PATCH 1/2] fix(crop-rotate): stop crop handles, pinch and tilt from jumping The crop handles were positioned absolutely on the pointer, so the distance the gesture recognizer swallows as slop (kPanSlop, 36px) plus the distance between the finger and the handle was applied as one jump before the handle started following the finger. Both are now captured at gesture start and compensated. The same applied to pinch-to-zoom, where the scale slop was applied as an instant zoom step. Further fixes in the same interaction: - Corner drags with a fixed aspect ratio derived the height from the width only, so a vertical drag did nothing and the corner drifted away from the finger. The pointer is now projected onto the ratio diagonal and the result is clamped to the image. - ScaleGestureRecognizer reports an end whenever the pointer count changes. That ran the full crop teardown when the second finger touched down and blocked the following start, leaving the pinch with a stale scale baseline. - The zoom-out hit area compared a raw screen position against the editor body size, which is inset on Android (maxWidthFactor) and sits below the app bar. Pointer positions are converted into the body's coordinate space instead of hand-rolling the offsets. - The overlay outside the crop area faded with two competing loops that each restarted from 0, so interrupting one snapped the brightness. It is driven by an AnimationController now. - While tilted, the bounds math overrode the zoom of the crop-end animation on every frame and the auto zoom-out fought the minimum zoom the tilt requires, which made the image jump around after a resize. --- CHANGELOG.md | 8 + .../crop_rotate_editor.dart | 459 +++++++++++++----- pubspec.yaml | 2 +- .../crop_rotate_editor_test.dart | 201 ++++++++ 4 files changed, 542 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1f7bee..1775028d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 13.3.1 +- **FIX**(crop-rotate): Crop handles no longer jump onto the finger when a drag starts; the gesture slop and the distance to the grabbed handle are compensated. +- **FIX**(crop-rotate): Corner drags with a fixed aspect ratio now follow the finger diagonally instead of tracking horizontal movement only, and stay inside the image. +- **FIX**(crop-rotate): Pinch-to-zoom no longer jumps at gesture start and is no longer interrupted when the second finger touches down. +- **FIX**(crop-rotate): The overlay outside the crop area fades smoothly again when an interaction is interrupted. +- **FIX**(crop-rotate): Fix the auto zoom-out hit area being misplaced on Android and with custom app bars. +- **FIX**(crop-rotate): Resizing a tilted image no longer makes it jump around; the tilt bounds now follow the crop animation instead of overriding its zoom, and the auto zoom-out stops at the zoom the tilt requires. + ## 13.3.0 - **FEAT**(layers): Rasterize layers outside a live editor session with `LayerRasterizer` and `LayerRasterizerHost`. diff --git a/lib/features/crop_rotate_editor/crop_rotate_editor.dart b/lib/features/crop_rotate_editor/crop_rotate_editor.dart index eacb6dff..291ae2ec 100644 --- a/lib/features/crop_rotate_editor/crop_rotate_editor.dart +++ b/lib/features/crop_rotate_editor/crop_rotate_editor.dart @@ -195,14 +195,15 @@ class CropRotateEditorState extends State StandaloneEditorState, ExtendedLoop, CropAreaHistory { - /// A global key used to identify the editor content widget. + /// A global key used to identify the editor body, the box the image and the + /// crop overlay are laid out in. + /// + /// Pointer positions are converted into the coordinate space of this box, so + /// they can be compared against [editorBodySize] no matter where the body + /// sits on the screen (embedded editor, app-bar above it or a horizontal + /// inset from [CropRotateEditorConfigs.maxWidthFactor]). final _editorContentKey = GlobalKey(); - /// An offset helper to keep track of the editor's screen offset. - /// This is required for the case the editor is embedded inside the screen. - /// Initialized to `Offset.zero`. - Offset _editorScreenOffsetHelper = Offset.zero; - final _mouseCursorsKey = GlobalKey(); /// A key used to access the state of the CropRotateGestureDetector widget. @@ -298,6 +299,33 @@ class CropRotateEditorState extends State ? _mainImageSize.aspectRatio : _activeAspectRatio); + /// Whether any perspective tilt is applied. + /// + /// While tilted the image no longer covers an axis-aligned rectangle, so the + /// bounds math auto-zooms instead of only clamping the translation. + bool get _isTilted => + tiltRotateAngle != 0 || + tiltHorizontalAngle != 0 || + tiltVerticalAngle != 0; + + /// The smallest zoom that still keeps [_viewRect] covered by the (possibly + /// tilted) image. + /// + /// Zooming out any further would reveal empty area next to the tilted image, + /// so [_setOffsetLimits] pushes the zoom straight back up. Callers that + /// reduce the zoom use this as their floor instead of fighting it. + double get _minCoveringScale { + if (!_isTilted) return 1; + + return fitCropInsideTiltedImage( + baseTiltCorners: _baseTiltCorners(), + cropSize: _viewRect.size, + minScale: 1, + maxScale: cropRotateEditorConfigs.maxScale, + currentTranslate: translate, + ).scale; + } + /// Indicates whether a locked-aspect-ratio rotation animation is in progress. /// /// Used to defer the history entry to the end of the crop-area transition @@ -308,14 +336,37 @@ class CropRotateEditorState extends State double _painterOpacity = 0; /// The interaction progress for opacity. + /// + /// Drives how much the area outside the crop area brightens up while the user + /// interacts with the crop frame. double _interactionOpacityProgress = 0; + /// Animates [_interactionOpacityProgress]. + /// + /// A controller is required here because the user can grab the crop frame + /// again while it is still fading back to its idle state. Restarting the + /// transition from `0` would make the overlay jump instead of continuing + /// smoothly from its current brightness. + late final AnimationController _interactionOpacityCtrl; + + /// The curved animation of [_interactionOpacityCtrl]. + late final Animation _interactionOpacityAnimation; + /// The padding around the screen. final double _screenPadding = 20; /// The starting scale value for pinch gestures. double _startingPinchScale = 1; + /// The scale the recognizer reported on the first update of the running + /// pinch gesture. + /// + /// A pinch is only recognized after the fingers moved past the gesture slop, + /// so the first reported scale is already noticeably off `1`. Zooming + /// relative to this baseline keeps the image from jumping when the pinch + /// starts. + double? _pinchScaleBaseline; + /// Helper variable to store the initial scale value at the start of a /// scaling gesture. double _scaleStartZoomHelper = 1; @@ -357,6 +408,15 @@ class CropRotateEditorState extends State /// The current part of the crop area being interacted with. CropAreaPart _currentCropAreaPart = CropAreaPart.none; + /// The distance between the pointer and the crop handle it grabbed. + /// + /// The handles have a generous hit area + /// ([CropRotateEditorConfigs.mobileCornerDragArea]), so the pointer usually + /// sits a couple of pixels next to the edge it dragged. Without compensating + /// for that distance the handle jumps onto the pointer with the first move + /// event before it starts following it. + Offset _cropGrabOffset = Offset.zero; + /// Manager class for handling desktop interactions. late final CropDesktopInteractionManager _desktopInteractionManager; @@ -372,8 +432,6 @@ class CropRotateEditorState extends State /// The current cursor style. MouseCursor _mouseCursor = SystemMouseCursors.basic; - bool _hasToolbar = true; - /// A flag indicating whether the screen has been resized. bool _isScreenResized = false; @@ -489,6 +547,24 @@ class CropRotateEditorState extends State end: initAngle, ).animate(rotateCtrl); + // Initialize the opacity animation of the area outside the crop area + _interactionOpacityCtrl = AnimationController( + duration: cropRotateEditorConfigs.opacityOutsideCropAreaDuration, + vsync: this, + ); + + /// The same curve is used in both directions on purpose. A separate + /// reverse curve maps the controller value to a different opacity, which + /// makes the overlay jump as soon as a transition is reversed midway. + _interactionOpacityAnimation = + CurvedAnimation( + parent: _interactionOpacityCtrl, + curve: Curves.decelerate, + )..addListener(() { + _interactionOpacityProgress = _interactionOpacityAnimation.value; + cropPainterKey.currentState?.setForegroundPainter(cropPainter); + }); + // Initialize scale animation double initScale = (initialTransformConfigs?.scaleRotation ?? 1); scaleCtrl = AnimationController( @@ -577,6 +653,7 @@ class CropRotateEditorState extends State _bottomBarScrollCtrl.dispose(); rotateCtrl.dispose(); scaleCtrl.dispose(); + _interactionOpacityCtrl.dispose(); super.dispose(); } @@ -1428,12 +1505,22 @@ class CropRotateEditorState extends State void _zoomOutside() async { const int frameHelper = 1000 ~/ 60; - while (userScaleFactor > 1 && _activeScaleOut) { + + /// A tilted image needs a minimum zoom to keep the crop area covered. + /// Without this floor every step is undone by [_setOffsetLimits] right + /// away, which leaves the image jittering while the crop area is reset by + /// [calcCropRect] on every iteration. + final double minZoom = _minCoveringScale; + + while (userScaleFactor > minZoom && _activeScaleOut) { double oldZoom = userScaleFactor; double zoomFactor = 0.025; - userScaleFactor -= zoomFactor; - userScaleFactor = max(1, userScaleFactor); + userScaleFactor = max(minZoom, userScaleFactor - zoomFactor); + + /// Zooming out is a manual zoom change, so the floor the bounds math + /// keeps has to follow along. + manualScaleFactor = userScaleFactor; var zoomOutsideWidth = _viewRect.width / oldZoom * userScaleFactor; var zoomOutsideHeight = _viewRect.height / oldZoom * userScaleFactor; @@ -1487,10 +1574,9 @@ class CropRotateEditorState extends State if (_blockInteraction || details.pointerCount > 2) return; _blockInteraction = true; - _editorScreenOffsetHelper = _calculateEditorScreenOffset(); - _startingPinchScale = userScaleFactor; _startingTranslate = translate; + _pinchScaleBaseline = null; // Calculate the center offset point from the old zoomed view _startingCenterOffset = _startingTranslate + @@ -1505,18 +1591,14 @@ class CropRotateEditorState extends State if (!isDesktop) { _currentCropAreaPart = _determineCropAreaPart(details.localFocalPoint); } - - loopWithTransitionTiming( - (double curveT) { - _interactionOpacityProgress = 1 * curveT; - cropPainterKey.currentState!.setForegroundPainter(cropPainter); - }, - mounted: mounted, - transitionFunction: Curves.decelerate.transform, - duration: cropRotateEditorConfigs.opacityOutsideCropAreaDuration, - ); + _interactionOpacityCtrl.forward(); } + /// Recalculated on every start, not only on the first one. The recognizer + /// restarts whenever the number of pointers changes, so this keeps the + /// dragged handle in place when a finger is lifted from a pinch. + _cropGrabOffset = _calcCropGrabOffset(details.localFocalPoint); + _scaleAllowUpdateHelper = false; _onScaleAllowUpdateDebounce(() { _scaleAllowUpdateHelper = true; @@ -1527,22 +1609,166 @@ class CropRotateEditorState extends State _blockInteraction = false; } - /// Calculates the offset of the editor screen. + /// Converts a global pointer position into the local coordinate space of the + /// editor body, the box [editorBodySize] describes. /// - /// This method determines the position of the editor content on the screen - /// by converting the local coordinates of the render box to global - /// coordinates. + /// The body is not aligned with the screen, it sits below the app-bar and can + /// be inset horizontally by [CropRotateEditorConfigs.maxWidthFactor], so a + /// raw pointer position must not be compared against [editorBodySize]. + Offset _toEditorBodyPosition(Offset globalPosition) { + var renderObject = _editorContentKey.currentContext?.findRenderObject(); + if (renderObject is! RenderBox) return globalPosition; + + return renderObject.globalToLocal(globalPosition); + } + + /// Converts a pointer position from the local space of the gesture detector + /// into the coordinate space of [cropRect]. + Offset _toCropHandlePosition( + Offset localPosition, { + required double zoom, + required Offset translateOffset, + }) { + Offset offset = + _getRealHitPoint(zoom: zoom, position: localPosition) + + translateOffset * zoom; + + double halfViewRectW = _viewRect.width / 2; + double halfViewRectH = _viewRect.height / 2; + + double circleGapX = 0; + double circleGapY = 0; + + if (cropMode == CropMode.oval) { + circleGapX = + sqrt( + pow(halfViewRectW, 2) - pow(min(offset.dy.abs(), halfViewRectW), 2), + ) - + halfViewRectW; + circleGapY = + sqrt( + pow(halfViewRectH, 2) - pow(min(offset.dx.abs(), halfViewRectH), 2), + ) - + halfViewRectH; + + circleGapX *= -offset.dx.sign; + circleGapY *= -offset.dy.sign; + } + + return Offset( + offset.dx + halfViewRectW + _cropSpaceHorizontal / 2 + circleGapX, + offset.dy + halfViewRectH + _cropSpaceVertical / 2 + circleGapY, + ); + } + + /// Returns how far the pointer sits away from the crop handle it grabbed. /// - /// Returns an [Offset] representing the position of the editor content. - /// If the editor content context is null, it returns [Offset.zero]. - Offset _calculateEditorScreenOffset() { - if (_editorContentKey.currentContext == null) return Offset.zero; + /// See [_cropGrabOffset]. + Offset _calcCropGrabOffset(Offset localPosition) { + if (_currentCropAreaPart == CropAreaPart.none || + _currentCropAreaPart == CropAreaPart.inside) { + return Offset.zero; + } + + Offset pointer = _toCropHandlePosition( + localPosition, + zoom: _startingPinchScale, + translateOffset: _startingTranslate, + ); + + return Offset( + switch (_currentCropAreaPart) { + CropAreaPart.left || + CropAreaPart.topLeft || + CropAreaPart.bottomLeft => pointer.dx - cropRect.left, + CropAreaPart.right || + CropAreaPart.topRight || + CropAreaPart.bottomRight => pointer.dx - cropRect.right, + _ => 0, + }, + switch (_currentCropAreaPart) { + CropAreaPart.top || + CropAreaPart.topLeft || + CropAreaPart.topRight => pointer.dy - cropRect.top, + CropAreaPart.bottom || + CropAreaPart.bottomLeft || + CropAreaPart.bottomRight => pointer.dy - cropRect.bottom, + _ => 0, + }, + ); + } + + /// Resizes [rect] from the dragged corner while keeping the locked aspect + /// ratio [_ratio], anchored at the opposite corner. + /// + /// [pointer] is projected onto the diagonal the ratio allows, so the corner + /// follows the pointer in both directions instead of tracking its horizontal + /// movement only. + Rect _resizeCornerToRatio({ + required Rect rect, + required Offset pointer, + required Rect bounds, + required double minSize, + }) { + bool isLeft = + _currentCropAreaPart == CropAreaPart.topLeft || + _currentCropAreaPart == CropAreaPart.bottomLeft; + bool isTop = + _currentCropAreaPart == CropAreaPart.topLeft || + _currentCropAreaPart == CropAreaPart.topRight; + + double anchorX = isLeft ? rect.right : rect.left; + double anchorY = isTop ? rect.bottom : rect.top; + + double pointerWidth = (pointer.dx - anchorX) * (isLeft ? -1 : 1); + double pointerHeight = (pointer.dy - anchorY) * (isTop ? -1 : 1); + + /// Closest point on the `height == width * _ratio` diagonal. + double width = + (pointerWidth + pointerHeight * _ratio) / (1 + _ratio * _ratio); + + double maxWidth = min( + isLeft ? anchorX - bounds.left : bounds.right - anchorX, + (isTop ? anchorY - bounds.top : bounds.bottom - anchorY) / _ratio, + ); + width = width.safeMinClamp(minSize, maxWidth); + double height = width * _ratio; + + return Rect.fromLTRB( + isLeft ? anchorX - width : anchorX, + isTop ? anchorY - height : anchorY, + isLeft ? anchorX : anchorX + width, + isTop ? anchorY : anchorY + height, + ); + } - final RenderBox renderBox = - _editorContentKey.currentContext!.findRenderObject() as RenderBox; - final Offset position = renderBox.localToGlobal(Offset.zero); + /// Restores the locked aspect ratio [_ratio] after an edge handle changed one + /// side of [rect], growing the opposite axis around the center and keeping + /// the result inside [bounds]. + Rect _resizeEdgeToRatio({ + required Rect rect, + required Rect bounds, + required bool fromWidth, + }) { + double width = fromWidth ? rect.width : rect.height / _ratio; + width = min(width, min(bounds.width, bounds.height / _ratio)); + + Rect result = Rect.fromCenter( + center: rect.center, + width: width, + height: width * _ratio, + ); - return position; + /// Shift the rect back inside the image when the opposite axis grew over + /// one of the edges. + double shiftX = 0; + double shiftY = 0; + if (result.left < bounds.left) shiftX = bounds.left - result.left; + if (result.right > bounds.right) shiftX = bounds.right - result.right; + if (result.top < bounds.top) shiftY = bounds.top - result.top; + if (result.bottom > bounds.bottom) shiftY = bounds.bottom - result.bottom; + + return result.shift(Offset(shiftX, shiftY)); } void _onScaleUpdate(ScaleUpdateDetails details) { @@ -1553,7 +1779,9 @@ class CropRotateEditorState extends State } _blockInteraction = true; if (details.pointerCount == 2) { - setScale(details.scale); + _pinchScaleBaseline ??= details.scale; + double baseline = _pinchScaleBaseline!; + setScale(baseline > 0 ? details.scale / baseline : details.scale); } else { if (_currentCropAreaPart != CropAreaPart.none && _currentCropAreaPart != CropAreaPart.inside) { @@ -1575,33 +1803,19 @@ class CropRotateEditorState extends State cropRotateEditorConfigs.style.cropCornerLength * 2.25; double minCornerDistance = outsidePadding + cornerGap; - double halfViewRectW = _viewRect.width / 2; - double halfViewRectH = _viewRect.height / 2; - - double circleGapX = 0; - double circleGapY = 0; - - if (cropMode == CropMode.oval) { - circleGapX = - sqrt( - pow(halfViewRectW, 2) - - pow(min(offset.dy.abs(), halfViewRectW), 2), - ) - - halfViewRectW; - circleGapY = - sqrt( - pow(halfViewRectH, 2) - - pow(min(offset.dx.abs(), halfViewRectH), 2), - ) - - halfViewRectH; - - circleGapX *= -offset.dx.sign; - circleGapY *= -offset.dy.sign; - } + /// The position of the dragged handle. `_cropGrabOffset` keeps the + /// handle where the pointer grabbed it instead of snapping it onto the + /// pointer with the first move event. + Offset handlePosition = + _toCropHandlePosition( + details.localFocalPoint, + zoom: _startingPinchScale, + translateOffset: _startingTranslate, + ) - + _cropGrabOffset; - double dx = - offset.dx + halfViewRectW + halfSpaceHorizontal + circleGapX; - double dy = offset.dy + halfViewRectH + halfSpaceVertical + circleGapY; + double dx = handlePosition.dx; + double dy = handlePosition.dy; double maxRight = cropRect.right + outsidePadding - minCornerDistance; double maxBottom = cropRect.bottom + outsidePadding - minCornerDistance; @@ -1659,21 +1873,14 @@ class CropRotateEditorState extends State doubleInteractiveArea, ); - double outsideHitPosY = - details.focalPoint.dy - - _editorScreenOffsetHelper.dy - - (_hasToolbar ? kToolbarHeight : 0) - - MediaQuery.paddingOf(context).top; + Offset bodyPosition = _toEditorBodyPosition(details.focalPoint); - bool outsideLeft = - details.focalPoint.dx - _editorScreenOffsetHelper.dx < - zoomOutHitAreaX; + bool outsideLeft = bodyPosition.dx < zoomOutHitAreaX; bool outsideRight = - details.focalPoint.dx - _editorScreenOffsetHelper.dx > - editorBodySize.width - zoomOutHitAreaX; - bool outsideTop = outsideHitPosY < zoomOutHitAreaY; + bodyPosition.dx > editorBodySize.width - zoomOutHitAreaX; + bool outsideTop = bodyPosition.dy < zoomOutHitAreaY; bool outsideBottom = - outsideHitPosY > editorBodySize.height - zoomOutHitAreaY; + bodyPosition.dy > editorBodySize.height - zoomOutHitAreaY; // Scale outside when the user move outside the scale area if (!isFreeAspectRatio && @@ -1757,38 +1964,39 @@ class CropRotateEditorState extends State break; } - if (_ratio >= 0 && cropRect.size.aspectRatio != _ratio) { - if (_currentCropAreaPart == CropAreaPart.left || - _currentCropAreaPart == CropAreaPart.right) { - cropRect = Rect.fromCenter( - center: cropRect.center, - width: cropRect.width, - height: cropRect.width * _ratio, - ); - } else if (_currentCropAreaPart == CropAreaPart.top || - _currentCropAreaPart == CropAreaPart.bottom) { - cropRect = Rect.fromCenter( - center: cropRect.center, - width: cropRect.height / _ratio, - height: cropRect.height, - ); - } else if (_currentCropAreaPart == CropAreaPart.topLeft || - _currentCropAreaPart == CropAreaPart.topRight) { - double gapBottom = _viewRect.height - cropRect.bottom; - cropRect = Rect.fromLTRB( - cropRect.left, - _viewRect.height - gapBottom - cropRect.width * _ratio, - cropRect.right, - cropRect.bottom, - ); - } else if (_currentCropAreaPart == CropAreaPart.bottomLeft || - _currentCropAreaPart == CropAreaPart.bottomRight) { - cropRect = Rect.fromLTRB( - cropRect.left, - cropRect.top, - cropRect.right, - cropRect.width * _ratio + cropRect.top, - ); + if (_ratio >= 0) { + Rect bounds = Rect.fromLTRB(minLeft, minTop, minRight, minBottom); + + switch (_currentCropAreaPart) { + case CropAreaPart.left: + case CropAreaPart.right: + cropRect = _resizeEdgeToRatio( + rect: cropRect, + bounds: bounds, + fromWidth: true, + ); + break; + case CropAreaPart.top: + case CropAreaPart.bottom: + cropRect = _resizeEdgeToRatio( + rect: cropRect, + bounds: bounds, + fromWidth: false, + ); + break; + case CropAreaPart.topLeft: + case CropAreaPart.topRight: + case CropAreaPart.bottomLeft: + case CropAreaPart.bottomRight: + cropRect = _resizeCornerToRatio( + rect: cropRect, + pointer: Offset(dx, dy), + bounds: bounds, + minSize: cornerGap, + ); + break; + default: + break; } } } @@ -1819,22 +2027,20 @@ class CropRotateEditorState extends State ); } - if (_blockInteraction || details.pointerCount > 2) return; + /// [ScaleGestureRecognizer] also reports an end every time the number of + /// pointers changes, so it fires in the middle of a pinch as soon as the + /// second finger touches down. Finalizing the crop here would animate the + /// selection back to the view rect and block the following + /// [_onScaleStart], which leaves the pinch working with a stale scale + /// baseline and makes the zoom jump. + if (_blockInteraction || details.pointerCount > 0) return; _blockInteraction = true; _interactionActive = false; _onScaleEndDebounce(() { if (_activePointers <= 0) { _scaleStarted = false; - loopWithTransitionTiming( - (double curveT) { - _interactionOpacityProgress = 1 - 1 * curveT; - cropPainterKey.currentState!.setForegroundPainter(cropPainter); - }, - mounted: mounted, - transitionFunction: Curves.decelerate.transform, - duration: cropRotateEditorConfigs.opacityOutsideCropAreaDuration, - ); + _interactionOpacityCtrl.reverse(); } }); @@ -1903,6 +2109,13 @@ class CropRotateEditorState extends State ); cropRect = interpolatedRect(startCropRect, targetCropRect, curveT); + + /// While tilted, [_setOffsetLimits] auto-zooms and never goes below + /// `manualScaleFactor`. Keeping that floor in sync with the animated + /// zoom lets the bounds only lift it further where the tilt requires + /// it. Without this the tilt bounds overwrite the zoom on every frame + /// and the image jumps around while the crop area animates back. + manualScaleFactor = userScaleFactor; _setOffsetLimits( rect: _ratio < 0 ? interpolatedRect(initRect, targetCropRect, curveT) @@ -2019,12 +2232,7 @@ class CropRotateEditorState extends State final double imgH = _renderedImgConstraints.maxHeight; if (imgW == 0 || imgH == 0) return true; - final bool isTilted = - tiltRotateAngle != 0 || - tiltHorizontalAngle != 0 || - tiltVerticalAngle != 0; - - if (!isTilted) { + if (!_isTilted) { // Fast path: axis-aligned clamp (unchanged behavior). Keep the manual // zoom floor in sync so a following tilt zooms relative to it. _clampTranslateAxisAligned(r); @@ -2296,7 +2504,6 @@ class CropRotateEditorState extends State isTiltEditorVisible: _isTiltEditorActive, tiltMode: _tiltMode, child: SafeArea( - key: _editorContentKey, top: cropRotateEditorConfigs.safeArea.top, bottom: cropRotateEditorConfigs.safeArea.bottom, left: cropRotateEditorConfigs.safeArea.left, @@ -2364,14 +2571,11 @@ class CropRotateEditorState extends State /// back, rotate, aspect ratio, and done. PreferredSizeWidget? _buildAppBar(BoxConstraints constraints) { if (cropRotateEditorConfigs.widgets.appBar != null) { - var customToolbar = cropRotateEditorConfigs.widgets.appBar!.call( + return cropRotateEditorConfigs.widgets.appBar!.call( this, rebuildController.stream, ); - _hasToolbar = customToolbar != null; - return customToolbar; } - _hasToolbar = true; return CropEditorAppbar( configs: configs.cropRotateEditor, i18n: i18n.cropRotateEditor, @@ -2437,6 +2641,7 @@ class CropRotateEditorState extends State }); }, child: Stack( + key: _editorContentKey, children: [ if (_showFakeHero) _buildFakeHero() diff --git a/pubspec.yaml b/pubspec.yaml index 016b5f88..5c856002 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: pro_image_editor description: "A Flutter image editor: Seamlessly enhance your images with user-friendly editing features." -version: 13.3.0 +version: 13.3.1 homepage: https://github.com/hm21/pro_image_editor/ repository: https://github.com/hm21/pro_image_editor/ documentation: https://github.com/hm21/pro_image_editor/ diff --git a/test/features/crop_rotate_editor/crop_rotate_editor_test.dart b/test/features/crop_rotate_editor/crop_rotate_editor_test.dart index 13634440..b82d2a0e 100644 --- a/test/features/crop_rotate_editor/crop_rotate_editor_test.dart +++ b/test/features/crop_rotate_editor/crop_rotate_editor_test.dart @@ -1,6 +1,7 @@ // ignore_for_file: invalid_use_of_protected_member // Flutter imports: +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,6 +11,8 @@ import 'package:pro_image_editor/core/models/editor_callbacks/pro_image_editor_c import 'package:pro_image_editor/core/models/editor_configs/pro_image_editor_configs.dart'; import 'package:pro_image_editor/core/models/init_configs/crop_rotate_editor_init_configs.dart'; import 'package:pro_image_editor/features/crop_rotate_editor/crop_rotate_editor.dart'; +import 'package:pro_image_editor/features/crop_rotate_editor/utils/crop_aspect_ratios.dart'; +import 'package:pro_image_editor/features/crop_rotate_editor/widgets/outside_gestures/crop_rotate_gesture_detector.dart'; import '../../mock/mock_image.dart'; void main() { @@ -381,6 +384,204 @@ void main() { }); }); + group('CropRotateEditor corner drag', () { + /// The tests run on a desktop host, so the crop handle is picked up from + /// hover events and the drag area is configurable. + const double dragArea = 40; + + Future> pumpRatioEditor( + WidgetTester tester, + double ratio, + ) async { + final editorKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CropRotateEditor.memory( + mockMemoryImage, + key: editorKey, + initConfigs: CropRotateEditorInitConfigs( + theme: ThemeData.light(), + enableFakeHero: false, + mainImageSize: const Size(600, 800), + configs: ProImageEditorConfigs( + cropRotateEditor: CropRotateEditorConfigs( + initAspectRatio: ratio, + desktopCornerDragArea: dragArea, + animationDuration: Duration.zero, + cropDragAnimationDuration: Duration.zero, + fadeInOutsideCropAreaAnimationDuration: Duration.zero, + opacityOutsideCropAreaDuration: Duration.zero, + ), + imageGeneration: const ImageGenerationConfigs( + enableBackgroundGeneration: false, + enableIsolateGeneration: false, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + return editorKey; + } + + /// Returns the global position of the bottom-right corner handle. + /// + /// `cropRect` lives in the untransformed coordinate space of the crop + /// painter, while the gesture detector sits below the zoom and the + /// translation, so the point has to be mapped back through both. + Offset bottomRightHandle(WidgetTester tester, CropRotateEditorState state) { + final RenderBox box = + find.byType(CropRotateGestureDetector).evaluate().first.renderObject! + as RenderBox; + final Offset center = Offset(box.size.width / 2, box.size.height / 2); + final Offset local = + center + + (state.cropRect.bottomRight - center) / state.userScaleFactor - + state.translate; + + return box.localToGlobal(local); + } + + /// Grabs the bottom-right handle [inset] pixels inside the exact corner, + /// like a pointer that hits the handle but not its very tip. + Future grabBottomRight( + WidgetTester tester, + CropRotateEditorState state, { + double inset = 12, + }) async { + final Offset position = bottomRightHandle( + tester, + state, + ).translate(-inset, -inset); + + /// The handle is picked up from a hover event on desktop. + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: position.translate(-1, -1)); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(position); + await tester.pump(); + + await gesture.down(position); + await tester.pump(const Duration(milliseconds: 16)); + return gesture; + } + + testWidgets('follows the pointer without jumping on the first move', ( + WidgetTester tester, + ) async { + final editorKey = await pumpRatioEditor( + tester, + CropAspectRatios.original, + ); + final state = editorKey.currentState!; + + final Rect startRect = state.cropRect; + expect(startRect.size.aspectRatio, closeTo(3 / 4, 0.01)); + + final gesture = await grabBottomRight(tester, state); + + /// Nothing may happen before the pointer moves. + expect(state.cropRect, startRect); + + for (var i = 0; i < 2; i++) { + await gesture.moveBy(const Offset(-4, -4)); + await tester.pump(const Duration(milliseconds: 16)); + } + + /// The handle must not snap onto the pointer. It may only shrink by the + /// distance the pointer traveled, not by the 12 pixels between the + /// pointer and the handle. + expect(startRect.width - state.cropRect.width, greaterThan(0)); + expect(startRect.width - state.cropRect.width, lessThan(8)); + expect(state.cropRect.size.aspectRatio, closeTo(3 / 4, 0.01)); + + final double widthAfterFirstMove = state.cropRect.width; + + /// A vertical move must resize as well. The ratio is kept by moving both + /// axes instead of tracking the horizontal movement only. + await gesture.moveBy(const Offset(0, -20)); + await tester.pump(const Duration(milliseconds: 16)); + expect(state.cropRect.width, lessThan(widthAfterFirstMove)); + expect(state.cropRect.size.aspectRatio, closeTo(3 / 4, 0.01)); + + /// The anchored corner never moves. + expect(state.cropRect.left, closeTo(startRect.left, 0.01)); + expect(state.cropRect.top, closeTo(startRect.top, 0.01)); + + await gesture.up(); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + }); + + testWidgets('keeps the crop rect inside the image', ( + WidgetTester tester, + ) async { + final editorKey = await pumpRatioEditor( + tester, + CropAspectRatios.original, + ); + final state = editorKey.currentState!; + final Rect viewRect = state.cropRect; + + final gesture = await grabBottomRight(tester, state); + + /// Drag far outside the image. + for (var i = 0; i < 4; i++) { + await gesture.moveBy(const Offset(100, 100)); + await tester.pump(const Duration(milliseconds: 16)); + } + + expect(state.cropRect.right, lessThanOrEqualTo(viewRect.right + 0.01)); + expect(state.cropRect.bottom, lessThanOrEqualTo(viewRect.bottom + 0.01)); + + await gesture.up(); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + }); + + testWidgets('resizes without losing the tilt zoom', ( + WidgetTester tester, + ) async { + final editorKey = await pumpRatioEditor( + tester, + CropAspectRatios.original, + ); + final state = editorKey.currentState!; + + expect(state.userScaleFactor, 1); + + /// A tilted image no longer covers the crop area on its own, so the + /// editor auto-zooms to keep it covered. + state.tilt(TiltMode.rotate, 0.12); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + final double tiltZoom = state.userScaleFactor; + expect(tiltZoom, greaterThan(1)); + + final Rect startRect = state.cropRect; + final gesture = await grabBottomRight(tester, state); + + for (var i = 0; i < 6; i++) { + await gesture.moveBy(const Offset(-8, -8)); + await tester.pump(const Duration(milliseconds: 16)); + + /// The crop area must never be left uncovered while dragging. + expect(state.userScaleFactor, greaterThanOrEqualTo(tiltZoom - 0.01)); + } + expect(state.cropRect.width, lessThan(startRect.width)); + + await gesture.up(); + await tester.pumpAndSettle(const Duration(milliseconds: 400)); + + /// The smaller selection is zoomed up to fill the view again, and the + /// tilt is untouched by the resize. + expect(state.cropRect.width, closeTo(startRect.width, 0.01)); + expect(state.userScaleFactor, greaterThan(tiltZoom)); + expect(state.tiltRotateAngle, closeTo(0.12, 0.0001)); + }); + }); + group('CropRotateEditor Aspect Ratio Dialog Tests', () { testWidgets('Opens and selects an aspect ratio', ( WidgetTester tester, From bf0117a1ca5a1e4b636f08e9a36027fa9ef2cfb8 Mon Sep 17 00:00:00 2001 From: hm21 Date: Tue, 11 Aug 2026 12:06:59 +0200 Subject: [PATCH 2/2] fix(crop-rotate): address review findings on the gesture fixes - Stop the auto zoom-out when the recognizer reports a mid-gesture end, so it no longer fights the pinch that follows. - Only latch the pinch baseline on a usable span; a scale of `0` used to disable the normalization for the whole gesture. - Release `_blockInteraction` when a degenerate crop rect aborts the scale end, which previously froze the editor for good. - Return a floor instead of `maxScale` from `_minCoveringScale` and keep resizing when the zoom-out can't run. - Skip the zoom-out instead of comparing a raw global position when the editor body has no render object. - Dispose the overlay `CurvedAnimation` and refresh its duration on every transition. - Hoist the fixed-ratio corner branch above the free-form clamping, pass the dragged part into both ratio helpers and compute the pointer hit point once per update. --- .../crop_rotate_editor.dart | 342 ++++++++++-------- 1 file changed, 201 insertions(+), 141 deletions(-) diff --git a/lib/features/crop_rotate_editor/crop_rotate_editor.dart b/lib/features/crop_rotate_editor/crop_rotate_editor.dart index 291ae2ec..d4d6f1ac 100644 --- a/lib/features/crop_rotate_editor/crop_rotate_editor.dart +++ b/lib/features/crop_rotate_editor/crop_rotate_editor.dart @@ -317,13 +317,21 @@ class CropRotateEditorState extends State double get _minCoveringScale { if (!_isTilted) return 1; - return fitCropInsideTiltedImage( + var fit = fitCropInsideTiltedImage( baseTiltCorners: _baseTiltCorners(), cropSize: _viewRect.size, minScale: 1, maxScale: cropRotateEditorConfigs.maxScale, currentTranslate: translate, - ).scale; + ); + + /// A crop area that can't be covered at all reports the maximum scale, + /// which is an upper bound and would turn this floor into a ceiling. The + /// current zoom is returned instead, so callers only stop zooming out and + /// never zoom back in through this value. + if (!fit.fits) return userScaleFactor; + + return fit.scale; } /// Indicates whether a locked-aspect-ratio rotation animation is in progress. @@ -350,7 +358,7 @@ class CropRotateEditorState extends State late final AnimationController _interactionOpacityCtrl; /// The curved animation of [_interactionOpacityCtrl]. - late final Animation _interactionOpacityAnimation; + late final CurvedAnimation _interactionOpacityAnimation; /// The padding around the screen. final double _screenPadding = 20; @@ -653,10 +661,27 @@ class CropRotateEditorState extends State _bottomBarScrollCtrl.dispose(); rotateCtrl.dispose(); scaleCtrl.dispose(); + _interactionOpacityAnimation.dispose(); _interactionOpacityCtrl.dispose(); super.dispose(); } + /// Fades the area outside the crop area in or out. + /// + /// The duration is refreshed on every transition, so a configs change while + /// the editor is open takes effect right away instead of keeping the value + /// the controller was created with. + void _animateInteractionOpacity({required bool visible}) { + _interactionOpacityCtrl.duration = + cropRotateEditorConfigs.opacityOutsideCropAreaDuration; + + if (visible) { + _interactionOpacityCtrl.forward(); + } else { + _interactionOpacityCtrl.reverse(); + } + } + @override void setState(void Function() fn) { rebuildController.add(null); @@ -1591,7 +1616,7 @@ class CropRotateEditorState extends State if (!isDesktop) { _currentCropAreaPart = _determineCropAreaPart(details.localFocalPoint); } - _interactionOpacityCtrl.forward(); + _animateInteractionOpacity(visible: true); } /// Recalculated on every start, not only on the first one. The recognizer @@ -1615,24 +1640,31 @@ class CropRotateEditorState extends State /// The body is not aligned with the screen, it sits below the app-bar and can /// be inset horizontally by [CropRotateEditorConfigs.maxWidthFactor], so a /// raw pointer position must not be compared against [editorBodySize]. - Offset _toEditorBodyPosition(Offset globalPosition) { + /// + /// Returns `null` while the body has no render object. Falling back to the + /// raw global position would silently reintroduce that coordinate mismatch, + /// so callers skip their check instead. + Offset? _toEditorBodyPosition(Offset globalPosition) { var renderObject = _editorContentKey.currentContext?.findRenderObject(); - if (renderObject is! RenderBox) return globalPosition; + if (renderObject is! RenderBox) return null; return renderObject.globalToLocal(globalPosition); } /// Converts a pointer position from the local space of the gesture detector - /// into the coordinate space of [cropRect]. - Offset _toCropHandlePosition( + /// into the space the image occupies, measured from its center. + Offset _toImageCenterPosition( Offset localPosition, { required double zoom, required Offset translateOffset, }) { - Offset offset = - _getRealHitPoint(zoom: zoom, position: localPosition) + + return _getRealHitPoint(zoom: zoom, position: localPosition) + translateOffset * zoom; + } + /// Converts a pointer position produced by [_toImageCenterPosition] into the + /// coordinate space of [cropRect]. + Offset _toCropHandlePosition(Offset offset) { double halfViewRectW = _viewRect.width / 2; double halfViewRectH = _viewRect.height / 2; @@ -1671,9 +1703,11 @@ class CropRotateEditorState extends State } Offset pointer = _toCropHandlePosition( - localPosition, - zoom: _startingPinchScale, - translateOffset: _startingTranslate, + _toImageCenterPosition( + localPosition, + zoom: _startingPinchScale, + translateOffset: _startingTranslate, + ), ); return Offset( @@ -1705,17 +1739,15 @@ class CropRotateEditorState extends State /// follows the pointer in both directions instead of tracking its horizontal /// movement only. Rect _resizeCornerToRatio({ + required CropAreaPart part, required Rect rect, required Offset pointer, required Rect bounds, required double minSize, }) { bool isLeft = - _currentCropAreaPart == CropAreaPart.topLeft || - _currentCropAreaPart == CropAreaPart.bottomLeft; - bool isTop = - _currentCropAreaPart == CropAreaPart.topLeft || - _currentCropAreaPart == CropAreaPart.topRight; + part == CropAreaPart.topLeft || part == CropAreaPart.bottomLeft; + bool isTop = part == CropAreaPart.topLeft || part == CropAreaPart.topRight; double anchorX = isLeft ? rect.right : rect.left; double anchorY = isTop ? rect.bottom : rect.top; @@ -1746,10 +1778,12 @@ class CropRotateEditorState extends State /// side of [rect], growing the opposite axis around the center and keeping /// the result inside [bounds]. Rect _resizeEdgeToRatio({ + required CropAreaPart part, required Rect rect, required Rect bounds, - required bool fromWidth, }) { + bool fromWidth = part == CropAreaPart.left || part == CropAreaPart.right; + double width = fromWidth ? rect.width : rect.height / _ratio; width = min(width, min(bounds.width, bounds.height / _ratio)); @@ -1779,18 +1813,21 @@ class CropRotateEditorState extends State } _blockInteraction = true; if (details.pointerCount == 2) { - _pinchScaleBaseline ??= details.scale; - double baseline = _pinchScaleBaseline!; - setScale(baseline > 0 ? details.scale / baseline : details.scale); + /// A degenerate span reports a scale of `0`. Latching that as the + /// baseline would leave every following update un-normalized, so the + /// update is skipped until the recognizer reports a usable span. + if (details.scale > 0) { + _pinchScaleBaseline ??= details.scale; + setScale(details.scale / _pinchScaleBaseline!); + } } else { if (_currentCropAreaPart != CropAreaPart.none && _currentCropAreaPart != CropAreaPart.inside) { - Offset offset = - _getRealHitPoint( - zoom: _startingPinchScale, - position: details.localFocalPoint, - ) + - _startingTranslate * _startingPinchScale; + Offset offset = _toImageCenterPosition( + details.localFocalPoint, + zoom: _startingPinchScale, + translateOffset: _startingTranslate, + ); double imgW = _renderedImgConstraints.maxWidth; double imgH = _renderedImgConstraints.maxHeight; @@ -1806,13 +1843,7 @@ class CropRotateEditorState extends State /// The position of the dragged handle. `_cropGrabOffset` keeps the /// handle where the pointer grabbed it instead of snapping it onto the /// pointer with the first move event. - Offset handlePosition = - _toCropHandlePosition( - details.localFocalPoint, - zoom: _startingPinchScale, - translateOffset: _startingTranslate, - ) - - _cropGrabOffset; + Offset handlePosition = _toCropHandlePosition(offset) - _cropGrabOffset; double dx = handlePosition.dx; double dy = handlePosition.dy; @@ -1873,131 +1904,146 @@ class CropRotateEditorState extends State doubleInteractiveArea, ); - Offset bodyPosition = _toEditorBodyPosition(details.focalPoint); + /// Without a body position the pointer can't be compared against + /// [editorBodySize], so the zoom-out is skipped rather than triggered + /// at the wrong place. + Offset? bodyPosition = _toEditorBodyPosition(details.focalPoint); - bool outsideLeft = bodyPosition.dx < zoomOutHitAreaX; + bool outsideLeft = + bodyPosition != null && bodyPosition.dx < zoomOutHitAreaX; bool outsideRight = + bodyPosition != null && bodyPosition.dx > editorBodySize.width - zoomOutHitAreaX; - bool outsideTop = bodyPosition.dy < zoomOutHitAreaY; + bool outsideTop = + bodyPosition != null && bodyPosition.dy < zoomOutHitAreaY; bool outsideBottom = + bodyPosition != null && bodyPosition.dy > editorBodySize.height - zoomOutHitAreaY; // Scale outside when the user move outside the scale area - if (!isFreeAspectRatio && - (outsideLeft || outsideRight || outsideTop || outsideBottom)) { - if (!_activeScaleOut) { - _activeScaleOut = true; - _zoomOutside(); - } - } else if (!_activeScaleOut || - (offset.dx.abs() < _viewRect.width / 2 - _interactiveCornerArea)) { - _activeScaleOut = false; - switch (_currentCropAreaPart) { - case CropAreaPart.topLeft: - cropRect = Rect.fromLTRB( - dx.safeMinClamp(minLeft, maxRight), - dy.safeMinClamp(minTop, maxBottom), - cropRect.right, - cropRect.bottom, - ); - - break; - case CropAreaPart.topRight: - cropRect = Rect.fromLTRB( - cropRect.left, - dy.safeMinClamp(minTop, maxBottom), - dx.safeMinClamp(cornerGap + cropRect.left, minRight), - cropRect.bottom, - ); - - break; - case CropAreaPart.bottomLeft: - cropRect = Rect.fromLTRB( - dx.safeMinClamp(minLeft, maxRight), - cropRect.top, - cropRect.right, - dy.safeMinClamp(cornerGap + cropRect.top, minBottom), - ); - break; - case CropAreaPart.bottomRight: - cropRect = Rect.fromLTRB( - cropRect.left, - cropRect.top, - dx.safeMinClamp(cornerGap + cropRect.left, minRight), - dy.safeMinClamp(cornerGap + cropRect.top, minBottom), - ); - break; - case CropAreaPart.left: - cropRect = Rect.fromLTRB( - dx.safeMinClamp(minLeft, maxRight), - cropRect.top, - cropRect.right, - cropRect.bottom, - ); - _setOffsetLimits(); - break; - case CropAreaPart.right: - cropRect = Rect.fromLTRB( - cropRect.left, - cropRect.top, - dx.safeMinClamp(cornerGap + cropRect.left, minRight), - cropRect.bottom, - ); - break; - case CropAreaPart.top: - cropRect = Rect.fromLTRB( - cropRect.left, - dy.safeMaxClamp(minTop, maxBottom), - cropRect.right, - cropRect.bottom, - ); - break; - case CropAreaPart.bottom: - cropRect = Rect.fromLTRB( - cropRect.left, - cropRect.top, - cropRect.right, - dy.safeMinClamp(cornerGap + cropRect.top, minBottom), - ); - break; - default: - break; - } + bool zoomOutside = + !isFreeAspectRatio && + (outsideLeft || outsideRight || outsideTop || outsideBottom); + if (zoomOutside && !_activeScaleOut) { + _activeScaleOut = true; + _zoomOutside(); + } - if (_ratio >= 0) { - Rect bounds = Rect.fromLTRB(minLeft, minTop, minRight, minBottom); + /// [_zoomOutside] clears the flag right away when the zoom already sits + /// on its floor, so a crop area that can't zoom out any further keeps + /// resizing instead of freezing while the pointer sits in the band. + if (!_activeScaleOut || + (!zoomOutside && + offset.dx.abs() < + _viewRect.width / 2 - _interactiveCornerArea)) { + _activeScaleOut = false; + bool isCorner = + _currentCropAreaPart == CropAreaPart.topLeft || + _currentCropAreaPart == CropAreaPart.topRight || + _currentCropAreaPart == CropAreaPart.bottomLeft || + _currentCropAreaPart == CropAreaPart.bottomRight; + + if (_ratio >= 0 && isCorner) { + /// A locked ratio anchors the rect at the opposite corner and + /// derives both sides from the pointer, so the free-form clamping + /// in the switch below would only be overwritten again. + cropRect = _resizeCornerToRatio( + part: _currentCropAreaPart, + rect: cropRect, + pointer: Offset(dx, dy), + bounds: Rect.fromLTRB(minLeft, minTop, minRight, minBottom), + minSize: cornerGap, + ); + } else { switch (_currentCropAreaPart) { + case CropAreaPart.topLeft: + cropRect = Rect.fromLTRB( + dx.safeMinClamp(minLeft, maxRight), + dy.safeMinClamp(minTop, maxBottom), + cropRect.right, + cropRect.bottom, + ); + break; + case CropAreaPart.topRight: + cropRect = Rect.fromLTRB( + cropRect.left, + dy.safeMinClamp(minTop, maxBottom), + dx.safeMinClamp(cornerGap + cropRect.left, minRight), + cropRect.bottom, + ); + break; + case CropAreaPart.bottomLeft: + cropRect = Rect.fromLTRB( + dx.safeMinClamp(minLeft, maxRight), + cropRect.top, + cropRect.right, + dy.safeMinClamp(cornerGap + cropRect.top, minBottom), + ); + break; + case CropAreaPart.bottomRight: + cropRect = Rect.fromLTRB( + cropRect.left, + cropRect.top, + dx.safeMinClamp(cornerGap + cropRect.left, minRight), + dy.safeMinClamp(cornerGap + cropRect.top, minBottom), + ); + break; case CropAreaPart.left: + cropRect = Rect.fromLTRB( + dx.safeMinClamp(minLeft, maxRight), + cropRect.top, + cropRect.right, + cropRect.bottom, + ); + _setOffsetLimits(); + break; case CropAreaPart.right: - cropRect = _resizeEdgeToRatio( - rect: cropRect, - bounds: bounds, - fromWidth: true, + cropRect = Rect.fromLTRB( + cropRect.left, + cropRect.top, + dx.safeMinClamp(cornerGap + cropRect.left, minRight), + cropRect.bottom, ); break; case CropAreaPart.top: - case CropAreaPart.bottom: - cropRect = _resizeEdgeToRatio( - rect: cropRect, - bounds: bounds, - fromWidth: false, + cropRect = Rect.fromLTRB( + cropRect.left, + dy.safeMaxClamp(minTop, maxBottom), + cropRect.right, + cropRect.bottom, ); break; - case CropAreaPart.topLeft: - case CropAreaPart.topRight: - case CropAreaPart.bottomLeft: - case CropAreaPart.bottomRight: - cropRect = _resizeCornerToRatio( - rect: cropRect, - pointer: Offset(dx, dy), - bounds: bounds, - minSize: cornerGap, + case CropAreaPart.bottom: + cropRect = Rect.fromLTRB( + cropRect.left, + cropRect.top, + cropRect.right, + dy.safeMinClamp(cornerGap + cropRect.top, minBottom), ); break; default: break; } + + /// An edge handle only changed one side, so the opposite axis is + /// grown back around the center to restore the locked ratio. + if (_ratio >= 0) { + switch (_currentCropAreaPart) { + case CropAreaPart.left: + case CropAreaPart.right: + case CropAreaPart.top: + case CropAreaPart.bottom: + cropRect = _resizeEdgeToRatio( + part: _currentCropAreaPart, + rect: cropRect, + bounds: Rect.fromLTRB(minLeft, minTop, minRight, minBottom), + ); + break; + default: + break; + } + } } } @@ -2033,20 +2079,34 @@ class CropRotateEditorState extends State /// selection back to the view rect and block the following /// [_onScaleStart], which leaves the pinch working with a stale scale /// baseline and makes the zoom jump. - if (_blockInteraction || details.pointerCount > 0) return; + /// + /// The auto zoom-out is still stopped, otherwise it keeps looping in the + /// background and fights the pinch that follows. + if (details.pointerCount > 0) { + _activeScaleOut = false; + return; + } + if (_blockInteraction) return; _blockInteraction = true; _interactionActive = false; _onScaleEndDebounce(() { if (_activePointers <= 0) { _scaleStarted = false; - _interactionOpacityCtrl.reverse(); + _animateInteractionOpacity(visible: false); } }); if (cropRect != _viewRect) { + /// A degenerate crop rect has nothing to animate back. Returning without + /// releasing [_blockInteraction] would freeze every following gesture. + /// /// Return is important for tests - if (cropRect.isEmpty) return; + if (cropRect.isEmpty) { + _activeScaleOut = false; + _blockInteraction = false; + return; + } Rect initRect = Rect.fromCenter( center: _viewRect.center,