Skip to content
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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1136,6 +1143,8 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
_onHandleDragEnd();
}

_controlsController!.caretDragOffset.value = null;

widget.dragHandleAutoScroller.value?.stopAutoScrollHandleMonitoring();
scrollPosition.removeListener(_onAutoScrollChange);
}
Expand Down Expand Up @@ -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;
Copy link
Contributor

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?

Copy link
Collaborator Author

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.

} 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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,14 @@ class _SelectionLeadersDocumentLayerState
class DocumentSelectionLayout {
DocumentSelectionLayout({
this.caret,
this.ghostCaret,
this.upstream,
this.downstream,
this.expandedSelectionBounds,
});

final Rect? caret;
final Rect? ghostCaret;
final Rect? upstream;
final Rect? downstream;
final Rect? expandedSelectionBounds;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -501,6 +502,7 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget {
this.handleBallDiameter = defaultIosHandleBallDiameter,
required this.shouldCaretBlink,
this.floatingCursorController,
this.caretDragOffset,
this.showDebugPaint = false,
});

Expand Down Expand Up @@ -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
Expand All @@ -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.
//
Expand All @@ -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);

Expand All @@ -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();
}
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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();
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The 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 DocumentSelectionLayout? Maybe the Dart Doc for DocumentSelectionLayout isn't quite accurate, but it says "Visual layout bounds related to a user selection..." - that sounds like its just tracking geometry so that other things can also track that geometry. If so, I'm curious what other parts of Super Editor needs to track the ghost caret inside of DocumentSelectionLayout?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DocumentSelectionLayout is how we expose the information of where the caret should be displayed. The IosControlsDocumentLayerState uses it to size and position the caret. Other parts of the editor won't need this.

Maybe we should create an IosDocumentSelectionLayout to expose this additional property that only iOS uses?

);
} else {
return DocumentSelectionLayout(
Expand Down Expand Up @@ -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!,
Expand Down Expand Up @@ -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,
Expand Down
Loading