From da3441cd6b6086effc560dcc3830cecc2d006f32 Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 23 Jul 2026 11:03:35 +0200 Subject: [PATCH 1/2] fix(main-editor): ignore spurious scale-end on mid-gesture pointer change (#850) Flutter's ScaleGestureRecognizer fires a spurious onScaleEnd (followed by a fresh onScaleStart) whenever the active pointer count changes mid-gesture, e.g. when a third finger touches the screen while two fingers are rotating a layer. _onScaleEnd then ran its full interaction teardown (replacing the last screenshot, mutating the history stack, clearing the selection), which corrupted the history/screenshot stack and, on iOS, crashed the editor with a `_dependents.isEmpty` framework assertion. - _onScaleEnd now returns early when details.pointerCount > 0 (reconfiguration rather than a real end); the interactive viewer still receives the event so its start/end pairing stays balanced. - _onScaleStart no longer adds a second history entry when a transform is already in progress. Adds a deterministic multi-pointer widget test reproducing the crash. --- CHANGELOG.md | 3 + lib/features/main_editor/main_editor.dart | 29 +++- .../multitouch_third_finger_test.dart | 131 ++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 test/features/main_editor/multitouch_third_finger_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b24788c..1bbad10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 13.2.2 +- **FIX**(main-editor): Ignore the spurious scale-end Flutter fires when a finger is added or removed mid-gesture (e.g. a third finger during a two-finger layer rotation). It previously ran the full interaction teardown, corrupting the history stack and crashing on iOS with a `_dependents.isEmpty` assertion (#850). + ## 13.2.1 - **FIX**(text-editor): Stop the rounded background text from growing and reflowing the moment editing completes (#849). The editing preview now reserves the same hit-box padding as the finished layer and wraps the background at the same column as the editable text, so the background no longer shifts or changes line count when editing ends. diff --git a/lib/features/main_editor/main_editor.dart b/lib/features/main_editor/main_editor.dart index bb205557..a0dadcbe 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -1430,7 +1430,14 @@ class ProImageEditorState extends State /// interaction. /// Important: No screenshot is taken at this point; it will be captured /// after the layer interaction is completed. - if (hasSelectedLayers) addHistory(blockCaptureScreenshot: true); + /// + /// The `!isLayerBeingTransformed` guard prevents a second history entry + /// when Flutter's [ScaleGestureRecognizer] restarts the gesture after a + /// mid-gesture pointer change (e.g. a third finger touching down), which + /// would otherwise corrupt the history/screenshot stack (issue #850). + if (hasSelectedLayers && !isLayerBeingTransformed) { + addHistory(blockCaptureScreenshot: true); + } _checkInteractiveViewer(); isLayerBeingTransformed = hasSelectedLayers; layerInteractionManager.onScaleStart( @@ -1572,6 +1579,26 @@ class ProImageEditorState extends State /// lines and flags. void _onScaleEnd(ScaleEndDetails details) async { mainEditorCallbacks?.handleScaleEnd(details); + + /// Flutter's [ScaleGestureRecognizer] fires a spurious `onScaleEnd` + /// (immediately followed by a fresh `onScaleStart`) whenever the number of + /// active pointers changes mid-gesture — for example when a third finger + /// touches the screen while two fingers are still rotating a layer. In that + /// case the fingers are not actually lifted ([details.pointerCount] > 0), + /// so we must not run the destructive finish logic below (removing the last + /// screenshot, mutating the history stack and clearing the selection). + /// Doing so corrupts the history/screenshot stack and, on iOS, crashes the + /// editor with a `_dependents.isEmpty` framework assertion (issue #850). + /// + /// The interactive viewer still needs the event to keep its own + /// start/end pairing balanced. + if (details.pointerCount > 0) { + if (!hasSelectedLayers && !_layerDragSelectionService.isActive) { + interactiveViewer.currentState?.onScaleEnd(details); + } + return; + } + if (selectedLayers.isNotEmpty) { mainEditorCallbacks?.handleLayerInteractionEnd(List.of(selectedLayers)); } diff --git a/test/features/main_editor/multitouch_third_finger_test.dart b/test/features/main_editor/multitouch_third_finger_test.dart new file mode 100644 index 00000000..77107e16 --- /dev/null +++ b/test/features/main_editor/multitouch_third_finger_test.dart @@ -0,0 +1,131 @@ +// Flutter imports: +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +// Package imports: +import 'package:flutter_test/flutter_test.dart'; + +// Project imports: +import 'package:pro_image_editor/pro_image_editor.dart'; + +import '../../mock/mock_image.dart'; + +/// Reproduction for issue #850: +/// "Editor freezes after a three-finger gesture while rotating an emoji layer". +/// +/// Steps from the report: +/// 1. Add an emoji layer. +/// 2. Rotate it with two fingers (keep both fingers down). +/// 3. Tap the screen with a third finger. +/// +/// On iOS this throws `assert(_dependents.isEmpty)` from +/// `InheritedElement.debugDeactivated()` and freezes the editor. +void main() { + const configs = ProImageEditorConfigs( + progressIndicatorConfigs: ProgressIndicatorConfigs( + widgets: ProgressIndicatorWidgets( + circularProgressIndicator: SizedBox.shrink(), + ), + ), + imageGeneration: ImageGenerationConfigs( + enableIsolateGeneration: false, + enableBackgroundGeneration: false, + ), + ); + + Future pumpEditor(WidgetTester tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: ProImageEditor.memory( + mockMemoryImage, + key: key, + configs: configs, + callbacks: ProImageEditorCallbacks( + onImageEditingComplete: (Uint8List bytes) async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return key.currentState!; + } + + testWidgets( + 'adding a third finger while rotating an emoji layer does not throw', + (tester) async { + final state = await pumpEditor(tester); + + final layer = EmojiLayer( + emoji: '😀', + offset: Offset.zero, + scale: 1, + rotation: 0, + ); + state.addLayer( + layer, + autoCorrectZoomOffset: false, + autoCorrectZoomScale: false, + ); + await tester.pump(); + + // Select the emoji so the two-finger gesture scales/rotates the layer. + state.layerInteractionManager + ..clearSelectedLayers() + ..addSelectedLayer(layer.id); + await tester.pump(); + + final Offset center = tester.getCenter(find.byType(ProImageEditor)); + + // --- Two fingers down: start a rotate/scale gesture on the layer. --- + final TestGesture finger1 = await tester.startGesture( + center + const Offset(-40, 0), + pointer: 1, + ); + final TestGesture finger2 = await tester.startGesture( + center + const Offset(40, 0), + pointer: 2, + ); + await tester.pump(); + + // Move both fingers to rotate the layer (rotate around the center). + for (int i = 1; i <= 6; i++) { + final double angle = (pi / 12) * i; + await finger1 + .moveTo(center + Offset(-40 * cos(angle), -40 * sin(angle))); + await finger2.moveTo(center + Offset(40 * cos(angle), 40 * sin(angle))); + await tester.pump(const Duration(milliseconds: 16)); + } + + // --- Third finger taps while the first two are still held down. --- + final TestGesture finger3 = await tester.startGesture( + center + const Offset(0, 120), + pointer: 3, + ); + await tester.pump(const Duration(milliseconds: 16)); + + // A tap = quick lift of the third finger. + await finger3.up(); + await tester.pump(const Duration(milliseconds: 16)); + + // Continue moving the two remaining fingers a little. + await finger1.moveBy(const Offset(-5, -5)); + await finger2.moveBy(const Offset(5, 5)); + await tester.pump(const Duration(milliseconds: 16)); + + // Lift the remaining fingers. + await finger1.up(); + await finger2.up(); + await tester.pumpAndSettle(); + + expect( + tester.takeException(), + isNull, + reason: 'Adding a third finger mid-rotation must not throw ' + '(issue #850).', + ); + }, + ); +} From ed8754e7450ac8ec8c81c0e308a2c1dd8156562d Mon Sep 17 00:00:00 2001 From: hm21 Date: Thu, 23 Jul 2026 11:13:17 +0200 Subject: [PATCH 2/2] fix(tests): improve readability of multitouch test for third finger gesture --- .../main_editor/multitouch_third_finger_test.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/features/main_editor/multitouch_third_finger_test.dart b/test/features/main_editor/multitouch_third_finger_test.dart index 77107e16..e06e9dc0 100644 --- a/test/features/main_editor/multitouch_third_finger_test.dart +++ b/test/features/main_editor/multitouch_third_finger_test.dart @@ -93,8 +93,9 @@ void main() { // Move both fingers to rotate the layer (rotate around the center). for (int i = 1; i <= 6; i++) { final double angle = (pi / 12) * i; - await finger1 - .moveTo(center + Offset(-40 * cos(angle), -40 * sin(angle))); + await finger1.moveTo( + center + Offset(-40 * cos(angle), -40 * sin(angle)), + ); await finger2.moveTo(center + Offset(40 * cos(angle), 40 * sin(angle))); await tester.pump(const Duration(milliseconds: 16)); } @@ -123,7 +124,8 @@ void main() { expect( tester.takeException(), isNull, - reason: 'Adding a third finger mid-rotation must not throw ' + reason: + 'Adding a third finger mid-rotation must not throw ' '(issue #850).', ); },