From 6f8624e3afb87647c61c00e76562e9483686b2cc Mon Sep 17 00:00:00 2001 From: Xin Yao Date: Wed, 11 Aug 2021 21:49:46 -0700 Subject: [PATCH] Bring into view upon selection extension When extending selection, have scroll controller jump to the most appropriate offset to display selection extension --- lib/src/widgets/box.dart | 83 +++++++++++++++++++ lib/src/widgets/cursor.dart | 7 +- lib/src/widgets/editor.dart | 59 ++++++++++++- lib/src/widgets/raw_editor.dart | 18 ++-- ...editor_state_selection_delegate_mixin.dart | 55 +++++++++++- lib/src/widgets/text_block.dart | 21 +++++ lib/src/widgets/text_line.dart | 21 +++++ 7 files changed, 249 insertions(+), 15 deletions(-) diff --git a/lib/src/widgets/box.dart b/lib/src/widgets/box.dart index 75547923..566c1a92 100644 --- a/lib/src/widgets/box.dart +++ b/lib/src/widgets/box.dart @@ -16,24 +16,107 @@ abstract class RenderContentProxyBox implements RenderBox { List getBoxesForSelection(TextSelection textSelection); } +/// Base class for render boxes of editable content. +/// +/// Implementations of this class usually work as a wrapper around +/// regular (non-editable) render boxes which implement +/// [RenderContentProxyBox]. abstract class RenderEditableBox extends RenderBox { + /// The document node represented by this render box. Container getContainer(); + /// Returns preferred line height at specified `position` in text. + /// + /// The `position` parameter must be relative to the [node]'s content. double preferredLineHeight(TextPosition position); + /// Returns the offset at which to paint the caret. + /// + /// The `position` parameter must be relative to the [node]'s content. + /// + /// Valid only after [layout]. Offset getOffsetForCaret(TextPosition position); + /// Returns the position within the text for the given pixel offset. + /// + /// The `offset` parameter must be local to this box coordinate system. + /// + /// Valid only after [layout]. TextPosition getPositionForOffset(Offset offset); + /// Returns the position relative to the [node] content + /// + /// The `position` must be within the [node] content + TextPosition globalToLocalPosition(TextPosition position); + + /// Returns the position within the text which is on the line above the given + /// `position`. + /// + /// The `position` parameter must be relative to the [node] content. + /// + /// Primarily used with multi-line or soft-wrapping text. + /// + /// Can return `null` which indicates that the `position` is at the topmost + /// line in the text already. TextPosition? getPositionAbove(TextPosition position); + /// Returns the position within the text which is on the line below the given + /// `position`. + /// + /// The `position` parameter must be relative to the [node] content. + /// + /// Primarily used with multi-line or soft-wrapping text. + /// + /// Can return `null` which indicates that the `position` is at the bottommost + /// line in the text already. TextPosition? getPositionBelow(TextPosition position); + /// Returns the text range of the word at the given offset. Characters not + /// part of a word, such as spaces, symbols, and punctuation, have word breaks + /// on both sides. In such cases, this method will return a text range that + /// contains the given text position. + /// + /// Word boundaries are defined more precisely in Unicode Standard Annex #29 + /// . + /// + /// The `position` parameter must be relative to the [node]'s content. + /// + /// Valid only after [layout]. TextRange getWordBoundary(TextPosition position); + /// Returns the text range of the line at the given offset. + /// + /// The newline, if any, is included in the range. + /// + /// The `position` parameter must be relative to the [node]'s content. + /// + /// Valid only after [layout]. TextRange getLineBoundary(TextPosition position); + /// Returns a list of rects that bound the given selection. + /// + /// A given selection might have more than one rect if this text painter + /// contains bidirectional text because logically contiguous text might not be + /// visually contiguous. + /// + /// Valid only after [layout]. + // List getBoxesForSelection(TextSelection selection); + + /// Returns a point for the base selection handle used on touch-oriented + /// devices. + /// + /// The `selection` parameter is expected to be in local offsets to this + /// render object's [node]. TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection); + /// Returns a point for the extent selection handle used on touch-oriented + /// devices. + /// + /// The `selection` parameter is expected to be in local offsets to this + /// render object's [node]. TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection); + + /// Returns the [Rect] in local coordinates for the caret at the given text + /// position. + Rect getLocalRectForCaret(TextPosition position); } diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 9fef16e5..25576120 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -292,8 +292,7 @@ class CursorPainter { } } - final pixelPerfectOffset = - _getPixelPerfectCursorOffset(editable!, caretRect, devicePixelRatio); + final pixelPerfectOffset = _getPixelPerfectCursorOffset(caretRect); if (!pixelPerfectOffset.isFinite) { return; } @@ -309,11 +308,9 @@ class CursorPainter { } Offset _getPixelPerfectCursorOffset( - RenderContentProxyBox editable, Rect caretRect, - double devicePixelRatio, ) { - final caretPosition = editable.localToGlobal(caretRect.topLeft); + final caretPosition = editable!.localToGlobal(caretRect.topLeft); final pixelMultiple = 1.0 / devicePixelRatio; final pixelPerfectOffsetX = caretPosition.dx.isFinite diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 5f3e80f1..c6d229fb 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -47,6 +47,8 @@ const linkPrefixes = [ ]; abstract class EditorState extends State { + ScrollController get scrollController; + TextEditingValue getTextEditingValue(); void setTextEditingValue(TextEditingValue value); @@ -62,32 +64,75 @@ abstract class EditorState extends State { void requestKeyboard(); } +/// Base interface for editable render objects. abstract class RenderAbstractEditor { TextSelection selectWordAtPosition(TextPosition position); TextSelection selectLineAtPosition(TextPosition position); + /// Returns preferred line height at specified `position` in text. double preferredLineHeight(TextPosition position); + /// Returns [Rect] for caret in local coordinates + /// + /// Useful to enforce visibility of full caret at given position + Rect getLocalRectForCaret(TextPosition position); + + /// Returns the local coordinates of the endpoints of the given selection. + /// + /// If the selection is collapsed (and therefore occupies a single point), the + /// returned list is of length one. Otherwise, the selection is not collapsed + /// and the returned list is of length two. In this case, however, the two + /// points might actually be co-located (e.g., because of a bidirectional + /// selection that contains some text but whose ends meet in the middle). TextPosition getPositionForOffset(Offset offset); List getEndpointsForSelection( TextSelection textSelection); + /// If [ignorePointer] is false (the default) then this method is called by + /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown] + /// callback. + /// + /// When [ignorePointer] is true, an ancestor widget must respond to tap + /// down events by calling this method. void handleTapDown(TapDownDetails details); + /// Selects the set words of a paragraph in a given range of global positions. + /// + /// The first and last endpoints of the selection will always be at the + /// beginning and end of a word respectively. + /// + /// {@macro flutter.rendering.editable.select} void selectWordsInRange( Offset from, Offset to, SelectionChangedCause cause, ); + /// Move the selection to the beginning or end of a word. + /// + /// {@macro flutter.rendering.editable.select} void selectWordEdge(SelectionChangedCause cause); + /// Select text between the global positions [from] and [to]. void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause); + /// Select a word around the location of the last tap down. + /// + /// {@macro flutter.rendering.editable.select} void selectWord(SelectionChangedCause cause); + /// Move selection to the location of the last tap down. + /// + /// {@template flutter.rendering.editable.select} + /// This method is mainly used to translate user inputs in global positions + /// into a [TextSelection]. When used in conjunction with a [EditableText], + /// the selection change is fed back into [TextEditingController.selection]. + /// + /// If you have a [TextEditingController], it's generally easier to + /// programmatically manipulate its `value` or `selection` directly. + /// {@endtemplate} void selectPosition(SelectionChangedCause cause); } @@ -988,7 +1033,8 @@ class RenderEditor extends RenderEditableContainerBox final caretTop = endpoint.point.dy - child.preferredLineHeight(TextPosition( - offset: selection.extentOffset - child.getContainer().offset)) - + offset: + selection.extentOffset - child.getContainer().documentOffset)) - kMargin + offsetInViewport + scrollBottomInset; @@ -1005,6 +1051,17 @@ class RenderEditor extends RenderEditableContainerBox } return math.max(dy, 0); } + + @override + Rect getLocalRectForCaret(TextPosition position) { + final targetChild = childAtPosition(position); + final localPosition = targetChild.globalToLocalPosition(position); + + final childLocalRect = targetChild.getLocalRectForCaret(localPosition); + + final boxParentData = targetChild.parentData as BoxParentData; + return childLocalRect.shift(Offset(0, boxParentData.offset.dy)); + } } class EditableContainerParentData diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index e06e00af..fdfcec6b 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -115,7 +115,9 @@ class RawEditorState extends EditorState EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay; EditorTextSelectionOverlay? _selectionOverlay; - ScrollController? _scrollController; + @override + ScrollController get scrollController => _scrollController; + late ScrollController _scrollController; late CursorCont _cursorCont; @@ -323,7 +325,7 @@ class RawEditorState extends EditorState }); _scrollController = widget.scrollController; - _scrollController!.addListener(_updateSelectionOverlayForScroll); + _scrollController.addListener(_updateSelectionOverlayForScroll); _cursorCont = CursorCont( show: ValueNotifier(widget.showCursor), @@ -392,9 +394,9 @@ class RawEditorState extends EditorState } if (widget.scrollController != _scrollController) { - _scrollController!.removeListener(_updateSelectionOverlayForScroll); + _scrollController.removeListener(_updateSelectionOverlayForScroll); _scrollController = widget.scrollController; - _scrollController!.addListener(_updateSelectionOverlayForScroll); + _scrollController.addListener(_updateSelectionOverlayForScroll); } if (widget.focusNode != oldWidget.focusNode) { @@ -570,16 +572,16 @@ class RawEditorState extends EditorState final viewport = RenderAbstractViewport.of(renderEditor); final editorOffset = renderEditor.localToGlobal(const Offset(0, 0), ancestor: viewport); - final offsetInViewport = _scrollController!.offset + editorOffset.dy; + final offsetInViewport = _scrollController.offset + editorOffset.dy; final offset = renderEditor.getOffsetToRevealCursor( - _scrollController!.position.viewportDimension, - _scrollController!.offset, + _scrollController.position.viewportDimension, + _scrollController.offset, offsetInViewport, ); if (offset != null) { - _scrollController!.animateTo( + _scrollController.animateTo( offset, duration: const Duration(milliseconds: 100), curve: Curves.fastOutSlowIn, diff --git a/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart index 7da1c66e..dcb9809a 100644 --- a/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart +++ b/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart @@ -1,3 +1,6 @@ +import 'dart:math'; + +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import '../editor.dart'; @@ -16,7 +19,57 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState @override void bringIntoView(TextPosition position) { - // TODO: implement bringIntoView + final localRect = getRenderEditor()!.getLocalRectForCaret(position); + final targetOffset = _getOffsetToRevealCaret(localRect, position); + + scrollController.jumpTo(targetOffset.offset); + getRenderEditor()!.showOnScreen(rect: targetOffset.rect); + } + + // Finds the closest scroll offset to the current scroll offset that fully + // reveals the given caret rect. If the given rect's main axis extent is too + // large to be fully revealed in `renderEditable`, it will be centered along + // the main axis. + // + // If this is a multiline EditableText (which means the Editable can only + // scroll vertically), the given rect's height will first be extended to match + // `renderEditable.preferredLineHeight`, before the target scroll offset is + // calculated. + RevealedOffset _getOffsetToRevealCaret(Rect rect, TextPosition position) { + if (!scrollController.position.allowImplicitScrolling) { + return RevealedOffset(offset: scrollController.offset, rect: rect); + } + + final editableSize = getRenderEditor()!.size; + final double additionalOffset; + final Offset unitOffset; + + // The caret is vertically centered within the line. Expand the caret's + // height so that it spans the line because we're going to ensure that the + // entire expanded caret is scrolled into view. + final expandedRect = Rect.fromCenter( + center: rect.center, + width: rect.width, + height: + max(rect.height, getRenderEditor()!.preferredLineHeight(position)), + ); + + additionalOffset = expandedRect.height >= editableSize.height + ? editableSize.height / 2 - expandedRect.center.dy + : 0.0 + .clamp(expandedRect.bottom - editableSize.height, expandedRect.top); + unitOffset = const Offset(0, 1); + + // No overscrolling when encountering tall fonts/scripts that extend past + // the ascent. + final targetOffset = (additionalOffset + scrollController.offset).clamp( + scrollController.position.minScrollExtent, + scrollController.position.maxScrollExtent, + ); + + final offsetDelta = scrollController.offset - targetOffset; + return RevealedOffset( + rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset); } @override diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index 4cee0324..93130884 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -527,6 +527,27 @@ class RenderEditableTextBlock extends RenderEditableContainerBox bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { return defaultHitTestChildren(result, position: position); } + + @override + Rect getLocalRectForCaret(TextPosition position) { + final child = childAtPosition(position); + final localPosition = TextPosition( + offset: position.offset - child.getContainer().offset, + affinity: position.affinity, + ); + final parentData = child.parentData as BoxParentData; + return child.getLocalRectForCaret(localPosition).shift(parentData.offset); + } + + @override + TextPosition globalToLocalPosition(TextPosition position) { + assert(getContainer().containsOffset(position.offset), + 'The provided text position is not in the current node'); + return TextPosition( + offset: position.offset - getContainer().documentOffset, + affinity: position.affinity, + ); + } } class _EditableBlock extends MultiChildRenderObjectWidget { diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index 8fab9056..bf3c415c 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -831,6 +831,27 @@ class RenderEditableTextLine extends RenderEditableBox { bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { return _children.first.hitTest(result, position: position); } + + @override + Rect getLocalRectForCaret(TextPosition position) { + final caretOffset = getOffsetForCaret(position); + var rect = + Rect.fromLTWH(0, 0, cursorWidth, cursorHeight).shift(caretOffset); + final cursorOffset = cursorCont.style.offset; + // Add additional cursor offset (generally only if on iOS). + if (cursorOffset != null) rect = rect.shift(cursorOffset); + return rect; + } + + @override + TextPosition globalToLocalPosition(TextPosition position) { + assert(getContainer().containsOffset(position.offset), + 'The provided text position is not in the current node'); + return TextPosition( + offset: position.offset - getContainer().documentOffset, + affinity: position.affinity, + ); + } } class _TextLineElement extends RenderObjectElement {