diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart index 2eb3c1d61c..64c49fb77c 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart @@ -136,6 +136,10 @@ class _AndroidDocumentTouchInteractorState extends State( - () => PanGestureRecognizer(), - (PanGestureRecognizer recognizer) { + VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => VerticalDragGestureRecognizer(), + (VerticalDragGestureRecognizer recognizer) { recognizer + ..dragStartBehavior = DragStartBehavior.down ..onStart = _onPanStart ..onUpdate = _onPanUpdate ..onEnd = _onPanEnd diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart index 256792dbde..439cc5be39 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart @@ -287,6 +287,9 @@ class _IosDocumentTouchInteractorState extends State // not collapsed/upstream/downstream. Change the type once it's working. HandleType? _dragHandleType; + /// Holds the drag gesture that scrolls the document. + Drag? _scrollingDrag; + Timer? _tapDownLongPressTimer; Offset? _globalTapDownOffset; bool get _isLongPressInProgress => _longPressStrategy != null; @@ -810,6 +813,8 @@ class _IosDocumentTouchInteractorState extends State // bit of slop might be the problem. final selection = widget.selection.value; if (selection == null) { + // There isn't a selection, the user is dragging to scroll the document. + _startDragScrolling(details); return; } @@ -827,6 +832,10 @@ class _IosDocumentTouchInteractorState extends State _dragMode = DragMode.extent; _dragHandleType = HandleType.downstream; } else { + // The user isn't dragging over a handle. + // Start scrolling the document. + _startDragScrolling(details); + return; } @@ -909,10 +918,9 @@ class _IosDocumentTouchInteractorState extends State } void _onPanUpdate(DragUpdateDetails details) { - // If the user isn't dragging a handle, then the user is trying to - // scroll the document. Scroll it, accordingly. - if (_dragMode == null) { - scrollPosition.jumpTo(scrollPosition.pixels - details.delta.dy); + if (_dragMode == DragMode.scroll) { + // The user is trying to scroll the document. Scroll it, accordingly. + _scrollingDrag!.update(details); return; } @@ -990,27 +998,39 @@ class _IosDocumentTouchInteractorState extends State ..hideMagnifier() ..blinkCaret(); - if (_dragMode == null) { - // User was dragging the scroll area. Go ballistic. - if (scrollPosition is ScrollPositionWithSingleContext) { - (scrollPosition as ScrollPositionWithSingleContext).goBallistic(-details.velocity.pixelsPerSecond.dy); - - if (_activeScrollPosition != scrollPosition) { - // We add the scroll change listener again, because going ballistic - // seems to switch out the scroll position. - _activeScrollPosition = scrollPosition; - } - } - } else { - // The user was dragging a selection change in some way, either with handles - // or with a long-press. Finish that interaction. - _onDragSelectionEnd(); + switch (_dragMode) { + case DragMode.scroll: + // The user was performing a drag gesture to scroll the document. + // End the scroll activity and let the document scrolling with momentum. + _scrollingDrag!.end(details); + _scrollingDrag = null; + _dragMode = null; + break; + case DragMode.collapsed: + case DragMode.base: + case DragMode.extent: + case DragMode.longPress: + // The user was dragging a selection change in some way, either with handles + // or with a long-press. Finish that interaction. + _onDragSelectionEnd(); + break; + case null: + // The user wasn't dragging over a selection. Do nothing. + break; } } void _onPanCancel() { _magnifierOffset.value = null; + if (_scrollingDrag != null) { + // The user was performing a drag gesture to scroll the document. + // Cancel the drag gesture. + _scrollingDrag!.cancel(); + _scrollingDrag = null; + return; + } + if (_dragMode != null) { _onDragSelectionEnd(); } @@ -1207,6 +1227,17 @@ class _IosDocumentTouchInteractorState extends State return ancestorScrollable; } + /// Starts a drag activity to scroll the document. + void _startDragScrolling(DragStartDetails details) { + _dragMode = DragMode.scroll; + + _scrollingDrag = scrollPosition.drag(details, () { + // Allows receiving touches while scrolling due to scroll momentum. + // This is needed to allow the user to stop scrolling by tapping down. + scrollPosition.context.setIgnorePointer(false); + }); + } + @override Widget build(BuildContext context) { if (widget.scrollController.hasClients) { @@ -1299,6 +1330,8 @@ enum DragMode { // Dragging after a long-press, which selects by the word // around the selected word. longPress, + // Dragging to scroll the document. + scroll } /// Adds and removes an iOS-style editor toolbar, as dictated by an ancestor diff --git a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart index 191a8209c6..50f0b77e4d 100644 --- a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart @@ -135,6 +135,9 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State(null); + /// Holds the drag gesture that scrolls the document. + Drag? _scrollingDrag; + @override void initState() { super.initState(); @@ -674,6 +677,11 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State( - () => PanGestureRecognizer(), - (PanGestureRecognizer recognizer) { + VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => VerticalDragGestureRecognizer(), + (VerticalDragGestureRecognizer recognizer) { recognizer + ..dragStartBehavior = DragStartBehavior.down ..onStart = _onPanStart ..onUpdate = _onPanUpdate ..onEnd = _onPanEnd diff --git a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart index 84684ff79b..990450c7b8 100644 --- a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart @@ -254,6 +254,9 @@ class _SuperReaderIosDocumentTouchInteractorState extends State _longPressStrategy != null; IosLongPressSelectionStrategy? _longPressStrategy; + /// Holds the drag gesture that scrolls the document. + Drag? _scrollingDrag; + @override void initState() { super.initState(); @@ -439,6 +442,13 @@ class _SuperReaderIosDocumentTouchInteractorState extends State MaterialApp( - home: Scaffold( - body: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: superEditor, - ), - ], - ), - ), - ), - ), - ) + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) .pump(); // Ensure the scrollview didn't start scrolled. @@ -645,23 +755,9 @@ void main() { await tester .createDocument() // .withLongTextContent() - .withCustomWidgetTreeBuilder( - (superEditor) => MaterialApp( - home: Scaffold( - body: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: superEditor, - ), - ], - ), - ), - ), - ), - ) + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) .pump(); // Ensure the scrollview didn't start scrolled. @@ -694,6 +790,139 @@ void main() { expect(tester.testTextInput.hasAnyClients, isFalse); }); + testWidgetsOnAndroid("doesn't overscroll when dragging down", (tester) async { + final scrollController = ScrollController(); + + await tester + .createDocument() // + .withSingleParagraph() + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels from the top of the editor. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperEditor)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 400.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, 0); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging up", (tester) async { + final scrollController = ScrollController(); + + // Pump an editor inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + + // Drag an arbitrary amount of pixels from the bottom of the editor. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).bottomCenter - const Offset(0, 10), + totalDragOffset: const Offset(0, -400.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnIos('overscrolls when dragging down', (tester) async { + final scrollController = ScrollController(); + + // Pump an editor inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount, smaller than the editor size. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 80.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, lessThan(0.0)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the top. + expect(scrollController.offset, 0.0); + }); + + testWidgetsOnIos('overscrolls when dragging up', (tester) async { + final scrollController = ScrollController(); + + // Pump an editor inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Drag up an arbitrary amount, smaller than the editor size. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -100.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(scrollController.position.maxScrollExtent)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the end. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + }); + group('respects horizontal scrolling', () { testWidgetsOnAllPlatforms('inside a TabBar', (tester) async { final tabController = TabController(length: 2, vsync: tester); diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index 0c9c65dc57..ec8b3151f2 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -301,6 +301,16 @@ class TestSuperEditorConfigurator { return this; } + /// Configures the [SuperEditor] to be displayed inside a [CustomScrollView]. + /// + /// The [CustomScrollView] is constrained by the size provided in [withEditorSize]. + /// + /// Use [withScrollController] to define the [ScrollController] of the [CustomScrollView]. + TestSuperEditorConfigurator insideCustomScrollView() { + _config.insideCustomScrollView = true; + return this; + } + /// Pumps a [SuperEditor] widget tree with the desired configuration, and returns /// a [TestDocumentContext], which includes the artifacts connected to the widget /// tree, e.g., the [DocumentEditor], [DocumentComposer], etc. @@ -333,7 +343,9 @@ class TestSuperEditorConfigurator { ConfiguredSuperEditorWidget _build([TestDocumentContext? testDocumentContext]) { final context = testDocumentContext ?? _createTestDocumentContext(); final superEditor = _buildConstrainedContent( - _buildSuperEditor(context), + _buildAncestorScrollable( + child: _buildSuperEditor(context), + ), ); return ConfiguredSuperEditorWidget( @@ -397,6 +409,22 @@ class TestSuperEditorConfigurator { return superEditor; } + /// Places [child] inside a [CustomScrollView], based on configurations in this class. + Widget _buildAncestorScrollable({required Widget child}) { + if (!_config.insideCustomScrollView) { + return child; + } + + return CustomScrollView( + controller: _config.scrollController, + slivers: [ + SliverToBoxAdapter( + child: child, + ), + ], + ); + } + /// Builds a [SuperEditor] widget based on the configuration of the given /// [testDocumentContext], as well as other configurations in this class. Widget _buildSuperEditor(TestDocumentContext testDocumentContext) { @@ -454,6 +482,7 @@ class SuperEditorTestConfiguration { List? componentBuilders; Stylesheet? stylesheet; ScrollController? scrollController; + bool insideCustomScrollView = false; DocumentGestureMode? gestureMode; TextInputSource? inputSource; SuperEditorSelectionPolicies? selectionPolicies; diff --git a/super_editor/test/super_reader/reader_test_tools.dart b/super_editor/test/super_reader/reader_test_tools.dart index cc5e8ebba1..7f15457dc0 100644 --- a/super_editor/test/super_reader/reader_test_tools.dart +++ b/super_editor/test/super_reader/reader_test_tools.dart @@ -91,6 +91,7 @@ class TestDocumentConfigurator { List? _componentBuilders; WidgetTreeBuilder? _widgetTreeBuilder; ScrollController? _scrollController; + bool _insideCustomScrollView = false; FocusNode? _focusNode; DocumentSelection? _selection; WidgetBuilder? _androidToolbarBuilder; @@ -222,6 +223,16 @@ class TestDocumentConfigurator { return this; } + /// Configures the [SuperReader] to be displayed inside a [CustomScrollView]. + /// + /// The [CustomScrollView] is constrained by the size provided in [withEditorSize]. + /// + /// Use [withScrollController] to define the [ScrollController] of the [CustomScrollView]. + TestDocumentConfigurator insideCustomScrollView() { + _insideCustomScrollView = true; + return this; + } + /// Pumps a [SuperReader] widget tree with the desired configuration, and returns /// a [TestDocumentContext], which includes the artifacts connected to the widget /// tree, e.g., the [DocumentEditor], [DocumentComposer], etc. @@ -242,26 +253,28 @@ class TestDocumentConfigurator { documentContext: documentContext, ); - final superDocument = _buildContent( - SuperReaderIosControlsScope( - controller: SuperReaderIosControlsController( - toolbarBuilder: _iOSToolbarBuilder, - ), - child: SuperReader( - focusNode: testContext.focusNode, - document: documentContext.document, - documentLayoutKey: layoutKey, - selection: documentContext.selection, - selectionStyle: _selectionStyles, - gestureMode: _gestureMode ?? _defaultGestureMode, - stylesheet: _stylesheet, - componentBuilders: [ - ..._addedComponents, - ...(_componentBuilders ?? defaultComponentBuilders), - ], - autofocus: _autoFocus, - scrollController: _scrollController, - androidToolbarBuilder: _androidToolbarBuilder, + final superDocument = _buildConstrainedContent( + _buildAncestorScrollable( + child: SuperReaderIosControlsScope( + controller: SuperReaderIosControlsController( + toolbarBuilder: _iOSToolbarBuilder, + ), + child: SuperReader( + focusNode: testContext.focusNode, + document: documentContext.document, + documentLayoutKey: layoutKey, + selection: documentContext.selection, + selectionStyle: _selectionStyles, + gestureMode: _gestureMode ?? _defaultGestureMode, + stylesheet: _stylesheet, + componentBuilders: [ + ..._addedComponents, + ...(_componentBuilders ?? defaultComponentBuilders), + ], + autofocus: _autoFocus, + scrollController: _scrollController, + androidToolbarBuilder: _androidToolbarBuilder, + ), ), ), ); @@ -273,7 +286,7 @@ class TestDocumentConfigurator { return testContext; } - Widget _buildContent(Widget superReader) { + Widget _buildConstrainedContent(Widget superReader) { if (_editorSize != null) { return ConstrainedBox( constraints: BoxConstraints( @@ -286,6 +299,22 @@ class TestDocumentConfigurator { return superReader; } + /// Places [child] inside a [CustomScrollView], based on configurations in this class. + Widget _buildAncestorScrollable({required Widget child}) { + if (!_insideCustomScrollView) { + return child; + } + + return CustomScrollView( + controller: _scrollController, + slivers: [ + SliverToBoxAdapter( + child: child, + ), + ], + ); + } + Widget _buildWidgetTree(Widget superReader) { if (_widgetTreeBuilder != null) { return _widgetTreeBuilder!(superReader); diff --git a/super_editor/test/super_reader/super_reader_scrolling_test.dart b/super_editor/test/super_reader/super_reader_scrolling_test.dart index ad40a74b43..fc142e3be1 100644 --- a/super_editor/test/super_reader/super_reader_scrolling_test.dart +++ b/super_editor/test/super_reader/super_reader_scrolling_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_reader_test.dart'; +import '../test_tools.dart'; import 'reader_test_tools.dart'; import 'test_documents.dart'; @@ -220,6 +221,128 @@ void main() { ); }); + testWidgetsOnAndroid("doesn't overscroll when dragging down", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .pump(); + + // Ensure the reader didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels from the top of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperReader)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 200.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, 0); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging up", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + + // Drag an arbitrary amount of pixels from the bottom of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperReader)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -200.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnIos('overscrolls when dragging down', (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels a few pixels below the top of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperReader)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 80.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, lessThan(0.0)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the top. + expect(scrollController.offset, 0.0); + }); + + testWidgetsOnIos('overscrolls when dragging up', (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Drag an arbitrary amount of pixels from the bottom of the reader. + // The gesture starts with an arbitrary margin from the bottom. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperReader)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -200.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(scrollController.position.maxScrollExtent)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the end. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + }); + group("when all content fits in the viewport", () { testWidgetsOnDesktop( "trackpad doesn't scroll content", @@ -322,23 +445,9 @@ void main() { await tester .createDocument() // .withLongTextContent() - .withCustomWidgetTreeBuilder( - (superReader) => MaterialApp( - home: Scaffold( - body: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: superReader, - ), - ], - ), - ), - ), - ), - ) + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) .pump(); // Ensure the scrollview didn't start scrolled. @@ -377,23 +486,9 @@ void main() { await tester .createDocument() // .withLongTextContent() - .withCustomWidgetTreeBuilder( - (superReader) => MaterialApp( - home: Scaffold( - body: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: superReader, - ), - ], - ), - ), - ), - ), - ) + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) .pump(); // Ensure the scrollview didn't start scrolled. @@ -424,6 +519,137 @@ void main() { expect(scrollController.offset, greaterThan(0)); expect(SuperReaderInspector.findDocumentSelection(), isNull); }); + + testWidgetsOnAndroid("doesn't overscroll when dragging down", (tester) async { + final scrollController = ScrollController(); + + await tester + .createDocument() + .withSingleParagraph() + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels from the top of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperReader)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 400.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, 0); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging up", (tester) async { + final scrollController = ScrollController(); + + // Pump a reader inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + + // Drag an arbitrary amount of pixels from the bottom of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -400.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnIos('overscrolls when dragging down', (tester) async { + final scrollController = ScrollController(); + + await tester + .createDocument() // + .withLongTextContent() + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount, smaller than the reader size. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 80.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, lessThan(0.0)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the top. + expect(scrollController.offset, 0.0); + }); + + testWidgetsOnIos('overscrolls when dragging up', (tester) async { + final scrollController = ScrollController(); + + // Pump a reader inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Drag up an arbitrary amount, smaller than the reader size. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -100.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(scrollController.position.maxScrollExtent)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the end. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + }); }); }); } diff --git a/super_editor/test/test_tools.dart b/super_editor/test/test_tools.dart index 6f40440015..f6120df932 100644 --- a/super_editor/test/test_tools.dart +++ b/super_editor/test/test_tools.dart @@ -16,3 +16,33 @@ class TestUrlLauncher implements UrlLauncher { return true; } } + +/// Extension on [WidgetTester] to make it easier to perform drag gestures. +extension DragExtensions on WidgetTester { + /// Simulates a user drag from [startLocation] to `startLocation + totalDragOffset`. + /// + /// Starts a gesture at [startLocation] and repeatedly drags the gesture + /// across [frameCount] frames, pumping a frame between each drag. + /// The gesture moves a distance each frame that's calculated as + /// `totalDragOffset / frameCount`. + /// + /// This method does not call `pumpAndSettle()`, so that the client can inspect + /// the app state immediately after the drag completes. + /// + /// The client must call [TestGesture.up] on the returned [TestGesture]. + Future dragByFrameCount({ + required Offset startLocation, + required Offset totalDragOffset, + int frameCount = 10, + }) async { + final dragPerFrame = Offset(totalDragOffset.dx / frameCount, totalDragOffset.dy / frameCount); + + final dragGesture = await startGesture(startLocation); + for (int i = 0; i < frameCount; i += 1) { + await dragGesture.moveBy(dragPerFrame); + await pump(); + } + + return dragGesture; + } +}