From 1cd946ee31155e1cf4fd6747242529942b3e36e5 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 8 Apr 2024 15:01:05 -0700 Subject: [PATCH] Text editing inside of Transformed.scale (#146019) Fixes bugs in the text selection positioning calculations so that they work even when the field is scaled. In many cases, we were simply translating things around without applying the proper localToGlobal (or vice versa) transform. | Before | After | | --- | --- | | | | Partial fix for: https://github.com/flutter/flutter/issues/144685 It looks like there are other problems where transforms aren't applied properly. Putting a transform at the root of the application, above MaterialApp, will expose more problems.
Sample code ```dart import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; void main() => runApp(const _App()); class _App extends StatelessWidget { const _App(); @override Widget build(BuildContext context) { return const MaterialApp(home: _Home()); } } class _Home extends StatefulWidget { const _Home(); @override State<_Home> createState() => _HomeState(); } class _HomeState extends State<_Home> { final _controller = WebViewController(); final TextEditingController textEditingController = TextEditingController( text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', ); final OverlayPortalController overlayPortalController = OverlayPortalController(); @override void initState() { super.initState(); _controller ..setJavaScriptMode(JavaScriptMode.unrestricted) ..loadRequest(Uri.https('api.flutter.dev')); } @override Widget build(BuildContext context) { overlayPortalController.show(); return Scaffold( appBar: AppBar( title: const Text('Scaled WebView Tester'), ), body: Stack( children: [ Transform.scale( alignment: Alignment.topLeft, scale: 0.5, child: TextField( controller: textEditingController, maxLines: null, ), ), OverlayPortal( controller: overlayPortalController, overlayChildBuilder: (BuildContext context) { return Positioned( top: 0.0, left: 0.0, child: SizedBox( height: 1000, width: 1000, child: Stack( children: [ Positioned( top: 90.0, left: 0.0, child: Container( height: 1.0, width: 1000, color: Colors.blue, ), ), Positioned( top: 102.0, left: 0.0, child: Container( height: 1.0, width: 1000, color: Colors.blue, ), ), ], ), ), ); }, ), ], ), ); } } ```
--- .../flutter/lib/src/material/magnifier.dart | 2 +- .../flutter/lib/src/rendering/editable.dart | 13 +- .../lib/src/widgets/text_selection.dart | 135 +++++--- .../test/widgets/editable_text_test.dart | 300 ++++++++++++++++++ .../test/widgets/text_selection_test.dart | 18 +- 5 files changed, 415 insertions(+), 53 deletions(-) diff --git a/packages/flutter/lib/src/material/magnifier.dart b/packages/flutter/lib/src/material/magnifier.dart index 3d9c2a1669..3ebd41187f 100644 --- a/packages/flutter/lib/src/material/magnifier.dart +++ b/packages/flutter/lib/src/material/magnifier.dart @@ -289,7 +289,7 @@ class Magnifier extends StatelessWidget { /// The [kStandardVerticalFocalPointShift] value is a constant so that /// positioning of this [Magnifier] can be done with a guaranteed size, as /// opposed to an estimate. - static const double kStandardVerticalFocalPointShift = 22; + static const double kStandardVerticalFocalPointShift = 22.0; static const double _borderRadius = 40; static const double _magnification = 1.25; diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index ae2a1e0b62..ba386545a6 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -1768,8 +1768,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, /// for a [TextPainter] object. TextPosition getPositionForPoint(Offset globalPosition) { _computeTextMetricsIfNeeded(); - globalPosition += -_paintOffset; - return _textPainter.getPositionForOffset(globalToLocal(globalPosition)); + return _textPainter.getPositionForOffset(globalToLocal(globalPosition) - _paintOffset); } /// Returns the [Rect] in local coordinates for the caret at the given text @@ -2070,10 +2069,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, /// to the [TextSelection.extentOffset]. void selectPositionAt({ required Offset from, Offset? to, required SelectionChangedCause cause }) { _computeTextMetricsIfNeeded(); - final TextPosition fromPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset)); + final TextPosition fromPosition = _textPainter.getPositionForOffset(globalToLocal(from) - _paintOffset); final TextPosition? toPosition = to == null ? null - : _textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)); + : _textPainter.getPositionForOffset(globalToLocal(to) - _paintOffset); final int baseOffset = fromPosition.offset; final int extentOffset = toPosition?.offset ?? fromPosition.offset; @@ -2107,9 +2106,9 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, /// {@macro flutter.rendering.RenderEditable.selectPosition} void selectWordsInRange({ required Offset from, Offset? to, required SelectionChangedCause cause }) { _computeTextMetricsIfNeeded(); - final TextPosition fromPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset)); + final TextPosition fromPosition = _textPainter.getPositionForOffset(globalToLocal(from) - _paintOffset); final TextSelection fromWord = getWordAtOffset(fromPosition); - final TextPosition toPosition = to == null ? fromPosition : _textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)); + final TextPosition toPosition = to == null ? fromPosition : _textPainter.getPositionForOffset(globalToLocal(to) - _paintOffset); final TextSelection toWord = toPosition == fromPosition ? fromWord : getWordAtOffset(toPosition); final bool isFromWordBeforeToWord = fromWord.start < toWord.end; @@ -2129,7 +2128,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, void selectWordEdge({ required SelectionChangedCause cause }) { _computeTextMetricsIfNeeded(); assert(_lastTapDownPosition != null); - final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition! - _paintOffset)); + final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition!) - _paintOffset); final TextRange word = _textPainter.getWordBoundary(position); late TextSelection newSelection; if (position.offset <= word.start) { diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 5398787540..9c7e16e644 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -646,9 +646,6 @@ class TextSelectionOverlay { required Offset globalGesturePosition, required TextPosition currentTextPosition, }) { - final Offset globalRenderEditableTopLeft = renderEditable.localToGlobal(Offset.zero); - final Rect localCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition); - final TextSelection lineAtOffset = renderEditable.getLineAtOffset(currentTextPosition); final TextPosition positionAtEndOfLine = TextPosition( offset: lineAtOffset.extentOffset, @@ -660,43 +657,64 @@ class TextSelectionOverlay { offset: lineAtOffset.baseOffset, ); - final Rect lineBoundaries = Rect.fromPoints( + final Rect localLineBoundaries = Rect.fromPoints( renderEditable.getLocalRectForCaret(positionAtBeginningOfLine).topCenter, renderEditable.getLocalRectForCaret(positionAtEndOfLine).bottomCenter, ); + final RenderBox? overlay = Overlay.of(context, rootOverlay: true).context.findRenderObject() as RenderBox?; + final Matrix4 transformToOverlay = renderEditable.getTransformTo(overlay); + final Rect overlayLineBoundaries = MatrixUtils.transformRect( + transformToOverlay, + localLineBoundaries, + ); + + final Rect localCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition); + final Rect overlayCaretRect = MatrixUtils.transformRect( + transformToOverlay, + localCaretRect, + ); + + final Offset overlayGesturePosition = overlay?.globalToLocal(globalGesturePosition) ?? globalGesturePosition; return MagnifierInfo( - fieldBounds: globalRenderEditableTopLeft & renderEditable.size, - globalGesturePosition: globalGesturePosition, - caretRect: localCaretRect.shift(globalRenderEditableTopLeft), - currentLineBoundaries: lineBoundaries.shift(globalRenderEditableTopLeft), + fieldBounds: MatrixUtils.transformRect(transformToOverlay, renderEditable.paintBounds), + globalGesturePosition: overlayGesturePosition, + caretRect: overlayCaretRect, + currentLineBoundaries: overlayLineBoundaries, ); } - // The contact position of the gesture at the current end handle location. - // Updated when the handle moves. + // The contact position of the gesture at the current end handle location, in + // global coordinates. Updated when the handle moves. late double _endHandleDragPosition; // The distance from _endHandleDragPosition to the center of the line that it - // corresponds to. - late double _endHandleDragPositionToCenterOfLine; + // corresponds to, in global coordinates. + late double _endHandleDragTarget; void _handleSelectionEndHandleDragStart(DragStartDetails details) { if (!renderObject.attached) { return; } - // This adjusts for the fact that the selection handles may not - // perfectly cover the TextPosition that they correspond to. _endHandleDragPosition = details.globalPosition.dy; - final Offset endPoint = - renderObject.localToGlobal(_selectionOverlay.selectionEndpoints.last.point); - final double centerOfLine = endPoint.dy - renderObject.preferredLineHeight / 2; - _endHandleDragPositionToCenterOfLine = centerOfLine - _endHandleDragPosition; + + // Use local coordinates when dealing with line height. because in case of a + // scale transformation, the line height will also be scaled. + final double centerOfLineLocal = _selectionOverlay.selectionEndpoints.last.point.dy + - renderObject.preferredLineHeight / 2; + final double centerOfLineGlobal = renderObject.localToGlobal( + Offset(0.0, centerOfLineLocal), + ).dy; + _endHandleDragTarget = centerOfLineGlobal - details.globalPosition.dy; + // Instead of finding the TextPosition at the handle's location directly, + // use the vertical center of the line that it points to. This is because + // selection handles typically hang above or below the line that they point + // to. final TextPosition position = renderObject.getPositionForPoint( Offset( details.globalPosition.dx, - centerOfLine, + centerOfLineGlobal, ), ); @@ -715,7 +733,17 @@ class TextSelectionOverlay { /// The handle jumps instantly between lines when the drag reaches a full /// line's height away from the original handle position. In other words, the /// line jump happens when the contact point would be located at the same - /// place on the handle at the new line as when the gesture started. + /// place on the handle at the new line as when the gesture started, for both + /// directions. + /// + /// This is not the same as just maintaining an offset from the target and the + /// contact point. There is no point at which moving the drag up and down a + /// small sub-line-height distance will cause the cursor to jump up and down + /// between lines. The drag distance must be a full line height for the cursor + /// to change lines, for both directions. + /// + /// Both parameters must be in local coordinates because the untransformed + /// line height is used, and the return value is in local coordinates as well. double _getHandleDy(double dragDy, double handleDy) { final double distanceDragged = dragDy - handleDy; final int dragDirection = distanceDragged < 0.0 ? -1 : 1; @@ -729,13 +757,24 @@ class TextSelectionOverlay { return; } - _endHandleDragPosition = _getHandleDy(details.globalPosition.dy, _endHandleDragPosition); - final Offset adjustedOffset = Offset( + // This is NOT the same as details.localPosition. That is relative to the + // selection handle, whereas this is relative to the RenderEditable. + final Offset localPosition = renderObject.globalToLocal(details.globalPosition); + + final double nextEndHandleDragPositionLocal = _getHandleDy( + localPosition.dy, + renderObject.globalToLocal(Offset(0.0, _endHandleDragPosition)).dy, + ); + _endHandleDragPosition = renderObject.localToGlobal( + Offset(0.0, nextEndHandleDragPositionLocal), + ).dy; + + final Offset handleTargetGlobal = Offset( details.globalPosition.dx, - _endHandleDragPosition + _endHandleDragPositionToCenterOfLine, + _endHandleDragPosition + _endHandleDragTarget, ); - final TextPosition position = renderObject.getPositionForPoint(adjustedOffset); + final TextPosition position = renderObject.getPositionForPoint(handleTargetGlobal); if (_selection.isCollapsed) { _selectionOverlay.updateMagnifier(_buildMagnifier( @@ -783,30 +822,37 @@ class TextSelectionOverlay { )); } - // The contact position of the gesture at the current start handle location. - // Updated when the handle moves. + // The contact position of the gesture at the current start handle location, + // in global coordinates. Updated when the handle moves. late double _startHandleDragPosition; // The distance from _startHandleDragPosition to the center of the line that - // it corresponds to. - late double _startHandleDragPositionToCenterOfLine; + // it corresponds to, in global coordinates. + late double _startHandleDragTarget; void _handleSelectionStartHandleDragStart(DragStartDetails details) { if (!renderObject.attached) { return; } - // This adjusts for the fact that the selection handles may not - // perfectly cover the TextPosition that they correspond to. _startHandleDragPosition = details.globalPosition.dy; - final Offset startPoint = - renderObject.localToGlobal(_selectionOverlay.selectionEndpoints.first.point); - final double centerOfLine = startPoint.dy - renderObject.preferredLineHeight / 2; - _startHandleDragPositionToCenterOfLine = centerOfLine - _startHandleDragPosition; + + // Use local coordinates when dealing with line height. because in case of a + // scale transformation, the line height will also be scaled. + final double centerOfLineLocal = _selectionOverlay.selectionEndpoints.first.point.dy + - renderObject.preferredLineHeight / 2; + final double centerOfLineGlobal = renderObject.localToGlobal( + Offset(0.0, centerOfLineLocal), + ).dy; + _startHandleDragTarget = centerOfLineGlobal - details.globalPosition.dy; + // Instead of finding the TextPosition at the handle's location directly, + // use the vertical center of the line that it points to. This is because + // selection handles typically hang above or below the line that they point + // to. final TextPosition position = renderObject.getPositionForPoint( Offset( details.globalPosition.dx, - centerOfLine, + centerOfLineGlobal, ), ); @@ -824,12 +870,21 @@ class TextSelectionOverlay { return; } - _startHandleDragPosition = _getHandleDy(details.globalPosition.dy, _startHandleDragPosition); - final Offset adjustedOffset = Offset( - details.globalPosition.dx, - _startHandleDragPosition + _startHandleDragPositionToCenterOfLine, + // This is NOT the same as details.localPosition. That is relative to the + // selection handle, whereas this is relative to the RenderEditable. + final Offset localPosition = renderObject.globalToLocal(details.globalPosition); + final double nextStartHandleDragPositionLocal = _getHandleDy( + localPosition.dy, + renderObject.globalToLocal(Offset(0.0, _startHandleDragPosition)).dy, ); - final TextPosition position = renderObject.getPositionForPoint(adjustedOffset); + _startHandleDragPosition = renderObject.localToGlobal( + Offset(0.0, nextStartHandleDragPositionLocal), + ).dy; + final Offset handleTargetGlobal = Offset( + details.globalPosition.dx, + _startHandleDragPosition + _startHandleDragTarget, + ); + final TextPosition position = renderObject.getPositionForPoint(handleTargetGlobal); if (_selection.isCollapsed) { _selectionOverlay.updateMagnifier(_buildMagnifier( diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 3c2bb0d216..8c031c4a17 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -16287,6 +16287,112 @@ void main() { isNull, ); }); + + testWidgets('magnifier is in correct position when EditableText is scaled', (WidgetTester tester) async { + controller.text = 'hello \n world \n this \n is \n text'; + final GlobalKey magnifierKey = GlobalKey(); + const double scale = 0.5; + await tester.pumpWidget(MaterialApp( + home: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Transform.scale( + scale: scale, + child: EditableText( + controller: controller, + maxLines: null, + showSelectionHandles: true, + autofocus: true, + focusNode: focusNode, + style: Typography.material2018().black.titleMedium!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + magnifierConfiguration: TextMagnifierConfiguration( + shouldDisplayHandlesInMagnifier: false, + magnifierBuilder: (BuildContext context, MagnifierController controller, ValueNotifier? notifier) { + return TextMagnifier( + key: magnifierKey, + magnifierInfo: notifier!, + ); + }, + ), + ), + ), + ], + ), + )); + + await tester.tapAt(textOffsetToPosition(tester, 3)); + await tester.pumpAndSettle(); + final List handles = List.from( + tester.renderObjectList( + find.descendant( + of: find.byType(CompositedTransformFollower), + matching: find.byType(Padding), + ), + ), + ); + expect(handles, hasLength(1)); + final RenderBox handle = handles.first; + expect(find.byKey(magnifierKey), findsNothing); + + final TestGesture gesture = await tester.startGesture(handle.localToGlobal(Offset( + handle.size.width / 2, + handle.size.height / 2, + ), + )); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(magnifierKey), findsOneWidget); + final Offset magnifierStart = tester.getTopLeft(find.byKey(magnifierKey)); + + // Dragging by a quarter of a line height does not move the magnifier. + // Typically, when not scaled, you need to drag by a full line height to + // get the magnifier to move vertically. + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + await gesture.moveBy(Offset(0.0, lineHeight / 4)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + expect(find.byKey(magnifierKey), findsOneWidget); + expect(tester.getTopLeft(find.byKey(magnifierKey)), magnifierStart); + + // Dragging by another quarter line height (total half a line height) does + // move the magnifier, because the text is scaled down by half. + await gesture.moveBy(Offset(0.0, lineHeight / 4)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + expect(find.byKey(magnifierKey), findsOneWidget); + expect( + tester.getTopLeft(find.byKey(magnifierKey)).dy, + magnifierStart.dy + lineHeight / 2, + ); + + // Drag back up by a quarter line height, cursor doesn't move. + await gesture.moveBy(Offset(0.0, -lineHeight / 4)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + expect(find.byKey(magnifierKey), findsOneWidget); + expect( + tester.getTopLeft(find.byKey(magnifierKey)).dy, + magnifierStart.dy + lineHeight / 2, + ); + + // Continuing the drag up to a half line height (whole line height scaled) + // does move the cursor. + await gesture.moveBy(Offset(0.0, -lineHeight / 4)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pumpAndSettle(); + expect(find.byKey(magnifierKey), findsOneWidget); + expect(tester.getTopLeft(find.byKey(magnifierKey)), magnifierStart); + + await gesture.up(); + await tester.pump(const Duration(milliseconds: 20)); + expect(find.byKey(magnifierKey), findsNothing); + + await tester.pumpAndSettle(); + }); }); // Regression test for: https://github.com/flutter/flutter/issues/117418. @@ -17248,6 +17354,200 @@ void main() { await tester.pumpAndSettle(); expect(scrollController.offset, 75.0); }); + + testWidgets('getPositionForPoint is correct when EditableText is scaled', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + controller.text = 'Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8'; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Transform.scale( + scale: 0.5, + child: EditableText( + key: key, + cursorColor: cursorColor, + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + maxLines: 2, + minLines: 2, + style: textStyle, + ), + ), + ), + ), + ); + + // With no scroll, the top left is the first character. + final EditableTextState state = tester.state(find.byType(EditableText)); + final Offset topLeft = tester.getTopLeft(find.byType(EditableText)); + expect( + state.renderEditable.getPositionForPoint(topLeft), + const TextPosition(offset: 0), + ); + + // After scrolling to view the fourth line, the top left is the start of the + // third line. + state.bringIntoView(const TextPosition(offset: 18)); + await tester.pumpAndSettle(); + expect( + state.renderEditable.getPositionForPoint(topLeft), + const TextPosition(offset: 12), + ); + }, + skip: kIsWeb, // [intended] + ); + + testWidgets('selectPositionAt is correct when EditableText is scaled', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + controller.text = 'Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8'; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Transform.scale( + scale: 0.5, + child: EditableText( + key: key, + cursorColor: cursorColor, + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + maxLines: 2, + minLines: 2, + style: textStyle, + ), + ), + ), + ), + ); + + final EditableTextState state = tester.state(find.byType(EditableText)); + final Offset topLeft = tester.getTopLeft(find.byType(EditableText)); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: -1), + ); + + // Scroll to the fourth line and select the full line above that. + state.bringIntoView(const TextPosition(offset: 18)); + await tester.pumpAndSettle(); + state.renderEditable.selectPositionAt( + from: topLeft, + to: topLeft + const Offset(100.0, 0.0), + cause: SelectionChangedCause.drag, + ); + await tester.pumpAndSettle(); + expect( + state.textEditingValue.selection, + const TextSelection(baseOffset: 12, extentOffset: 17), + ); + }, + skip: kIsWeb, // [intended] + ); + + testWidgets('selectWordsInRange is correct when EditableText is scaled', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + controller.text = 'Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8'; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Transform.scale( + scale: 0.5, + child: EditableText( + key: key, + cursorColor: cursorColor, + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + maxLines: 2, + minLines: 2, + style: textStyle, + ), + ), + ), + ), + ); + + final EditableTextState state = tester.state(find.byType(EditableText)); + final Offset topLeft = tester.getTopLeft(find.byType(EditableText)); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: -1), + ); + + // Scroll to the fourth line and select the full line above that. + state.bringIntoView(const TextPosition(offset: 18)); + await tester.pumpAndSettle(); + state.renderEditable.selectWordsInRange( + from: topLeft, + to: topLeft + const Offset(100.0, 0.0), + cause: SelectionChangedCause.drag, + ); + await tester.pumpAndSettle(); + expect( + state.textEditingValue.selection, + const TextSelection(baseOffset: 12, extentOffset: 17), + ); + }, + skip: kIsWeb, // [intended] + ); + + testWidgets('selectWordEdge is correct when EditableText is scaled', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + controller.text = 'Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8'; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Transform.scale( + scale: 0.5, + child: EditableText( + key: key, + cursorColor: cursorColor, + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + maxLines: 2, + minLines: 2, + style: textStyle, + ), + ), + ), + ), + ); + + final EditableTextState state = tester.state(find.byType(EditableText)); + //final Offset topLeft = tester.getTopLeft(find.byType(EditableText)); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: -1), + ); + + // Scroll to the fourth line. + state.bringIntoView(const TextPosition(offset: 18)); + await tester.pumpAndSettle(); + + // Secondary tap inside of the 3rd line. + state.renderEditable.handleSecondaryTapDown(TapDownDetails( + globalPosition: textOffsetToPosition(tester, 13), + )); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: -1), + ); + + // selectWordEdge moves the selection to the end of the 3rd line. + state.renderEditable.selectWordEdge( + cause: SelectionChangedCause.tap, + ); + expect( + state.textEditingValue.selection, + const TextSelection.collapsed(offset: 17, affinity: TextAffinity.upstream), + ); + }); } class UnsettableController extends TextEditingController { diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index 9e21a0133d..fee2a0c576 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -1474,11 +1474,15 @@ void main() { testWidgets('can show magnifier when no handles exist', (WidgetTester tester) async { final GlobalKey magnifierKey = GlobalKey(); + Offset? builtGlobalGesturePosition; + Rect? builtFieldBounds; final SelectionOverlay selectionOverlay = await pumpApp( tester, magnifierConfiguration: TextMagnifierConfiguration( shouldDisplayHandlesInMagnifier: false, magnifierBuilder: (BuildContext context, MagnifierController controller, ValueNotifier? notifier) { + builtGlobalGesturePosition = notifier?.value.globalGesturePosition; + builtFieldBounds = notifier?.value.fieldBounds; return SizedBox.shrink( key: magnifierKey, ); @@ -1488,10 +1492,12 @@ void main() { expect(find.byKey(magnifierKey), findsNothing); + const Offset globalGesturePosition = Offset(10.0, 10.0); + final Rect fieldBounds = Offset.zero & const Size(200.0, 50.0); final MagnifierInfo info = MagnifierInfo( - globalGesturePosition: Offset.zero, + globalGesturePosition: globalGesturePosition, caretRect: Offset.zero & const Size(5.0, 20.0), - fieldBounds: Offset.zero & const Size(200.0, 50.0), + fieldBounds: fieldBounds, currentLineBoundaries: Offset.zero & const Size(200.0, 50.0), ); selectionOverlay.showMagnifier(info); @@ -1499,6 +1505,8 @@ void main() { expect(tester.takeException(), isNull); expect(find.byKey(magnifierKey), findsOneWidget); + expect(builtFieldBounds, fieldBounds); + expect(builtGlobalGesturePosition, globalGesturePosition); selectionOverlay.dispose(); await tester.pumpAndSettle(); @@ -1724,7 +1732,7 @@ void main() { final LayerLink endHandleLayerLink = LayerLink(); final LayerLink toolbarLayerLink = LayerLink(); - final UniqueKey editableText = UniqueKey(); + final UniqueKey editableTextKey = UniqueKey(); final TextEditingController controller = TextEditingController(); addTearDown(controller.dispose); final FocusNode focusNode = FocusNode(); @@ -1735,7 +1743,7 @@ void main() { key: column, children: [ FakeEditableText( - key: editableText, + key: editableTextKey, controller: controller, focusNode: focusNode, ), @@ -1757,7 +1765,7 @@ void main() { return TextSelectionOverlay( value: TextEditingValue.empty, - renderObject: tester.state(find.byKey(editableText)).renderEditable, + renderObject: tester.state(find.byKey(editableTextKey)).renderEditable, context: tester.element(find.byKey(column)), onSelectionHandleTapped: () {}, startHandleLayerLink: startHandleLayerLink,