From ac3189c341a9c48ecb5bf99dd9ea47b2d70e1dd3 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Mon, 7 Feb 2022 18:20:18 -0800 Subject: [PATCH] Remove RenderEditable dependency from TextSelectionHandleOverlay (#97967) --- .../lib/src/widgets/text_selection.dart | 421 +++++++++--------- .../test/material/text_field_test.dart | 2 +- .../test/widgets/editable_text_test.dart | 2 +- .../test/widgets/selectable_text_test.dart | 2 +- 4 files changed, 218 insertions(+), 209 deletions(-) diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index e1995e197e..c6bc899398 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -62,10 +62,6 @@ enum TextSelectionHandleType { collapsed, } -/// The text position that a give selection handle manipulates. Dragging the -/// [start] handle always moves the [start]/[baseOffset] of the selection. -enum _TextSelectionHandlePosition { start, end } - /// Signature for when a pointer that's dragging to select text has moved again. /// /// The first argument [startDetails] contains the details of the event that @@ -262,7 +258,7 @@ class TextSelectionOverlay { required this.renderObject, this.selectionControls, bool handlesVisible = false, - this.selectionDelegate, + required this.selectionDelegate, this.dragStartBehavior = DragStartBehavior.start, this.onSelectionHandleTapped, this.clipboardStatus, @@ -312,7 +308,7 @@ class TextSelectionOverlay { /// The delegate for manipulating the current selection in the owning /// text field. - final TextSelectionDelegate? selectionDelegate; + final TextSelectionDelegate selectionDelegate; /// Determines the way that drag start behavior is handled. /// @@ -412,8 +408,8 @@ class TextSelectionOverlay { return; _handles = [ - OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.start)), - OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.end)), + OverlayEntry(builder: (BuildContext context) => _buildStartHandle(context)), + OverlayEntry(builder: (BuildContext context) => _buildEndHandle(context)), ]; Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! @@ -507,29 +503,30 @@ class TextSelectionOverlay { _toolbarController.dispose(); } - Widget _buildHandle(BuildContext context, _TextSelectionHandlePosition position) { + Widget _buildStartHandle(BuildContext context) { final Widget handle; final TextSelectionControls? selectionControls = this.selectionControls; - if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) || - selectionControls == null) - handle = Container(); // hide the second handle when collapsed + if (selectionControls == null) + handle = Container(); else { handle = Visibility( visible: handlesVisible, - child: _TextSelectionHandleOverlay( - onSelectionHandleChanged: (TextSelection newSelection) { - _handleSelectionHandleChanged(newSelection, position); - }, + child: _SelectionHandleOverlay( + type: _chooseType( + renderObject.textDirection, + TextSelectionHandleType.left, + TextSelectionHandleType.right, + ), + handleLayerLink: startHandleLayerLink, onSelectionHandleTapped: onSelectionHandleTapped, - startHandleLayerLink: startHandleLayerLink, - endHandleLayerLink: endHandleLayerLink, - renderObject: renderObject, - selection: _selection, + onSelectionHandleDragStart: _handleSelectionStartHandleDragStart, + onSelectionHandleDragUpdate: _handleSelectionStartHandleDragUpdate, selectionControls: selectionControls, - position: position, + visibility: renderObject.selectionStartInViewport, + preferredLineHeight: renderObject.preferredLineHeight, + glyphHeight: _getStartGlyphHeight(), dragStartBehavior: dragStartBehavior, - selectionDelegate: selectionDelegate!, - ), + ) ); } return ExcludeSemantics( @@ -537,6 +534,131 @@ class TextSelectionOverlay { ); } + Widget _buildEndHandle(BuildContext context) { + final Widget handle; + final TextSelectionControls? selectionControls = this.selectionControls; + if (_selection.isCollapsed || selectionControls == null) + handle = Container(); // hide the second handle when collapsed + else { + handle = Visibility( + visible: handlesVisible, + child: _SelectionHandleOverlay( + type: _chooseType( + renderObject.textDirection, + TextSelectionHandleType.right, + TextSelectionHandleType.left, + ), + handleLayerLink: endHandleLayerLink, + onSelectionHandleTapped: onSelectionHandleTapped, + onSelectionHandleDragStart: _handleSelectionEndHandleDragStart, + onSelectionHandleDragUpdate: _handleSelectionEndHandleDragUpdate, + selectionControls: selectionControls, + visibility: renderObject.selectionEndInViewport, + preferredLineHeight: renderObject.preferredLineHeight, + glyphHeight: _getEndGlyphHeight(), + dragStartBehavior: dragStartBehavior, + ) + ); + } + return ExcludeSemantics( + child: handle, + ); + } + + double? _getStartGlyphHeight() { + final InlineSpan span = renderObject.text!; + final String prevText = span.toPlainText(); + final String currText = selectionDelegate.textEditingValue.text; + final int firstSelectedGraphemeExtent; + Rect? startHandleRect; + // Only calculate handle rects if the text in the previous frame + // is the same as the text in the current frame. This is done because + // widget.renderObject contains the renderEditable from the previous frame. + // If the text changed between the current and previous frames then + // widget.renderObject.getRectForComposingRange might fail. In cases where + // the current frame is different from the previous we fall back to + // renderObject.preferredLineHeight. + if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) { + final String selectedGraphemes = _selection.textInside(currText); + firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length; + startHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.start, end: _selection.start + firstSelectedGraphemeExtent)); + } + return startHandleRect?.height; + } + + double? _getEndGlyphHeight() { + final InlineSpan span = renderObject.text!; + final String prevText = span.toPlainText(); + final String currText = selectionDelegate.textEditingValue.text; + final int lastSelectedGraphemeExtent; + Rect? endHandleRect; + // See the explanation in _getStartGlyphHeight. + if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) { + final String selectedGraphemes = _selection.textInside(currText); + lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length; + endHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.end - lastSelectedGraphemeExtent, end: _selection.end)); + } + return endHandleRect?.height; + } + + late Offset _dragEndPosition; + + void _handleSelectionEndHandleDragStart(DragStartDetails details) { + final Size handleSize = selectionControls!.getHandleSize( + renderObject.preferredLineHeight, + ); + _dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height); + } + + void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { + _dragEndPosition += details.delta; + final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition); + + if (_selection.isCollapsed) { + _handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: true); + return; + } + + final TextSelection newSelection = TextSelection( + baseOffset: _selection.baseOffset, + extentOffset: position.offset, + ); + + if (newSelection.baseOffset >= newSelection.extentOffset) + return; // don't allow order swapping. + + _handleSelectionHandleChanged(newSelection, isEnd: true); + } + + late Offset _dragStartPosition; + + void _handleSelectionStartHandleDragStart(DragStartDetails details) { + final Size handleSize = selectionControls!.getHandleSize( + renderObject.preferredLineHeight, + ); + _dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height); + } + + void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { + _dragStartPosition += details.delta; + final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition); + + if (_selection.isCollapsed) { + _handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: false); + return; + } + + final TextSelection newSelection = TextSelection( + baseOffset: position.offset, + extentOffset: _selection.extentOffset, + ); + + if (newSelection.baseOffset >= newSelection.extentOffset) + return; // don't allow order swapping. + + _handleSelectionHandleChanged(newSelection, isEnd: false); + } + Widget _buildToolbar(BuildContext context) { if (selectionControls == null) return Container(); @@ -581,7 +703,7 @@ class TextSelectionOverlay { renderObject.preferredLineHeight, midpoint, endpoints, - selectionDelegate!, + selectionDelegate, clipboardStatus!, renderObject.lastSecondaryTapDownPosition, ); @@ -592,67 +714,67 @@ class TextSelectionOverlay { ); } - void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) { - final TextPosition textPosition; - switch (position) { - case _TextSelectionHandlePosition.start: - textPosition = newSelection.base; - break; - case _TextSelectionHandlePosition.end: - textPosition = newSelection.extent; - break; - } - selectionDelegate!.userUpdateTextEditingValue( + void _handleSelectionHandleChanged(TextSelection newSelection, {required bool isEnd}) { + final TextPosition textPosition = isEnd ? newSelection.extent : newSelection.base; + selectionDelegate.userUpdateTextEditingValue( _value.copyWith(selection: newSelection), SelectionChangedCause.drag, ); - selectionDelegate!.bringIntoView(textPosition); + selectionDelegate.bringIntoView(textPosition); } -} -/// This widget represents a single draggable text selection handle. -class _TextSelectionHandleOverlay extends StatefulWidget { - const _TextSelectionHandleOverlay({ - Key? key, - required this.selection, - required this.position, - required this.startHandleLayerLink, - required this.endHandleLayerLink, - required this.renderObject, - required this.onSelectionHandleChanged, - required this.onSelectionHandleTapped, - required this.selectionControls, - required this.selectionDelegate, - this.dragStartBehavior = DragStartBehavior.start, - }) : super(key: key); + TextSelectionHandleType _chooseType( + TextDirection textDirection, + TextSelectionHandleType ltrType, + TextSelectionHandleType rtlType, + ) { + if (_selection.isCollapsed) + return TextSelectionHandleType.collapsed; - final TextSelection selection; - final _TextSelectionHandlePosition position; - final LayerLink startHandleLayerLink; - final LayerLink endHandleLayerLink; - final RenderEditable renderObject; - final ValueChanged onSelectionHandleChanged; - final VoidCallback? onSelectionHandleTapped; - final TextSelectionControls selectionControls; - final DragStartBehavior dragStartBehavior; - final TextSelectionDelegate selectionDelegate; - - @override - _TextSelectionHandleOverlayState createState() => _TextSelectionHandleOverlayState(); - - ValueListenable get _visibility { - switch (position) { - case _TextSelectionHandlePosition.start: - return renderObject.selectionStartInViewport; - case _TextSelectionHandlePosition.end: - return renderObject.selectionEndInViewport; + assert(textDirection != null); + switch (textDirection) { + case TextDirection.ltr: + return ltrType; + case TextDirection.rtl: + return rtlType; } } } -class _TextSelectionHandleOverlayState - extends State<_TextSelectionHandleOverlay> with SingleTickerProviderStateMixin { - late Offset _dragPosition; +/// This widget represents a single draggable selection handle. +class _SelectionHandleOverlay extends StatefulWidget { + /// Create selection overlay. + const _SelectionHandleOverlay({ + Key? key, + required this.type, + required this.handleLayerLink, + this.onSelectionHandleTapped, + this.onSelectionHandleDragStart, + this.onSelectionHandleDragUpdate, + required this.selectionControls, + required this.visibility, + required this.preferredLineHeight, + this.glyphHeight, + this.dragStartBehavior = DragStartBehavior.start, + }) : super(key: key); + + final LayerLink handleLayerLink; + final VoidCallback? onSelectionHandleTapped; + final ValueChanged? onSelectionHandleDragStart; + final ValueChanged? onSelectionHandleDragUpdate; + final TextSelectionControls selectionControls; + final ValueListenable visibility; + final double preferredLineHeight; + final double? glyphHeight; + final TextSelectionHandleType type; + final DragStartBehavior dragStartBehavior; + + @override + State<_SelectionHandleOverlay> createState() => _SelectionHandleOverlayState(); + +} + +class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with SingleTickerProviderStateMixin { late AnimationController _controller; Animation get _opacity => _controller.view; @@ -664,11 +786,11 @@ class _TextSelectionHandleOverlayState _controller = AnimationController(duration: TextSelectionOverlay.fadeDuration, vsync: this); _handleVisibilityChanged(); - widget._visibility.addListener(_handleVisibilityChanged); + widget.visibility.addListener(_handleVisibilityChanged); } void _handleVisibilityChanged() { - if (widget._visibility.value) { + if (widget.visibility.value) { _controller.forward(); } else { _controller.reverse(); @@ -676,126 +798,30 @@ class _TextSelectionHandleOverlayState } @override - void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) { + void didUpdateWidget(_SelectionHandleOverlay oldWidget) { super.didUpdateWidget(oldWidget); - oldWidget._visibility.removeListener(_handleVisibilityChanged); + oldWidget.visibility.removeListener(_handleVisibilityChanged); _handleVisibilityChanged(); - widget._visibility.addListener(_handleVisibilityChanged); + widget.visibility.addListener(_handleVisibilityChanged); } @override void dispose() { - widget._visibility.removeListener(_handleVisibilityChanged); + widget.visibility.removeListener(_handleVisibilityChanged); _controller.dispose(); super.dispose(); } - void _handleDragStart(DragStartDetails details) { - final Size handleSize = widget.selectionControls.getHandleSize( - widget.renderObject.preferredLineHeight, - ); - _dragPosition = details.globalPosition + Offset(0.0, -handleSize.height); - } - - void _handleDragUpdate(DragUpdateDetails details) { - _dragPosition += details.delta; - final TextPosition position = widget.renderObject.getPositionForPoint(_dragPosition); - - if (widget.selection.isCollapsed) { - widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); - return; - } - - final TextSelection newSelection; - switch (widget.position) { - case _TextSelectionHandlePosition.start: - newSelection = TextSelection( - baseOffset: position.offset, - extentOffset: widget.selection.extentOffset, - ); - break; - case _TextSelectionHandlePosition.end: - newSelection = TextSelection( - baseOffset: widget.selection.baseOffset, - extentOffset: position.offset, - ); - break; - } - - if (newSelection.baseOffset >= newSelection.extentOffset) - return; // don't allow order swapping. - - widget.onSelectionHandleChanged(newSelection); - } - @override Widget build(BuildContext context) { - final LayerLink layerLink; - final TextSelectionHandleType type; - - switch (widget.position) { - case _TextSelectionHandlePosition.start: - layerLink = widget.startHandleLayerLink; - type = _chooseType( - widget.renderObject.textDirection, - TextSelectionHandleType.left, - TextSelectionHandleType.right, - ); - break; - case _TextSelectionHandlePosition.end: - // For collapsed selections, we shouldn't be building the [end] handle. - assert(!widget.selection.isCollapsed); - layerLink = widget.endHandleLayerLink; - type = _chooseType( - widget.renderObject.textDirection, - TextSelectionHandleType.right, - TextSelectionHandleType.left, - ); - break; - } - - // On some platforms we may want to calculate the start and end handles - // separately so they scale for the selected content. - // - // For the start handle we compute the rectangles that encompass the range - // of the first full selected grapheme cluster at the beginning of the selection. - // - // For the end handle we compute the rectangles that encompass the range - // of the last full selected grapheme cluster at the end of the selection. - // - // Only calculate start/end handle rects if the text in the previous frame - // is the same as the text in the current frame. This is done because - // widget.renderObject contains the renderEditable from the previous frame. - // If the text changed between the current and previous frames then - // widget.renderObject.getRectForComposingRange might fail. In cases where - // the current frame is different from the previous we fall back to - // widget.renderObject.preferredLineHeight. - final InlineSpan span = widget.renderObject.text!; - final String prevText = span.toPlainText(); - final String currText = widget.selectionDelegate.textEditingValue.text; - final int firstSelectedGraphemeExtent; - final int lastSelectedGraphemeExtent; - final TextSelection selection = widget.selection; - Rect? startHandleRect; - Rect? endHandleRect; - - if (prevText == currText && selection != null && selection.isValid && !selection.isCollapsed) { - final String selectedGraphemes = selection.textInside(currText); - firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length; - lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length; - assert(firstSelectedGraphemeExtent <= selectedGraphemes.length && lastSelectedGraphemeExtent <= selectedGraphemes.length); - startHandleRect = widget.renderObject.getRectForComposingRange(TextRange(start: selection.start, end: selection.start + firstSelectedGraphemeExtent)); - endHandleRect = widget.renderObject.getRectForComposingRange(TextRange(start: selection.end - lastSelectedGraphemeExtent, end: selection.end)); - } - final Offset handleAnchor = widget.selectionControls.getHandleAnchor( - type, - widget.renderObject.preferredLineHeight, - startHandleRect?.height ?? widget.renderObject.preferredLineHeight, - endHandleRect?.height ?? widget.renderObject.preferredLineHeight, + widget.type, + widget.preferredLineHeight, + widget.glyphHeight, + widget.glyphHeight, ); final Size handleSize = widget.selectionControls.getHandleSize( - widget.renderObject.preferredLineHeight, + widget.preferredLineHeight, ); final Rect handleRect = Rect.fromLTWH( @@ -817,7 +843,7 @@ class _TextSelectionHandleOverlayState ); return CompositedTransformFollower( - link: layerLink, + link: widget.handleLayerLink, offset: interactiveRect.topLeft, showWhenUnlinked: false, child: FadeTransition( @@ -829,8 +855,8 @@ class _TextSelectionHandleOverlayState child: GestureDetector( behavior: HitTestBehavior.translucent, dragStartBehavior: widget.dragStartBehavior, - onPanStart: _handleDragStart, - onPanUpdate: _handleDragUpdate, + onPanStart: widget.onSelectionHandleDragStart, + onPanUpdate: widget.onSelectionHandleDragUpdate, child: Padding( padding: EdgeInsets.only( left: padding.left, @@ -840,11 +866,11 @@ class _TextSelectionHandleOverlayState ), child: widget.selectionControls.buildHandle( context, - type, - widget.renderObject.preferredLineHeight, + widget.type, + widget.preferredLineHeight, widget.onSelectionHandleTapped, - startHandleRect?.height ?? widget.renderObject.preferredLineHeight, - endHandleRect?.height ?? widget.renderObject.preferredLineHeight, + widget.glyphHeight, + widget.glyphHeight, ), ), ), @@ -852,23 +878,6 @@ class _TextSelectionHandleOverlayState ), ); } - - TextSelectionHandleType _chooseType( - TextDirection textDirection, - TextSelectionHandleType ltrType, - TextSelectionHandleType rtlType, - ) { - if (widget.selection.isCollapsed) - return TextSelectionHandleType.collapsed; - - assert(textDirection != null); - switch (textDirection) { - case TextDirection.ltr: - return ltrType; - case TextDirection.rtl: - return rtlType; - } - } } /// Delegate interface for the [TextSelectionGestureDetectorBuilder]. diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 15674c5c40..c46f3e7a07 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -9138,7 +9138,7 @@ void main() { await tester.pumpAndSettle(); final List transitions = find.descendant( - of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_TextSelectionHandleOverlay'), + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'), matching: find.byType(FadeTransition), ).evaluate().map((Element e) => e.widget).cast().toList(); expect(transitions.length, 2); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 60b50213cd..5e0840181e 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4697,7 +4697,7 @@ void main() { // direction. final List transitions = find.descendant( - of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_TextSelectionHandleOverlay'), + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'), matching: find.byType(FadeTransition), ).evaluate().map((Element e) => e.widget).cast().toList(); expect(transitions.length, 2); diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index ddfaf61824..aa429648b7 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -4128,7 +4128,7 @@ void main() { await tester.pumpAndSettle(); final List transitions = find.descendant( - of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_TextSelectionHandleOverlay'), + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'), matching: find.byType(FadeTransition), ).evaluate().map((Element e) => e.widget).cast().toList(); expect(transitions.length, 2);