EditableText.bringIntoView calls showOnScreen (#58346)
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index ff29484..94cd50b 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -1487,45 +1487,57 @@ bool get _hasFocus => widget.focusNode.hasFocus; bool get _isMultiline => widget.maxLines != 1; - // Calculate the new scroll offset so the cursor remains visible. - double _getScrollOffsetForCaret(Rect caretRect) { - double caretStart; - double caretEnd; - if (_isMultiline) { - // 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 double lineHeight = renderEditable.preferredLineHeight; - final double caretOffset = (lineHeight - caretRect.height) / 2; - caretStart = caretRect.top - caretOffset; - caretEnd = caretRect.bottom + caretOffset; + // 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) { + if (!_scrollController.position.allowImplicitScrolling) + return RevealedOffset(offset: _scrollController.offset, rect: rect); + + final Size editableSize = renderEditable.size; + double additionalOffset; + Offset unitOffset; + + if (!_isMultiline) { + additionalOffset = rect.width >= editableSize.width + // Center `rect` if it's oversized. + ? editableSize.width / 2 - rect.center.dx + // Valid additional offsets range from (rect.right - size.width) + // to (rect.left). Pick the closest one if out of range. + : 0.0.clamp(rect.right - editableSize.width, rect.left) as double; + unitOffset = const Offset(1, 0); } else { - // Scrolls horizontally for single-line fields. - caretStart = caretRect.left; - caretEnd = caretRect.right; + // 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 Rect expandedRect = Rect.fromCenter( + center: rect.center, + width: rect.width, + height: math.max(rect.height, renderEditable.preferredLineHeight), + ); + + additionalOffset = expandedRect.height >= editableSize.height + ? editableSize.height / 2 - expandedRect.center.dy + : 0.0.clamp(expandedRect.bottom - editableSize.height, expandedRect.top) as double; + unitOffset = const Offset(0, 1); } - double scrollOffset = _scrollController.offset; - final double viewportExtent = _scrollController.position.viewportDimension; - if (caretStart < 0.0) { // cursor before start of bounds - scrollOffset += caretStart; - } else if (caretEnd >= viewportExtent) { // cursor after end of bounds - scrollOffset += caretEnd - viewportExtent; - } + // No overscrolling when encountering tall fonts/scripts that extend past + // the ascent. + final double targetOffset = (additionalOffset + _scrollController.offset) + .clamp( + _scrollController.position.minScrollExtent, + _scrollController.position.maxScrollExtent, + ) as double; - if (_isMultiline) { - // Clamp the final results to prevent programmatically scrolling to - // out-of-paragraph-bounds positions when encountering tall fonts/scripts that - // extend past the ascent. - scrollOffset = scrollOffset.clamp(0.0, renderEditable.maxScrollExtent) as double; - } - return scrollOffset; - } - - // Calculates where the `caretRect` would be if `_scrollController.offset` is set to `scrollOffset`. - Rect _getCaretRectAtScrollOffset(Rect caretRect, double scrollOffset) { - final double offsetDiff = _scrollController.offset - scrollOffset; - return _isMultiline ? caretRect.translate(0.0, offsetDiff) : caretRect.translate(offsetDiff, 0.0); + final double offsetDelta = _scrollController.offset - targetOffset; + return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset); } bool get _hasInputConnection => _textInputConnection != null && _textInputConnection.attached; @@ -1684,19 +1696,15 @@ if (_currentCaretRect == null || !_scrollController.hasClients) { return; } - final double scrollOffsetForCaret = _getScrollOffsetForCaret(_currentCaretRect); - _scrollController.animateTo( - scrollOffsetForCaret, - duration: _caretAnimationDuration, - curve: _caretAnimationCurve, - ); - final Rect newCaretRect = _getCaretRectAtScrollOffset(_currentCaretRect, scrollOffsetForCaret); - // Enlarge newCaretRect by scrollPadding to ensure that caret is not + + final double lineHeight = renderEditable.preferredLineHeight; + + // Enlarge the target rect by scrollPadding to ensure that caret is not // positioned directly at the edge after scrolling. double bottomSpacing = widget.scrollPadding.bottom; if (_selectionOverlay?.selectionControls != null) { final double handleHeight = _selectionOverlay.selectionControls - .getHandleSize(renderEditable.preferredLineHeight).height; + .getHandleSize(lineHeight).height; final double interactiveHandleHeight = math.max( handleHeight, kMinInteractiveDimension, @@ -1704,7 +1712,7 @@ final Offset anchor = _selectionOverlay.selectionControls .getHandleAnchor( TextSelectionHandleType.collapsed, - renderEditable.preferredLineHeight, + lineHeight, ); final double handleCenter = handleHeight / 2 - anchor.dy; bottomSpacing = math.max( @@ -1712,14 +1720,20 @@ bottomSpacing, ); } - final Rect inflatedRect = Rect.fromLTRB( - newCaretRect.left - widget.scrollPadding.left, - newCaretRect.top - widget.scrollPadding.top, - newCaretRect.right + widget.scrollPadding.right, - newCaretRect.bottom + bottomSpacing, + + final EdgeInsets caretPadding = widget.scrollPadding + .copyWith(bottom: bottomSpacing); + + final RevealedOffset targetOffset = _getOffsetToRevealCaret(_currentCaretRect); + + _scrollController.animateTo( + targetOffset.offset, + duration: _caretAnimationDuration, + curve: _caretAnimationCurve, ); - _editableKey.currentContext.findRenderObject().showOnScreen( - rect: inflatedRect, + + renderEditable.showOnScreen( + rect: caretPadding.inflateRect(targetOffset.rect), duration: _caretAnimationDuration, curve: _caretAnimationCurve, ); @@ -1928,7 +1942,11 @@ @override void bringIntoView(TextPosition position) { - _scrollController.jumpTo(_getScrollOffsetForCaret(renderEditable.getLocalRectForCaret(position))); + final Rect localRect = renderEditable.getLocalRectForCaret(position); + final RevealedOffset targetOffset = _getOffsetToRevealCaret(localRect); + + _scrollController.jumpTo(targetOffset.offset); + renderEditable.showOnScreen(rect: targetOffset.rect); } /// Shows the selection toolbar at the location of the current cursor.
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 05c4c8e..c781ce4 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -4083,6 +4083,95 @@ expect(scrollable.controller.position.pixels, equals(renderEditable.maxScrollExtent)); }, skip: isBrowser); + testWidgets('bringIntoView brings the caret into view when in a viewport', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/55547. + final TextEditingController controller = TextEditingController(text: testText * 20); + final ScrollController editableScrollController = ScrollController(); + final ScrollController outerController = ScrollController(); + + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 200, + height: 200, + child: SingleChildScrollView( + controller: outerController, + child: EditableText( + maxLines: null, + controller: controller, + scrollController: editableScrollController, + focusNode: FocusNode(), + style: textStyle, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + ), + ), + ), + ), + )); + + + expect(outerController.offset, 0); + expect(editableScrollController.offset, 0); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + state.bringIntoView(TextPosition(offset: controller.text.length)); + + await tester.pumpAndSettle(); + // The SingleChildScrollView is scrolled instead of the EditableText to + // reveal the caret. + expect(outerController.offset, outerController.position.maxScrollExtent); + expect(editableScrollController.offset, 0); + }); + + testWidgets('bringIntoView does nothing if the physics prohibits implicit scrolling', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: testText * 20); + final ScrollController scrollController = ScrollController(); + + Future<void> buildWithPhysics({ ScrollPhysics physics }) async { + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 200, + height: 200, + child: EditableText( + maxLines: null, + controller: controller, + scrollController: scrollController, + focusNode: FocusNode(), + style: textStyle, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + scrollPhysics: physics, + ), + ), + ), + )); + } + + + await buildWithPhysics(); + expect(scrollController.offset, 0); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + state.bringIntoView(TextPosition(offset: controller.text.length)); + + await tester.pumpAndSettle(); + // Scrolled to the maxScrollExtent to reveal to caret. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + + scrollController.jumpTo(0); + await buildWithPhysics(physics: const NoImplicitScrollPhysics()); + expect(scrollController.offset, 0); + + state.bringIntoView(TextPosition(offset: controller.text.length)); + + await tester.pumpAndSettle(); + expect(scrollController.offset, 0); + }); + testWidgets('obscured multiline fields throw an exception', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); expect( @@ -4900,3 +4989,15 @@ ); } } + +class NoImplicitScrollPhysics extends AlwaysScrollableScrollPhysics { + const NoImplicitScrollPhysics({ ScrollPhysics parent }) : super(parent: parent); + + @override + bool get allowImplicitScrolling => false; + + @override + NoImplicitScrollPhysics applyTo(ScrollPhysics ancestor) { + return NoImplicitScrollPhysics(parent: buildParent(ancestor)); + } +}