diff --git a/super_editor/lib/src/default_editor/document_gestures_mouse.dart b/super_editor/lib/src/default_editor/document_gestures_mouse.dart index 06c548606..35967787a 100644 --- a/super_editor/lib/src/default_editor/document_gestures_mouse.dart +++ b/super_editor/lib/src/default_editor/document_gestures_mouse.dart @@ -17,6 +17,7 @@ import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; import '../infrastructure/document_gestures_interaction_overrides.dart'; @@ -51,7 +52,8 @@ class DocumentMouseInteractor extends StatefulWidget { this.contentTapHandler, required this.autoScroller, this.showDebugPaint = false, - this.child, + required this.child, + required this.fillViewport, }) : super(key: key); final FocusNode? focusNode; @@ -69,12 +71,14 @@ class DocumentMouseInteractor extends StatefulWidget { /// Auto-scrolling delegate. final AutoScrollController autoScroller; + final bool fillViewport; + /// Paints some extra visual ornamentation to help with /// debugging, when `true`. final bool showDebugPaint; /// The document to display within this [DocumentMouseInteractor]. - final Widget? child; + final Widget child; @override State createState() => _DocumentMouseInteractorState(); @@ -510,16 +514,6 @@ class _DocumentMouseInteractorState extends State with editorGesturesLog .info("Pan update on document, global offset: ${details.globalPosition}, device: $_panGestureDevice"); - if (_panGestureDevice == PointerDeviceKind.trackpad) { - // The user dragged using two fingers on a trackpad. - // Scroll the document and keep the selection unchanged. - // We multiply by -1 because the scroll should be in the opposite - // direction of the drag, e.g., dragging up on a trackpad scrolls - // the document to downstream direction. - _scrollVertically(details.delta.dy * -1); - return; - } - setState(() { _dragEndGlobal = details.globalPosition; @@ -533,13 +527,6 @@ class _DocumentMouseInteractorState extends State with void _onPanEnd(DragEndDetails details) { editorGesturesLog.info("Pan end on document, device: $_panGestureDevice"); - - if (_panGestureDevice == PointerDeviceKind.trackpad) { - // The user ended a pan gesture with two fingers on a trackpad. - // We already scrolled the document. - widget.autoScroller.goBallistic(-details.velocity.pixelsPerSecond.dy); - return; - } _onDragEnd(); } @@ -556,27 +543,12 @@ class _DocumentMouseInteractorState extends State with _expandSelectionDuringDrag = false; _wordSelectionUpstream = null; _wordSelectionDownstream = null; + _selectionType = SelectionType.position; }); widget.autoScroller.disableAutoScrolling(); } - /// Scrolls the document vertically by [delta] pixels. - void _scrollVertically(double delta) { - widget.autoScroller.jumpBy(delta); - _updateDragSelection(); - } - - /// We prevent SingleChildScrollView from processing mouse events because - /// it scrolls by drag by default, which we don't want. However, we do - /// still want mouse scrolling. This method re-implements a primitive - /// form of mouse scrolling. - void _scrollOnMouseWheel(PointerSignalEvent event) { - if (event is PointerScrollEvent) { - _scrollVertically(event.scrollDelta.dy); - } - } - void _updateDragSelection() { if (_dragEndGlobal == null) { // User isn't dragging. No need to update drag selection. @@ -747,14 +719,19 @@ Updating drag selection: @override Widget build(BuildContext context) { - return Listener( - onPointerSignal: _scrollOnMouseWheel, - onPointerHover: _onMouseMove, - child: _buildCursorStyle( - child: _buildGestureInput( - child: widget.child ?? const SizedBox(), + return SliverHybridStack( + fillViewport: widget.fillViewport, + children: [ + Listener( + onPointerHover: _onMouseMove, + child: _buildCursorStyle( + child: _buildGestureInput( + child: const SizedBox(), + ), + ), ), - ), + widget.child, + ], ); } @@ -794,7 +771,10 @@ Updating drag selection: }, ), PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), + () => PanGestureRecognizer(supportedDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + }), (PanGestureRecognizer recognizer) { recognizer ..onStart = _onPanStart 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 530c4da5e..7202b8ac0 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 @@ -19,6 +19,7 @@ import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/content_layers.dart'; import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/android/android_document_controls.dart'; @@ -403,11 +404,12 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { required this.selection, required this.openSoftwareKeyboard, required this.scrollController, + required this.fillViewport, this.contentTapHandler, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), required this.dragHandleAutoScroller, this.showDebugPaint = false, - this.child, + required this.child, }) : super(key: key); final FocusNode focusNode; @@ -435,9 +437,11 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { final ValueNotifier dragHandleAutoScroller; + final bool fillViewport; + final bool showDebugPaint; - final Widget? child; + final Widget child; @override State createState() => _AndroidDocumentTouchInteractorState(); @@ -447,14 +451,9 @@ class _AndroidDocumentTouchInteractorState extends State(null); Timer? _tapDownLongPressTimer; @@ -489,8 +485,6 @@ class _AndroidDocumentTouchInteractorState extends State viewportBox, ); - _configureScrollController(); - widget.document.addListener(_onDocumentChange); widget.selection.addListener(_onSelectionChange); @@ -508,14 +502,6 @@ class _AndroidDocumentTouchInteractorState extends State _updateScrollPositionListener()); } @override @@ -531,11 +517,6 @@ class _AndroidDocumentTouchInteractorState extends State _interactor.currentContext!.findRenderObject() as RenderBox; + /// Maps the given [interactorOffset] within the interactor's coordinate space /// to the same screen position in the viewport's coordinate space. /// @@ -627,46 +610,11 @@ class _AndroidDocumentTouchInteractorState extends State scrollPosition.isScrollingNotifier.addListener(_onScrollActivityChange)); - } - - void _teardownScrollController() { - widget.scrollController.removeListener(_onScrollActivityChange); - - if (widget.scrollController.hasClients) { - scrollPosition.isScrollingNotifier.removeListener(_onScrollActivityChange); - } - } - - void _onScrollActivityChange() { - final isScrolling = scrollPosition.isScrollingNotifier.value; - - if (isScrolling) { - _isScrolling = true; - - // The user started to scroll. - // Cancel the timer to stop trying to detect a long press. - _tapDownLongPressTimer?.cancel(); - _tapDownLongPressTimer = null; - } else { - onNextFrame((_) { - // Set our scrolling flag to false on the next frame, so that our tap handlers - // have an opportunity to see that the scrollable was scrolling when the user - // tapped down. - // - // See the "on tap down" handler for more info about why this flag is important. - _isScrolling = false; - }); - } - } - void _ensureSelectionExtentIsVisible() { editorGesturesLog.fine("Ensuring selection extent is visible"); final selection = widget.selection.value; @@ -715,25 +663,7 @@ class _AndroidDocumentTouchInteractorState extends State{ + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..canAccept = () { + if (_globalTapDownOffset == null) { + return false; + } + return _isOverCaret(_globalTapDownOffset!) || _isLongPressInProgress; + } + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + ); + final layerBelow = RawGestureDetector( behavior: HitTestBehavior.translucent, gestures: { TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( @@ -1302,20 +1239,15 @@ class _AndroidDocumentTouchInteractorState extends State( - () => VerticalDragGestureRecognizer(), - (VerticalDragGestureRecognizer recognizer) { - recognizer - ..dragStartBehavior = DragStartBehavior.down - ..onStart = _onPanStart - ..onUpdate = _onPanUpdate - ..onEnd = _onPanEnd - ..onCancel = _onPanCancel - ..gestureSettings = gestureSettings; - }, - ), }, - child: widget.child, + ); + return SliverHybridStack( + fillViewport: widget.fillViewport, + children: [ + layerBelow, + widget.child, + layerAbove, + ], ); } } 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 d902fe85b..9bfde574e 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 @@ -4,7 +4,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_composer.dart'; @@ -247,10 +246,11 @@ class IosDocumentTouchInteractor extends StatefulWidget { required this.openSoftwareKeyboard, required this.scrollController, required this.dragHandleAutoScroller, + required this.fillViewport, this.contentTapHandler, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), this.showDebugPaint = false, - this.child, + required this.child, }) : super(key: key); final FocusNode focusNode; @@ -278,9 +278,11 @@ class IosDocumentTouchInteractor extends StatefulWidget { /// edges. final AxisOffset dragAutoScrollBoundary; + final bool fillViewport; + final bool showDebugPaint; - final Widget? child; + final Widget child; @override State createState() => _IosDocumentTouchInteractorState(); @@ -288,13 +290,8 @@ class IosDocumentTouchInteractor extends StatefulWidget { class _IosDocumentTouchInteractorState extends State with WidgetsBindingObserver, SingleTickerProviderStateMixin { - bool _isScrolling = false; - // The ScrollPosition attached to the _ancestorScrollable. ScrollPosition? _ancestorScrollPosition; - // The actual ScrollPosition that's used for the document layout, either - // the Scrollable installed by this interactor, or an ancestor Scrollable. - ScrollPosition? _activeScrollPosition; SuperEditorIosControlsController? _controlsController; late FloatingCursorListener _floatingCursorListener; @@ -309,14 +306,11 @@ class _IosDocumentTouchInteractorState extends State final _magnifierFocalPointInDocumentSpace = ValueNotifier(null); Offset? _dragEndInInteractor; DragMode? _dragMode; - DragStartDetails? _dragStartDetails; + // TODO: HandleType is the wrong type here, we need collapsed/base/extent, // 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; @@ -337,8 +331,6 @@ class _IosDocumentTouchInteractorState extends State getViewportBox: () => viewportBox, ); - _configureScrollController(); - widget.document.addListener(_onDocumentChange); _floatingCursorListener = FloatingCursorListener( @@ -367,23 +359,6 @@ class _IosDocumentTouchInteractorState extends State _controlsController!.floatingCursorController.cursorGeometryInViewport.addListener(_onFloatingCursorGeometryChange); _ancestorScrollPosition = context.findAncestorScrollableWithVerticalScroll?.position; - - // On the next frame, check if our active scroll position changed to a - // different instance. If it did, move our listener to the new one. - // - // This is posted to the next frame because the first time this method - // runs, we haven't attached to our own ScrollController yet, so - // this.scrollPosition might be null. - onNextFrame((_) { - final newScrollPosition = scrollPosition; - if (newScrollPosition == _activeScrollPosition) { - return; - } - - setState(() { - _activeScrollPosition = newScrollPosition; - }); - }); } @override @@ -394,11 +369,6 @@ class _IosDocumentTouchInteractorState extends State oldWidget.document.removeListener(_onDocumentChange); widget.document.addListener(_onDocumentChange); } - - if (widget.scrollController != oldWidget.scrollController) { - _teardownScrollController(); - _configureScrollController(); - } } @override @@ -411,8 +381,6 @@ class _IosDocumentTouchInteractorState extends State widget.document.removeListener(_onDocumentChange); - _teardownScrollController(); - widget.dragHandleAutoScroller.value?.dispose(); super.dispose(); @@ -443,33 +411,6 @@ class _IosDocumentTouchInteractorState extends State }); } - void _configureScrollController() { - onNextFrame((_) => scrollPosition.isScrollingNotifier.addListener(_onScrollActivityChange)); - } - - void _teardownScrollController() { - if (widget.scrollController.hasClients) { - scrollPosition.isScrollingNotifier.removeListener(_onScrollActivityChange); - } - } - - void _onScrollActivityChange() { - final isScrolling = scrollPosition.isScrollingNotifier.value; - - if (isScrolling) { - _isScrolling = true; - } else { - onNextFrame((_) { - // Set our scrolling flag to false on the next frame, so that our tap handlers - // have an opportunity to see that the scrollable was scrolling when the user - // tapped down. - // - // See the "on tap down" handler for more info about why this flag is important. - _isScrolling = false; - }); - } - } - void _ensureSelectionExtentIsVisible() { editorGesturesLog.fine("Ensuring selection extent is visible"); final selection = widget.selection.value; @@ -531,12 +472,14 @@ class _IosDocumentTouchInteractorState extends State return viewportBox.globalToLocal(globalOffset); } - RenderBox get interactorBox => context.findRenderObject() as RenderBox; + final _interactor = GlobalKey(); + + RenderBox get interactorBox => _interactor.currentContext!.findRenderObject() as RenderBox; /// Converts the given [interactorOffset] from the [DocumentInteractor]'s coordinate /// space to the [DocumentLayout]'s coordinate space. Offset _interactorOffsetToDocumentOffset(Offset interactorOffset) { - final globalOffset = (context.findRenderObject() as RenderBox).localToGlobal(interactorOffset); + final globalOffset = interactorBox.localToGlobal(interactorOffset); return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); } @@ -556,25 +499,7 @@ class _IosDocumentTouchInteractorState extends State ); } - bool _wasScrollingOnTapDown = false; void _onTapDown(TapDownDetails details) { - // When the user scrolls and releases, the scrolling continues with momentum. - // If the user then taps down again, the momentum stops. When this happens, we - // still receive tap callbacks. But we don't want to take any further action, - // like moving the caret, when the user taps to stop scroll momentum. We have - // to carefully watch the scrolling activity to recognize when this happens. - // We can't check whether we're scrolling in "on tap up" because by then the - // scrolling has already stopped. So we log whether we're scrolling "on tap down" - // and then check this flag in "on tap up". - _wasScrollingOnTapDown = _isScrolling; - - if (_isScrolling) { - // On iOS, unlike Android, tapping while scrolling doesn't seem to stop the scrolling - // momentum. If we're actively scrolling, stop the momentum. - (scrollPosition as ScrollPositionWithSingleContext).goIdle(); - return; - } - _globalTapDownOffset = details.globalPosition; _tapDownLongPressTimer?.cancel(); if (!disableLongPressSelectionForSuperlist) { @@ -638,13 +563,6 @@ class _IosDocumentTouchInteractorState extends State ..hideMagnifier() ..blinkCaret(); - if (_wasScrollingOnTapDown) { - // The scrollable was scrolling when the user touched down. We expect that the - // touch down stopped the scrolling momentum. We don't want to take any further - // action on this touch event. The user will tap again to change the selection. - return; - } - final selection = widget.selection.value; if (selection != null && !selection.isCollapsed && @@ -898,9 +816,6 @@ class _IosDocumentTouchInteractorState extends State } void _onPanStart(DragStartDetails details) { - // Store the gesture start details to disambiguate horizontal vs vertical dragging, later. - _dragStartDetails = details; - // Stop waiting for a long-press to start, if a long press isn't already in-progress. _globalTapDownOffset = null; _tapDownLongPressTimer?.cancel(); @@ -910,11 +825,6 @@ class _IosDocumentTouchInteractorState extends State // bit of slop might be the problem. final selection = widget.selection.value; if (selection == null) { - // There isn't a selection, but we still don't know if the user is dragging - // vertically or horizontally. Wait until the onPanUpdate event is fired - // to decide whether or not we should scroll the document. - _dragMode = DragMode.waitingForScrollDirection; - _updateDragStartLocation(details.globalPosition); return; } @@ -932,12 +842,6 @@ class _IosDocumentTouchInteractorState extends State _dragMode = DragMode.extent; _dragHandleType = HandleType.downstream; } else { - // The user isn't dragging over a handle, but we still don't know if the user is dragging - // vertically or horizontally. Wait until the onPanUpdate event is fired - // to decide whether or not we should scroll the document. - _dragMode = DragMode.waitingForScrollDirection; - _updateDragStartLocation(details.globalPosition); - return; } @@ -997,31 +901,7 @@ class _IosDocumentTouchInteractorState extends State } void _onPanUpdate(DragUpdateDetails details) { - if (_dragMode == DragMode.waitingForScrollDirection) { - if (_globalStartDragOffset != null && (details.globalPosition.dy - _globalStartDragOffset!.dy).abs() > kPanSlop) { - // The user is dragging vertically. Start scrolling the document. - _startDragScrolling(_dragStartDetails!); - } - } - - if (_dragMode == DragMode.scroll) { - // The user is trying to scroll the document. Scroll it, accordingly. - _scrollingDrag!.update( - DragUpdateDetails( - globalPosition: details.globalPosition, - localPosition: details.localPosition, - primaryDelta: details.delta.dy, - // Having a primary delta requires that one of the - // offset dimensions is zero. - delta: Offset(0.0, details.delta.dy), - ), - ); - - return; - } - _globalDragOffset = details.globalPosition; - final interactorBox = context.findRenderObject() as RenderBox; _dragEndInInteractor = interactorBox.globalToLocal(details.globalPosition); final dragEndInViewport = _interactorOffsetToViewportOffset(_dragEndInInteractor!); @@ -1097,13 +977,6 @@ class _IosDocumentTouchInteractorState extends State ..blinkCaret(); 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: @@ -1112,24 +985,14 @@ class _IosDocumentTouchInteractorState extends State // or with a long-press. Finish that interaction. _onDragSelectionEnd(); break; - case DragMode.waitingForScrollDirection: - _dragMode = null; - break; - case null: + default: // The user wasn't dragging over a selection. Do nothing. + _dragMode = null; break; } } void _onPanCancel() { - 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(); } @@ -1314,25 +1177,15 @@ class _IosDocumentTouchInteractorState extends State ]); } - /// 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); - }); - } - /// Updates the magnifier focal point in relation to the current drag position. void _placeFocalPointNearTouchOffset() { late DocumentPosition? docPositionToMagnify; if (_globalTapDownOffset != null) { // A drag isn't happening. Magnify the position that the user tapped. - docPositionToMagnify = - _docLayout.getDocumentPositionNearestToOffset(_globalTapDownOffset! + Offset(0, scrollPosition.pixels)); + final interactorOffset = interactorBox.globalToLocal(_globalTapDownOffset!); + final tapDownDocumentOffset = _interactorOffsetToDocumentOffset(interactorOffset); + docPositionToMagnify = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); } else { final docDragDelta = _globalDragOffset! - _globalStartDragOffset!; final dragScrollDelta = _dragStartScrollOffset! - scrollPosition.pixels; @@ -1349,7 +1202,6 @@ class _IosDocumentTouchInteractorState extends State void _updateDragStartLocation(Offset globalOffset) { _globalStartDragOffset = globalOffset; - final interactorBox = context.findRenderObject() as RenderBox; final handleOffsetInInteractor = interactorBox.globalToLocal(globalOffset); _dragStartInDoc = _interactorOffsetToDocumentOffset(handleOffsetInInteractor); @@ -1386,33 +1238,28 @@ class _IosDocumentTouchInteractorState extends State // // Defer adding the listener to the next frame. scheduleBuildAfterBuild(); - } else { - if (scrollPosition != _activeScrollPosition) { - _activeScrollPosition = scrollPosition; - } } } final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; - return RawGestureDetector( - behavior: HitTestBehavior.opaque, + final layerAbove = RawGestureDetector( + key: _interactor, + behavior: HitTestBehavior.translucent, gestures: { - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapDown = _onTapDown - ..onTapCancel = _onTapCancel - ..onTapUp = _onTapUp - ..onDoubleTapUp = _onDoubleTapUp - ..onTripleTapUp = _onTripleTapUp - ..gestureSettings = gestureSettings; - }, - ), EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => EagerPanGestureRecognizer(), (EagerPanGestureRecognizer instance) { instance + ..canAccept = () { + if (_globalTapDownOffset == null) { + return false; + } + final panDown = interactorBox.globalToLocal(_globalTapDownOffset!); + final isOverHandle = + _isOverBaseHandle(panDown) || _isOverExtentHandle(panDown) || _isOverCollapsedHandle(panDown); + final res = isOverHandle || _isLongPressInProgress; + return res; + } ..dragStartBehavior = DragStartBehavior.down ..onDown = _onPanDown ..onStart = _onPanStart @@ -1425,11 +1272,35 @@ class _IosDocumentTouchInteractorState extends State }, child: Stack( children: [ - widget.child ?? const SizedBox(), _buildMagnifierFocalPoint(), ], ), ); + final layerBelow = RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapCancel = _onTapCancel + ..onTapUp = _onTapUp + ..onDoubleTapUp = _onDoubleTapUp + ..onTripleTapUp = _onTripleTapUp + ..gestureSettings = gestureSettings; + }, + ), + }, + ); + return SliverHybridStack( + fillViewport: widget.fillViewport, + children: [ + layerBelow, + widget.child, + layerAbove, + ], + ); } Widget _buildMagnifierFocalPoint() { @@ -1466,10 +1337,6 @@ enum DragMode { // Dragging after a long-press, which selects by the word // around the selected word. longPress, - // Dragging to scroll the document. - scroll, - // We still don't know if the user is dragging vertically or horizontally. - waitingForScrollDirection, } /// Adds and removes an iOS-style editor toolbar, as dictated by an ancestor @@ -1511,6 +1378,7 @@ class SuperEditorIosToolbarOverlayManagerState extends State with SingleTick child: CustomScrollView( controller: _scrollController, shrinkWrap: widget.shrinkWrap, - physics: const NeverScrollableScrollPhysics(), slivers: [child], ), ), diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index f252ced67..c12bf79c6 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -26,6 +26,7 @@ import 'package:super_editor/src/infrastructure/content_layers.dart'; import 'package:super_editor/src/infrastructure/documents/document_scaffold.dart'; import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; import 'package:super_editor/src/infrastructure/links.dart'; import 'package:super_editor/src/infrastructure/platforms/android/toolbar.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; @@ -823,7 +824,10 @@ class SuperEditorState extends State { } } - Widget _buildGestureInteractor(BuildContext context) { + Widget _buildGestureInteractor(BuildContext context, {required Widget child}) { + // Ensure that gesture object fill entire viewport when not being + // in user specified scrollable. + final fillViewport = context.findAncestorScrollableWithVerticalScroll == null; switch (gestureMode) { case DocumentGestureMode.mouse: return DocumentMouseInteractor( @@ -835,7 +839,9 @@ class SuperEditorState extends State { selectionNotifier: editContext.composer.selectionNotifier, contentTapHandler: _contentTapDelegate, autoScroller: _autoScrollController, + fillViewport: fillViewport, showDebugPaint: widget.debugPaint.gestures, + child: child, ); case DocumentGestureMode.android: return AndroidDocumentTouchInteractor( @@ -848,7 +854,9 @@ class SuperEditorState extends State { contentTapHandler: _contentTapDelegate, scrollController: _scrollController, dragHandleAutoScroller: _dragHandleAutoScroller, + fillViewport: fillViewport, showDebugPaint: widget.debugPaint.gestures, + child: child, ); case DocumentGestureMode.iOS: return IosDocumentTouchInteractor( @@ -861,7 +869,9 @@ class SuperEditorState extends State { contentTapHandler: _contentTapDelegate, scrollController: _scrollController, dragHandleAutoScroller: _dragHandleAutoScroller, + fillViewport: fillViewport, showDebugPaint: widget.debugPaint.gestures, + child: child, ); } } diff --git a/super_editor/lib/src/infrastructure/documents/document_scaffold.dart b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart index 4ac084d26..d83adaec0 100644 --- a/super_editor/lib/src/infrastructure/documents/document_scaffold.dart +++ b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart @@ -40,7 +40,7 @@ class DocumentScaffold extends StatefulWidget { /// Builder that creates a gesture interaction widget, which is displayed /// beneath the document, at the same size as the viewport. - final WidgetBuilder gestureBuilder; + final Widget Function(BuildContext context, {required Widget child}) gestureBuilder; /// Builds the text input widget, if applicable. The text input system is placed /// above the gesture system and beneath viewport decoration. @@ -130,24 +130,7 @@ class _DocumentScaffoldState extends State { Widget _buildGestureSystem({ required Widget child, }) { - final ancestorScrollable = context.findAncestorScrollableWithVerticalScroll; - return SliverHybridStack( - // Ensure that gesture object fill entire viewport when not being - // in user specified scrollable. - fillViewport: ancestorScrollable == null, - children: [ - // A layer that sits beneath the document and handles gestures. - // It's beneath the document so that components that include - // interactive UI, like a Checkbox, can intercept their own - // touch events. - // - // This layer is placed outside of `ContentLayers` because this - // layer needs to be wider than the document, to fill all available - // space. - widget.gestureBuilder(context), - child, - ], - ); + return widget.gestureBuilder(context, child: child); } Widget _buildDocumentLayout() { diff --git a/super_editor/lib/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart b/super_editor/lib/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart index 2afdde9a9..34d8843fe 100644 --- a/super_editor/lib/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart +++ b/super_editor/lib/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart @@ -17,6 +17,8 @@ class EagerPanGestureRecognizer extends DragGestureRecognizer { super.allowedButtonsFilter, }); + bool Function()? canAccept; + @override bool isFlingGesture(VelocityEstimate estimate, PointerDeviceKind kind) { final minVelocity = minFlingVelocity ?? kMinFlingVelocity; @@ -25,6 +27,13 @@ class EagerPanGestureRecognizer extends DragGestureRecognizer { estimate.offset.distanceSquared > minDistance * minDistance; } + @override + void acceptGesture(int pointer) { + if (canAccept?.call() ?? true) { + super.acceptGesture(pointer); + } + } + @override DragEndDetails? considerFling(VelocityEstimate estimate, PointerDeviceKind kind) { if (!isFlingGesture(estimate, kind)) { @@ -45,7 +54,12 @@ class EagerPanGestureRecognizer extends DragGestureRecognizer { // Flutter's PanGestureRecognizer uses the pan slop, which is twice bigger than the hit slop, // to determine if the gesture should be accepted. Use the same distance used by the // VerticalDragGestureRecognizer. - return globalDistanceMoved.abs() > computeHitSlop(pointerDeviceKind, gestureSettings); + final res = globalDistanceMoved.abs() > computeHitSlop(pointerDeviceKind, gestureSettings); + if (res && canAccept != null) { + return canAccept!(); + } else { + return res; + } } @override 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 a7084f588..a6fc9f0b3 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 @@ -16,6 +16,7 @@ import 'package:super_editor/src/infrastructure/document_gestures.dart'; import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/flutter/overlay_with_groups.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; @@ -26,6 +27,7 @@ import 'package:super_editor/src/infrastructure/platforms/android/selection_hand import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; import 'package:super_editor/src/infrastructure/platforms/platform.dart'; import 'package:super_editor/src/infrastructure/signal_notifier.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; import 'package:super_editor/src/infrastructure/toolbar_position_delegate.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; import 'package:super_editor/src/super_textfield/metrics.dart'; @@ -54,10 +56,11 @@ class ReadOnlyAndroidDocumentTouchInteractor extends StatefulWidget { this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), required this.handleColor, required this.popoverToolbarBuilder, + required this.fillViewport, this.createOverlayControlsClipper, this.showDebugPaint = false, this.overlayController, - this.child, + required this.child, }) : super(key: key); final FocusNode focusNode; @@ -102,9 +105,11 @@ class ReadOnlyAndroidDocumentTouchInteractor extends StatefulWidget { /// Shows, hides, and positions a floating toolbar and magnifier. final MagnifierAndToolbarController? overlayController; + final bool fillViewport; + final bool showDebugPaint; - final Widget? child; + final Widget child; @override State createState() => _ReadOnlyAndroidDocumentTouchInteractorState(); @@ -112,8 +117,6 @@ class ReadOnlyAndroidDocumentTouchInteractor extends StatefulWidget { class _ReadOnlyAndroidDocumentTouchInteractorState extends State with WidgetsBindingObserver, SingleTickerProviderStateMixin { - bool _isScrolling = false; - // The ScrollPosition attached to the _ancestorScrollable, if there's an ancestor // Scrollable. ScrollPosition? _ancestorScrollPosition; @@ -147,9 +150,6 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State(null); - /// Holds the drag gesture that scrolls the document. - Drag? _scrollingDrag; - @override void initState() { super.initState(); @@ -302,39 +302,10 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State scrollPosition.isScrollingNotifier.addListener(_onScrollActivityChange)); } void _teardownScrollController() { widget.scrollController.removeListener(_onScrollChange); - - if (widget.scrollController.hasClients) { - scrollPosition.isScrollingNotifier.removeListener(_onScrollActivityChange); - } - } - - void _onScrollActivityChange() { - final isScrolling = scrollPosition.isScrollingNotifier.value; - - if (isScrolling) { - _isScrolling = true; - - // The long-press timer is cancelled if a pan gesture is detected. - // However, if we have an ancestor scrollable, we won't receive a pan gesture in this widget. - // Cancel the timer as soon as the user started scrolling. - _tapDownLongPressTimer?.cancel(); - _tapDownLongPressTimer = null; - } else { - onNextFrame((_) { - // Set our scrolling flag to false on the next frame, so that our tap handlers - // have an opportunity to see that the scrollable was scrolling when the user - // tapped down. - // - // See the "on tap down" handler for more info about why this flag is important. - _isScrolling = false; - }); - } } void _ensureSelectionExtentIsVisible() { @@ -448,6 +419,10 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State context.findViewportBox(); + final _interactor = GlobalKey(); + + RenderBox get interactorBox => _interactor.currentContext!.findRenderObject() as RenderBox; + Offset _getDocumentOffsetFromGlobalOffset(Offset globalOffset) { return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); } @@ -455,7 +430,7 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State{ - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapDown = _onTapDown - ..onTapCancel = _onTapCancel - ..onTapUp = _onTapUp - ..onDoubleTapDown = _onDoubleTapDown - ..onTripleTapDown = _onTripleTapDown - ..gestureSettings = gestureSettings; - }, - ), - VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => VerticalDragGestureRecognizer(), - (VerticalDragGestureRecognizer recognizer) { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer recognizer) { recognizer + ..canAccept = () { + if (_globalTapDownOffset == null) { + return false; + } + return _isLongPressInProgress; + } ..dragStartBehavior = DragStartBehavior.down ..onStart = _onPanStart ..onUpdate = _onPanUpdate @@ -1077,9 +1004,33 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State{ + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapCancel = _onTapCancel + ..onTapUp = _onTapUp + ..onDoubleTapDown = _onDoubleTapDown + ..onTripleTapDown = _onTripleTapDown + ..gestureSettings = gestureSettings; + }, + ), + }, + ); + return SliverHybridStack( + fillViewport: widget.fillViewport, + children: [ + layerBelow, + widget.child, + layerAbove, + ], + ); } Widget _buildControlsOverlay(BuildContext context) { 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 a6e1cbdc3..6065ddce4 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 @@ -193,10 +193,11 @@ class SuperReaderIosDocumentTouchInteractor extends StatefulWidget { required this.getDocumentLayout, required this.selection, required this.scrollController, + required this.fillViewport, this.contentTapHandler, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), this.showDebugPaint = false, - this.child, + required this.child, }) : super(key: key); final FocusNode focusNode; @@ -219,9 +220,11 @@ class SuperReaderIosDocumentTouchInteractor extends StatefulWidget { /// edges. final AxisOffset dragAutoScrollBoundary; + final bool fillViewport; + final bool showDebugPaint; - final Widget? child; + final Widget child; @override State createState() => _SuperReaderIosDocumentTouchInteractorState(); @@ -231,9 +234,6 @@ class _SuperReaderIosDocumentTouchInteractorState extends State _longPressStrategy != null; IosLongPressSelectionStrategy? _longPressStrategy; - /// Holds the drag gesture that scrolls the document. - Drag? _scrollingDrag; - @override void initState() { super.initState(); @@ -289,23 +286,6 @@ class _SuperReaderIosDocumentTouchInteractorState extends State context.findViewportBox(); - RenderBox get interactorBox => context.findRenderObject() as RenderBox; + final _interactor = GlobalKey(); + + RenderBox get interactorBox => _interactor.currentContext!.findRenderObject() as RenderBox; /// Converts the given [interactorOffset] from the [DocumentInteractor]'s coordinate /// space to the [DocumentLayout]'s coordinate space. Offset _interactorOffsetToDocumentOffset(Offset interactorOffset) { - final globalOffset = (context.findRenderObject() as RenderBox).localToGlobal(interactorOffset); + final globalOffset = interactorBox.localToGlobal(interactorOffset); return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); } @@ -644,9 +626,6 @@ class _SuperReaderIosDocumentTouchInteractorState extends State{ - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapDown = _onTapDown - ..onTapCancel = _onTapCancel - ..onTapUp = _onTapUp - ..onDoubleTapUp = _onDoubleTapUp - ..onTripleTapUp = _onTripleTapUp - ..gestureSettings = gestureSettings; - }, - ), EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => EagerPanGestureRecognizer(), (EagerPanGestureRecognizer instance) { instance + ..canAccept = () { + if (_globalTapDownOffset == null) { + return false; + } + final panDown = interactorBox.globalToLocal(_globalTapDownOffset!); + final isOverHandle = _isOverBaseHandle(panDown) || _isOverExtentHandle(panDown); + return isOverHandle || _isLongPressInProgress; + } ..dragStartBehavior = DragStartBehavior.down ..onDown = _onPanDown ..onStart = _onPanStart @@ -1032,11 +936,35 @@ class _SuperReaderIosDocumentTouchInteractorState extends State{ + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapCancel = _onTapCancel + ..onTapUp = _onTapUp + ..onDoubleTapUp = _onDoubleTapUp + ..onTripleTapUp = _onTripleTapUp + ..gestureSettings = gestureSettings; + }, + ), + }, + ); + return SliverHybridStack( + fillViewport: widget.fillViewport, + children: [ + layerBelow, + widget.child, + layerAbove, + ], + ); } Widget _buildMagnifierFocalPoint() { diff --git a/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart index 16589a921..e52434729 100644 --- a/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart @@ -12,6 +12,7 @@ import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; import 'reader_context.dart'; @@ -39,6 +40,7 @@ class ReadOnlyDocumentMouseInteractor extends StatefulWidget { required this.readerContext, this.contentTapHandler, required this.autoScroller, + required this.fillViewport, this.showDebugPaint = false, required this.child, }) : super(key: key); @@ -55,6 +57,8 @@ class ReadOnlyDocumentMouseInteractor extends StatefulWidget { /// Auto-scrolling delegate. final AutoScrollController autoScroller; + final bool fillViewport; + /// Paints some extra visual ornamentation to help with /// debugging, when `true`. final bool showDebugPaint; @@ -399,16 +403,6 @@ class _ReadOnlyDocumentMouseInteractorState extends State( - () => PanGestureRecognizer(), + () => PanGestureRecognizer(supportedDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + }), (PanGestureRecognizer recognizer) { recognizer ..onStart = _onPanStart diff --git a/super_editor/lib/src/super_reader/super_reader.dart b/super_editor/lib/src/super_reader/super_reader.dart index 9ac4512e4..ceea5118b 100644 --- a/super_editor/lib/src/super_reader/super_reader.dart +++ b/super_editor/lib/src/super_reader/super_reader.dart @@ -29,6 +29,7 @@ import 'package:super_editor/src/infrastructure/documents/document_scaffold.dart import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; import 'package:super_editor/src/infrastructure/documents/document_selection.dart'; import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; import 'package:super_editor/src/infrastructure/links.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; @@ -483,7 +484,10 @@ class SuperReaderState extends State { } } - Widget _buildGestureInteractor(BuildContext context) { + Widget _buildGestureInteractor(BuildContext context, {required Widget child}) { + // Ensure that gesture object fill entire viewport when not being + // in user specified scrollable. + final fillViewport = context.findAncestorScrollableWithVerticalScroll == null; switch (_gestureMode) { case DocumentGestureMode.mouse: return ReadOnlyDocumentMouseInteractor( @@ -491,8 +495,9 @@ class SuperReaderState extends State { readerContext: _readerContext, contentTapHandler: _contentTapDelegate, autoScroller: _autoScrollController, + fillViewport: fillViewport, showDebugPaint: widget.debugPaint.gestures, - child: const SizedBox(), + child: child, ); case DocumentGestureMode.android: return ReadOnlyAndroidDocumentTouchInteractor( @@ -510,6 +515,8 @@ class SuperReaderState extends State { createOverlayControlsClipper: widget.createOverlayControlsClipper, showDebugPaint: widget.debugPaint.gestures, overlayController: widget.overlayController, + fillViewport: fillViewport, + child: child, ); case DocumentGestureMode.iOS: return SuperReaderIosDocumentTouchInteractor( @@ -520,7 +527,9 @@ class SuperReaderState extends State { selection: _readerContext.selection, contentTapHandler: _contentTapDelegate, scrollController: _scrollController, + fillViewport: fillViewport, showDebugPaint: widget.debugPaint.gestures, + child: child, ); } } diff --git a/super_editor/test/super_editor/supereditor_scrolling_test.dart b/super_editor/test/super_editor/supereditor_scrolling_test.dart index 51b65f55a..7892f52a2 100644 --- a/super_editor/test/super_editor/supereditor_scrolling_test.dart +++ b/super_editor/test/super_editor/supereditor_scrolling_test.dart @@ -749,7 +749,7 @@ void main() { await tester // .createDocument() - .withSingleParagraph() + .withLongDoc() .withScrollController(scrollController) .pump(); @@ -781,7 +781,7 @@ void main() { await tester // .createDocument() - .withSingleParagraph() + .withLongDoc() .withScrollController(scrollController) .pump(); 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 564dc85ff..5c3149736 100644 --- a/super_editor/test/super_reader/super_reader_scrolling_test.dart +++ b/super_editor/test/super_reader/super_reader_scrolling_test.dart @@ -283,7 +283,7 @@ void main() { await tester // .createDocument() - .withSingleParagraph() + .withLongTextContent() .withScrollController(scrollController) .pump(); @@ -315,7 +315,7 @@ void main() { await tester // .createDocument() - .withSingleParagraph() + .withLongTextContent() .withScrollController(scrollController) .pump(); diff --git a/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart b/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart index c002839b5..83f31a069 100644 --- a/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart +++ b/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart @@ -378,6 +378,9 @@ void main() { const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); await tester.pumpAndSettle(); + // Wait a bit to ensure that the interactor recognizer doesn't consider this a double + // tap. + await tester.pump(const Duration(milliseconds: 500)); final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 34, 28); final handleRectGlobal = SuperEditorInspector.findMobileCaret().globalRect; @@ -409,6 +412,9 @@ void main() { const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); await tester.pumpAndSettle(); + // Wait a bit to ensure that the interactor recognizer doesn't consider this a double + // tap. + await tester.pump(const Duration(milliseconds: 500)); final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 34, 39); final handleRectGlobal = SuperEditorInspector.findMobileCaret().globalRect;