-
Notifications
You must be signed in to change notification settings - Fork 249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[SuperEditor][SuperTextField][iOS] Allow caret to slide over characters (Resolves #2103) #2245
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -157,6 +157,11 @@ class SuperEditorIosControlsController { | |
/// Controls the iOS floating cursor. | ||
late final FloatingCursorController floatingCursorController; | ||
|
||
/// Reports the offset where the caret is being dragged, in content space. | ||
/// | ||
/// If the caret is not being dragged, this value is `null`. | ||
final ValueNotifier<Offset?> caretDragOffset = ValueNotifier<Offset?>(null); | ||
|
||
/// Whether the iOS magnifier should be displayed right now. | ||
ValueListenable<bool> get shouldShowMagnifier => _shouldShowMagnifier; | ||
final _shouldShowMagnifier = ValueNotifier<bool>(false); | ||
|
@@ -1053,6 +1058,8 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor> | |
} | ||
|
||
if (_dragHandleType == HandleType.collapsed) { | ||
_controlsController!.caretDragOffset.value = | ||
_interactorOffsetToDocumentOffset(interactorBox.globalToLocal(_globalDragOffset!)); | ||
widget.editor.execute([ | ||
ChangeSelectionRequest( | ||
DocumentSelection.collapsed( | ||
|
@@ -1136,6 +1143,8 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor> | |
_onHandleDragEnd(); | ||
} | ||
|
||
_controlsController!.caretDragOffset.value = null; | ||
|
||
widget.dragHandleAutoScroller.value?.stopAutoScrollHandleMonitoring(); | ||
scrollPosition.removeListener(_onAutoScrollChange); | ||
} | ||
|
@@ -1327,18 +1336,26 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor> | |
// A drag isn't happening. Magnify the position that the user tapped. | ||
docPositionToMagnify = | ||
_docLayout.getDocumentPositionNearestToOffset(_globalTapDownOffset! + Offset(0, scrollPosition.pixels)); | ||
final centerOfContentAtOffset = _interactorOffsetToDocumentOffset( | ||
_docLayout.getRectForPosition(docPositionToMagnify!)!.center, | ||
); | ||
|
||
_magnifierFocalPointInDocumentSpace.value = centerOfContentAtOffset; | ||
} else { | ||
final docDragDelta = _globalDragOffset! - _globalStartDragOffset!; | ||
final dragScrollDelta = _dragStartScrollOffset! - scrollPosition.pixels; | ||
docPositionToMagnify = _docLayout | ||
.getDocumentPositionNearestToOffset(_startDragPositionOffset! + docDragDelta - Offset(0, dragScrollDelta)); | ||
final centerOfContentAtOffset = _interactorOffsetToDocumentOffset( | ||
_docLayout.getRectForPosition(docPositionToMagnify!)!.center, | ||
); | ||
_magnifierFocalPointInDocumentSpace.value = Offset( | ||
_interactorOffsetToDocumentOffset(interactorBox.globalToLocal(_globalDragOffset!)).dx, | ||
// The user can move the caret freely in the x-axis, but the caret is snaped to the selection | ||
// at the y-axis. | ||
centerOfContentAtOffset.dy, | ||
); | ||
} | ||
|
||
final centerOfContentAtOffset = _interactorOffsetToDocumentOffset( | ||
_docLayout.getRectForPosition(docPositionToMagnify!)!.center, | ||
); | ||
|
||
_magnifierFocalPointInDocumentSpace.value = centerOfContentAtOffset; | ||
} | ||
|
||
@override | ||
|
@@ -1939,6 +1956,8 @@ class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuild | |
return const ContentLayerProxyWidget(child: SizedBox()); | ||
} | ||
|
||
final controlsScope = SuperEditorIosControlsScope.rootOf(context); | ||
|
||
return IosHandlesDocumentLayer( | ||
document: editContext.document, | ||
documentLayout: editContext.documentLayout, | ||
|
@@ -1949,13 +1968,12 @@ class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuild | |
const ClearComposingRegionRequest(), | ||
]); | ||
}, | ||
handleColor: handleColor ?? | ||
SuperEditorIosControlsScope.maybeRootOf(context)?.handleColor ?? | ||
Theme.of(context).primaryColor, | ||
handleColor: handleColor ?? controlsScope.handleColor ?? Theme.of(context).primaryColor, | ||
caretWidth: caretWidth ?? 2, | ||
handleBallDiameter: handleBallDiameter ?? defaultIosHandleBallDiameter, | ||
shouldCaretBlink: SuperEditorIosControlsScope.rootOf(context).shouldCaretBlink, | ||
floatingCursorController: SuperEditorIosControlsScope.rootOf(context).floatingCursorController, | ||
shouldCaretBlink: controlsScope.shouldCaretBlink, | ||
floatingCursorController: controlsScope.floatingCursorController, | ||
caretDragOffset: controlsScope.caretDragOffset, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we de-dup this with floating cursor? I know you mentioned sharing some code over on Discord, but I wasn't sure if you already did that you wanted, or if that task still remains. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I looked into de-duping that, but I've found it would only make things more complicated, and we wouldn't be de-duping that much code. |
||
); | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; | |
import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; | ||
import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; | ||
import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; | ||
import 'package:super_editor/src/infrastructure/platforms/ios/floating_cursor.dart'; | ||
import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; | ||
import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; | ||
import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; | ||
|
@@ -501,6 +502,7 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { | |
this.handleBallDiameter = defaultIosHandleBallDiameter, | ||
required this.shouldCaretBlink, | ||
this.floatingCursorController, | ||
this.caretDragOffset, | ||
this.showDebugPaint = false, | ||
}); | ||
|
||
|
@@ -529,6 +531,11 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { | |
/// caret when the floating cursor is far away from its nearest text. | ||
final FloatingCursorController? floatingCursorController; | ||
|
||
/// The offset where the caret is being dragged, in content space. | ||
/// | ||
/// If the caret is not being dragged, this value must be `null`. | ||
final ValueListenable<Offset?>? caretDragOffset; | ||
|
||
final bool showDebugPaint; | ||
|
||
@override | ||
|
@@ -538,7 +545,7 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { | |
|
||
@visibleForTesting | ||
class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesDocumentLayer, DocumentSelectionLayout> | ||
with SingleTickerProviderStateMixin { | ||
with TickerProviderStateMixin { | ||
// These global keys are assigned to each draggable handle to | ||
// prevent a strange dragging issue. | ||
// | ||
|
@@ -562,8 +569,15 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD | |
void initState() { | ||
super.initState(); | ||
_caretBlinkController = BlinkController(tickerProvider: this); | ||
_caretReleaseAnimationController = AnimationController( | ||
duration: const Duration(milliseconds: 100), | ||
vsync: this, | ||
); | ||
|
||
_caretReleaseAnimationController.addListener(_onCaretAnimationChange); | ||
|
||
widget.selection.addListener(_onSelectionChange); | ||
widget.caretDragOffset?.addListener(_onCaretDragOffsetChange); | ||
widget.shouldCaretBlink.addListener(_onBlinkModeChange); | ||
widget.floatingCursorController?.isActive.addListener(_onFloatingCursorActivationChange); | ||
|
||
|
@@ -588,14 +602,21 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD | |
oldWidget.floatingCursorController?.isActive.removeListener(_onFloatingCursorActivationChange); | ||
widget.floatingCursorController?.isActive.addListener(_onFloatingCursorActivationChange); | ||
} | ||
|
||
if (widget.caretDragOffset != oldWidget.caretDragOffset) { | ||
oldWidget.caretDragOffset?.removeListener(_onCaretDragOffsetChange); | ||
widget.caretDragOffset?.addListener(_onCaretDragOffsetChange); | ||
} | ||
} | ||
|
||
@override | ||
void dispose() { | ||
widget.selection.removeListener(_onSelectionChange); | ||
widget.shouldCaretBlink.removeListener(_onBlinkModeChange); | ||
widget.floatingCursorController?.isActive.removeListener(_onFloatingCursorActivationChange); | ||
widget.caretDragOffset?.removeListener(_onCaretDragOffsetChange); | ||
|
||
_caretReleaseAnimationController.dispose(); | ||
_caretBlinkController.dispose(); | ||
super.dispose(); | ||
} | ||
|
@@ -621,13 +642,41 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD | |
@visibleForTesting | ||
bool get isDownstreamHandleDisplayed => layoutData?.downstream != null; | ||
|
||
/// The most recent caret position while the user is dragging it, in content space. | ||
/// | ||
/// Used to detect when the user releases the caret. | ||
Offset? _latestCaretDragOffset; | ||
|
||
/// Controls the animation of the caret moving from the position where | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please clarify the difference between "the position where the user released" and "the selected position" - how are these different? what are they, literally? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated. |
||
/// the user released the finger to the actual selected position. | ||
/// | ||
/// For example, the user can release the finger between characters or | ||
/// far from the text. The caret then animates to the closest legal position. | ||
late AnimationController _caretReleaseAnimationController; | ||
|
||
void _onSelectionChange() { | ||
_updateCaretFlash(); | ||
setState(() { | ||
// Schedule a new layout computation because the caret and/or handles need to move. | ||
}); | ||
} | ||
|
||
void _onCaretDragOffsetChange() { | ||
if (_latestCaretDragOffset != widget.caretDragOffset?.value && widget.caretDragOffset?.value == null) { | ||
// The user stopped dragging the caret. Animate the caret back to the | ||
// selected position. | ||
_caretReleaseAnimationController | ||
..reset() | ||
..forward(); | ||
return; | ||
} | ||
|
||
// Schedule a new layout computation because the caret needs to move. | ||
setState(() { | ||
_latestCaretDragOffset = widget.caretDragOffset?.value; | ||
}); | ||
} | ||
|
||
void _updateCaretFlash() { | ||
_caretBlinkController.jumpToOpaque(); | ||
_startOrStopBlinking(); | ||
|
@@ -664,6 +713,13 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD | |
} | ||
} | ||
|
||
void _onCaretAnimationChange() { | ||
// Schedule a new layout to position the caret acording to the animation. | ||
setState(() { | ||
// | ||
}); | ||
} | ||
|
||
/// Computes a zero width `Rect` that represents the x and y offsets and the height | ||
/// of the upstream or downstream handle in content space. | ||
/// | ||
|
@@ -720,7 +776,7 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD | |
} | ||
|
||
if (selection.isCollapsed) { | ||
Rect caretRect = documentLayout.getEdgeForPosition(selection.extent)!; | ||
Rect snapedCaretRect = documentLayout.getEdgeForPosition(selection.extent)!; | ||
|
||
// Default caret width used by IOSCollapsedHandle. | ||
const caretWidth = 2; | ||
|
@@ -738,20 +794,50 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD | |
// layer's RenderBox is outdated, because it wasn't laid out yet for the current frame. | ||
// Use the content's RenderBox, which was already laid out for the current frame. | ||
final contentBox = documentContext.findRenderObject() as RenderSliver?; | ||
if (contentBox != null && contentBox.hasSize && caretRect.left + caretWidth >= contentBox.size.width) { | ||
if (contentBox != null && contentBox.hasSize && snapedCaretRect.left + caretWidth >= contentBox.size.width) { | ||
// Ajust the caret position to make it entirely visible because it's currently placed | ||
// partially or entirely outside of the layers' bounds. This can happen for downstream selections | ||
// of block components that take all the available width. | ||
caretRect = Rect.fromLTWH( | ||
snapedCaretRect = Rect.fromLTWH( | ||
contentBox.size.width - caretWidth, | ||
caretRect.top, | ||
caretRect.width, | ||
caretRect.height, | ||
snapedCaretRect.top, | ||
snapedCaretRect.width, | ||
snapedCaretRect.height, | ||
); | ||
} | ||
|
||
Rect caretRect = snapedCaretRect; | ||
Rect? ghostCaretRect; | ||
|
||
final caretDragOffset = widget.caretDragOffset?.value; | ||
if (caretDragOffset != null) { | ||
// The user is dragging the caret. Snap it to the y-axis but let the user | ||
// move it freely on the x-axis. | ||
caretRect = Offset(caretDragOffset.dx, snapedCaretRect.top) & snapedCaretRect.size; | ||
final distance = caretRect.topLeft - snapedCaretRect.topLeft; | ||
// TODO: should we create a policy for this distance instead of using FloatingCursorPolicies? | ||
final isNearText = distance.dx.abs() <= FloatingCursorPolicies.maximumDistanceToBeNearText; | ||
if (!isNearText) { | ||
ghostCaretRect = snapedCaretRect; | ||
} | ||
} | ||
|
||
if (_caretReleaseAnimationController.isAnimating && _latestCaretDragOffset != null) { | ||
// The caret is animating between the release position and the selected position. | ||
// Interpolate the current offset. | ||
final caretReleaseOffset = Offset(_latestCaretDragOffset!.dx, snapedCaretRect.top); | ||
final caretDestinationOffset = snapedCaretRect.topLeft; | ||
caretRect = Offset.lerp( | ||
caretReleaseOffset, | ||
caretDestinationOffset, | ||
Curves.easeInOut.transform(_caretReleaseAnimationController.value), | ||
)! & | ||
snapedCaretRect.size; | ||
} | ||
|
||
return DocumentSelectionLayout( | ||
caret: caretRect, | ||
ghostCaret: ghostCaretRect, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you describe why we need to provide the ghost caret to the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Maybe we should create an |
||
); | ||
} else { | ||
return DocumentSelectionLayout( | ||
|
@@ -790,6 +876,8 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD | |
children: [ | ||
if (layoutData.caret != null) // | ||
_buildCollapsedHandle(caret: layoutData.caret!), | ||
if (layoutData.ghostCaret != null) // | ||
_buildGhostCaret(caret: layoutData.ghostCaret!), | ||
if (layoutData.upstream != null && layoutData.downstream != null) ...[ | ||
_buildUpstreamHandle( | ||
upstream: layoutData.upstream!, | ||
|
@@ -827,18 +915,46 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD | |
return const SizedBox(); | ||
} | ||
|
||
return IOSCollapsedHandle( | ||
key: DocumentKeys.caret, | ||
controller: _caretBlinkController, | ||
color: isShowingFloatingCursor ? Colors.grey : widget.handleColor, | ||
caretHeight: caret.height, | ||
caretWidth: widget.caretWidth, | ||
final isDraggingCaret = widget.caretDragOffset?.value != null; | ||
|
||
return DecoratedBox( | ||
decoration: BoxDecoration( | ||
boxShadow: [ | ||
if (isDraggingCaret) | ||
BoxShadow( | ||
blurRadius: 5, | ||
offset: const Offset(0.0, 6.0), | ||
color: widget.handleColor, | ||
) | ||
], | ||
), | ||
child: IOSCollapsedHandle( | ||
key: DocumentKeys.caret, | ||
controller: _caretBlinkController, | ||
color: isShowingFloatingCursor ? Colors.grey : widget.handleColor, | ||
caretHeight: caret.height, | ||
caretWidth: widget.caretWidth, | ||
), | ||
); | ||
}, | ||
), | ||
); | ||
} | ||
|
||
Widget _buildGhostCaret({ | ||
required Rect caret, | ||
}) { | ||
return Positioned( | ||
left: caret.left, | ||
top: caret.top, | ||
child: IOSCollapsedHandle( | ||
color: Colors.grey, | ||
caretHeight: caret.height, | ||
caretWidth: widget.caretWidth, | ||
), | ||
); | ||
} | ||
|
||
Widget _buildUpstreamHandle({ | ||
required Rect upstream, | ||
required Color debugColor, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you mention why we're making changes to magnifier stuff in a couple places in this PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The magnifier focal point is aligned with the caret. Since now the caret isn't snapped to character edges, the magnifier should follow the same policies.