Skip to content

fix(crop-rotate): stop crop handles, pinch and tilt from jumping - #855

Merged
hm21 merged 2 commits into
stablefrom
fix/crop-gesture-jumps
Aug 11, 2026
Merged

fix(crop-rotate): stop crop handles, pinch and tilt from jumping#855
hm21 merged 2 commits into
stablefrom
fix/crop-gesture-jumps

Conversation

@hm21

@hm21 hm21 commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Problem

Resizing the crop area felt broken: grabbing a corner and moving slowly produced an instant jump before the handle started following the finger. Pinch-to-zoom had the same jump, the darkening outside the crop area snapped instead of fading, and once a tilt was applied the image jumped around after every resize.

Cause

The crop handles were positioned absolutely on the pointer. ScaleGestureRecognizer only reports a drag after the pointer moved past kPanSlop (36 logical px), and the handle hit area is kMinInteractiveDimension (48 px) wide — both distances were applied at once the moment the gesture was recognized. Pinch had the equivalent problem with the scale slop.

On top of that, five independent defects in the same interaction:

  • Fixed aspect ratio corner drags derived the height from the width only (bottom = top + width * ratio), so a vertical drag did nothing and diagonal drags drifted away from the finger. The result was also never clamped, so the selection could leave the image.
  • onScaleEnd fires on every pointer-count change, not just at the end of a gesture. The second finger of a pinch therefore ran the full crop teardown (animating the selection back and blocking the following onScaleStart), leaving the pinch with a stale scale baseline.
  • The auto zoom-out hit area compared a raw screen x against editorBodySize.width, which on Android is only 90 % of the screen and centered (maxWidthFactor). That created a dead band along the right edge where drag updates were silently dropped. The vertical test hand-rolled - kToolbarHeight - MediaQuery.padding.top, which breaks with custom or absent app bars.
  • The overlay opacity was animated by two competing loopWithTransitionTiming loops that each restarted from 0/1, so interrupting one snapped the brightness.
  • With a tilt applied, _setOffsetLimits overrode the zoom of the crop-end animation on every frame (its manualScaleFactor floor was stale), and _zoomOutside looped against the minimum zoom the tilt requires while calcCropRect() reset the selection every 16 ms.

Changes

_cropGrabOffset Captures pointer → handle distance at gesture start (including the swallowed slop) and subtracts it on every update. Recomputed on each start so lifting a finger from a pinch does not teleport the handle.
_pinchScaleBaseline Zooms relative to the first reported scale instead of the raw one.
_resizeCornerToRatio Projects the pointer onto the ratio diagonal, anchored at the opposite corner, clamped to the image bounds.
_resizeEdgeToRatio Keeps the ratio for edge handles and shifts the result back inside the image.
_toEditorBodyPosition Converts pointer positions into the editor body's coordinate space via globalToLocal.
_interactionOpacityCtrl AnimationController + CurvedAnimation (same curve both directions, so reversing midway stays continuous).
_onScaleEnd Returns early while pointers remain (details.pointerCount > 0).
_isTilted / _minCoveringScale The crop-end animation keeps manualScaleFactor in sync so the tilt bounds may only lift the zoom, and _zoomOutside floors at the zoom the tilt requires.

_hasToolbar and _editorScreenOffsetHelper are gone with the hand-rolled offset math; _editorContentKey moved from the outer SafeArea to the editor body.

Tests

Three new cases in CropRotateEditor corner drag:

  • follows the pointer without jumping on the first move — grabs the handle 12 px inside the corner and asserts the selection only shrinks by the distance the pointer traveled, that a purely vertical move resizes too, and that the anchored corner never moves.
  • keeps the crop rect inside the image — dragging far outside must not push the selection past the view rect.
  • resizes without losing the tilt zoom — the selection stays covered during the drag and is zoomed up to the correct target afterwards, with the tilt untouched.

Full suite: 532 passing, analyzer clean.

The frame-by-frame smoothness of the crop-end animation is not asserted directly — loopWithTransitionTiming drives itself off wall-clock DateTime.now(), which degenerates under flutter_test's fake clock. The tests lock in the end state instead.

Note

Compensating the slop means the handle now permanently trails the finger by however far it moved before the gesture was recognized. That is the trade-off for removing the jump.

hm21 added 2 commits August 11, 2026 11:33
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.
- 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.
@hm21
hm21 merged commit 36e9092 into stable Aug 11, 2026
1 check passed
@hm21
hm21 deleted the fix/crop-gesture-jumps branch August 11, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant