Skip to content

Commit 872ed27

Browse files
angelosilvestrematthew-carroll
authored andcommitted
[SuperEditor][SuperReader][iOS] Fix horizontal drag scrolling the editor (Resolves #2081) (#2155)
1 parent fadf61a commit 872ed27

File tree

7 files changed

+1146
-110
lines changed

7 files changed

+1146
-110
lines changed

super_editor/lib/src/default_editor/document_gestures_touch_ios.dart

Lines changed: 72 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'package:super_editor/src/default_editor/text_tools.dart';
1818
import 'package:super_editor/src/document_operations/selection_operations.dart';
1919
import 'package:super_editor/src/infrastructure/_logging.dart';
2020
import 'package:super_editor/src/infrastructure/content_layers.dart';
21+
import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart';
2122
import 'package:super_editor/src/infrastructure/flutter/build_context.dart';
2223
import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart';
2324
import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart';
@@ -308,6 +309,7 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
308309
final _magnifierFocalPointInDocumentSpace = ValueNotifier<Offset?>(null);
309310
Offset? _dragEndInInteractor;
310311
DragMode? _dragMode;
312+
DragStartDetails? _dragStartDetails;
311313
// TODO: HandleType is the wrong type here, we need collapsed/base/extent,
312314
// not collapsed/upstream/downstream. Change the type once it's working.
313315
HandleType? _dragHandleType;
@@ -896,6 +898,9 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
896898
}
897899

898900
void _onPanStart(DragStartDetails details) {
901+
// Store the gesture start details to disambiguate horizontal vs vertical dragging, later.
902+
_dragStartDetails = details;
903+
899904
// Stop waiting for a long-press to start, if a long press isn't already in-progress.
900905
_globalTapDownOffset = null;
901906
_tapDownLongPressTimer?.cancel();
@@ -905,8 +910,11 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
905910
// bit of slop might be the problem.
906911
final selection = widget.selection.value;
907912
if (selection == null) {
908-
// There isn't a selection, the user is dragging to scroll the document.
909-
_startDragScrolling(details);
913+
// There isn't a selection, but we still don't know if the user is dragging
914+
// vertically or horizontally. Wait until the onPanUpdate event is fired
915+
// to decide whether or not we should scroll the document.
916+
_dragMode = DragMode.waitingForScrollDirection;
917+
_updateDragStartLocation(details.globalPosition);
910918
return;
911919
}
912920

@@ -924,9 +932,11 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
924932
_dragMode = DragMode.extent;
925933
_dragHandleType = HandleType.downstream;
926934
} else {
927-
// The user isn't dragging over a handle.
928-
// Start scrolling the document.
929-
_startDragScrolling(details);
935+
// The user isn't dragging over a handle, but we still don't know if the user is dragging
936+
// vertically or horizontally. Wait until the onPanUpdate event is fired
937+
// to decide whether or not we should scroll the document.
938+
_dragMode = DragMode.waitingForScrollDirection;
939+
_updateDragStartLocation(details.globalPosition);
930940

931941
return;
932942
}
@@ -936,31 +946,7 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
936946
..hideToolbar()
937947
..showMagnifier();
938948

939-
_globalStartDragOffset = details.globalPosition;
940-
final interactorBox = context.findRenderObject() as RenderBox;
941-
final handleOffsetInInteractor = interactorBox.globalToLocal(details.globalPosition);
942-
_dragStartInDoc = _interactorOffsetToDocumentOffset(handleOffsetInInteractor);
943-
944-
if (_dragHandleType != null) {
945-
_startDragPositionOffset = _docLayout
946-
.getRectForPosition(
947-
_dragHandleType == HandleType.upstream ? selection.base : selection.extent,
948-
)!
949-
.center;
950-
} else {
951-
// User is long-press dragging, which is why there's no drag handle type.
952-
// In this case, the start drag offset is wherever the user touched.
953-
_startDragPositionOffset = _dragStartInDoc!;
954-
}
955-
956-
// We need to record the scroll offset at the beginning of
957-
// a drag for the case that this interactor is embedded
958-
// within an ancestor Scrollable. We need to use this value
959-
// to calculate a scroll delta on every scroll frame to
960-
// account for the fact that this interactor is moving within
961-
// the ancestor scrollable, despite the fact that the user's
962-
// finger/mouse position hasn't changed.
963-
_dragStartScrollOffset = scrollPosition.pixels;
949+
_updateDragStartLocation(details.globalPosition);
964950

965951
widget.dragHandleAutoScroller.value?.startAutoScrollHandleMonitoring();
966952

@@ -1011,9 +997,26 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
1011997
}
1012998

1013999
void _onPanUpdate(DragUpdateDetails details) {
1000+
if (_dragMode == DragMode.waitingForScrollDirection) {
1001+
if (_globalStartDragOffset != null && (details.globalPosition.dy - _globalStartDragOffset!.dy).abs() > kPanSlop) {
1002+
// The user is dragging vertically. Start scrolling the document.
1003+
_startDragScrolling(_dragStartDetails!);
1004+
}
1005+
}
1006+
10141007
if (_dragMode == DragMode.scroll) {
10151008
// The user is trying to scroll the document. Scroll it, accordingly.
1016-
_scrollingDrag!.update(details);
1009+
_scrollingDrag!.update(
1010+
DragUpdateDetails(
1011+
globalPosition: details.globalPosition,
1012+
localPosition: details.localPosition,
1013+
primaryDelta: details.delta.dy,
1014+
// Having a primary delta requires that one of the
1015+
// offset dimensions is zero.
1016+
delta: Offset(0.0, details.delta.dy),
1017+
),
1018+
);
1019+
10171020
return;
10181021
}
10191022

@@ -1109,6 +1112,9 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
11091112
// or with a long-press. Finish that interaction.
11101113
_onDragSelectionEnd();
11111114
break;
1115+
case DragMode.waitingForScrollDirection:
1116+
_dragMode = null;
1117+
break;
11121118
case null:
11131119
// The user wasn't dragging over a selection. Do nothing.
11141120
break;
@@ -1341,6 +1347,35 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
13411347
_magnifierFocalPointInDocumentSpace.value = centerOfContentAtOffset;
13421348
}
13431349

1350+
void _updateDragStartLocation(Offset globalOffset) {
1351+
_globalStartDragOffset = globalOffset;
1352+
final interactorBox = context.findRenderObject() as RenderBox;
1353+
final handleOffsetInInteractor = interactorBox.globalToLocal(globalOffset);
1354+
_dragStartInDoc = _interactorOffsetToDocumentOffset(handleOffsetInInteractor);
1355+
1356+
final selection = widget.selection.value;
1357+
if (_dragHandleType != null && selection != null) {
1358+
_startDragPositionOffset = _docLayout
1359+
.getRectForPosition(
1360+
_dragHandleType == HandleType.upstream ? selection.base : selection.extent,
1361+
)!
1362+
.center;
1363+
} else {
1364+
// User is long-press dragging, which is why there's no drag handle type.
1365+
// In this case, the start drag offset is wherever the user touched.
1366+
_startDragPositionOffset = _dragStartInDoc!;
1367+
}
1368+
1369+
// We need to record the scroll offset at the beginning of
1370+
// a drag for the case that this interactor is embedded
1371+
// within an ancestor Scrollable. We need to use this value
1372+
// to calculate a scroll delta on every scroll frame to
1373+
// account for the fact that this interactor is moving within
1374+
// the ancestor scrollable, despite the fact that the user's
1375+
// finger/mouse position hasn't changed.
1376+
_dragStartScrollOffset = scrollPosition.pixels;
1377+
}
1378+
13441379
@override
13451380
Widget build(BuildContext context) {
13461381
if (widget.scrollController.hasClients) {
@@ -1374,27 +1409,9 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
13741409
..gestureSettings = gestureSettings;
13751410
},
13761411
),
1377-
// We use a combination of a VerticalDragGestureRecognizer and a HorizontalDragGestureRecognizer
1378-
// instead of a PanGestureRecognizer because `Scrollable` also uses a VerticalDragGestureRecognizer
1379-
// and we need to beat out any ancestor `Scrollable` in the gesture arena.
1380-
// Without the HorizontalDragGestureRecognizer, horizontal drags aren't reported here
1381-
// when the editor has an ancestor `Scrollable`.
1382-
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
1383-
() => VerticalDragGestureRecognizer(),
1384-
(VerticalDragGestureRecognizer instance) {
1385-
instance
1386-
..dragStartBehavior = DragStartBehavior.down
1387-
..onDown = _onPanDown
1388-
..onStart = _onPanStart
1389-
..onUpdate = _onPanUpdate
1390-
..onEnd = _onPanEnd
1391-
..onCancel = _onPanCancel
1392-
..gestureSettings = gestureSettings;
1393-
},
1394-
),
1395-
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
1396-
() => HorizontalDragGestureRecognizer(),
1397-
(HorizontalDragGestureRecognizer instance) {
1412+
EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers<EagerPanGestureRecognizer>(
1413+
() => EagerPanGestureRecognizer(),
1414+
(EagerPanGestureRecognizer instance) {
13981415
instance
13991416
..dragStartBehavior = DragStartBehavior.down
14001417
..onDown = _onPanDown
@@ -1450,7 +1467,9 @@ enum DragMode {
14501467
// around the selected word.
14511468
longPress,
14521469
// Dragging to scroll the document.
1453-
scroll
1470+
scroll,
1471+
// We still don't know if the user is dragging vertically or horizontally.
1472+
waitingForScrollDirection,
14541473
}
14551474

14561475
/// Adds and removes an iOS-style editor toolbar, as dictated by an ancestor
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import 'package:flutter/foundation.dart';
2+
import 'package:flutter/gestures.dart' hide DragGestureRecognizer;
3+
4+
import 'package:super_editor/src/infrastructure/flutter/monodrag.dart';
5+
6+
/// Recognizes movement both horizontally and vertically.
7+
///
8+
/// Flutter's `PanGestureRecognizer` loses the gesture arena if there
9+
/// is a `VerticalDragGestureRecognizer` in the tree.
10+
///
11+
/// This recognizer uses the same minimum distance as the `VerticalDragGestureRecognizer`
12+
/// to accept a gesture
13+
class EagerPanGestureRecognizer extends DragGestureRecognizer {
14+
EagerPanGestureRecognizer({
15+
super.debugOwner,
16+
super.supportedDevices,
17+
super.allowedButtonsFilter,
18+
});
19+
20+
@override
21+
bool isFlingGesture(VelocityEstimate estimate, PointerDeviceKind kind) {
22+
final minVelocity = minFlingVelocity ?? kMinFlingVelocity;
23+
final minDistance = minFlingDistance ?? computeHitSlop(kind, gestureSettings);
24+
return estimate.pixelsPerSecond.distanceSquared > minVelocity * minVelocity &&
25+
estimate.offset.distanceSquared > minDistance * minDistance;
26+
}
27+
28+
@override
29+
DragEndDetails? considerFling(VelocityEstimate estimate, PointerDeviceKind kind) {
30+
if (!isFlingGesture(estimate, kind)) {
31+
return null;
32+
}
33+
final maxVelocity = maxFlingVelocity ?? kMaxFlingVelocity;
34+
final dy = clampDouble(estimate.pixelsPerSecond.dy, -maxVelocity, maxVelocity);
35+
return DragEndDetails(
36+
velocity: Velocity(pixelsPerSecond: Offset(0, dy)),
37+
primaryVelocity: dy,
38+
globalPosition: finalPosition.global,
39+
localPosition: finalPosition.local,
40+
);
41+
}
42+
43+
@override
44+
bool hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind, double? deviceTouchSlop) {
45+
// Flutter's PanGestureRecognizer uses the pan slop, which is twice bigger than the hit slop,
46+
// to determine if the gesture should be accepted. Use the same distance used by the
47+
// VerticalDragGestureRecognizer.
48+
return globalDistanceMoved.abs() > computeHitSlop(pointerDeviceKind, gestureSettings);
49+
}
50+
51+
@override
52+
Offset getDeltaForDetails(Offset delta) => delta;
53+
54+
@override
55+
double? getPrimaryValueFromOffset(Offset value) => null;
56+
57+
@override
58+
String get debugDescription => 'pan';
59+
}

0 commit comments

Comments
 (0)