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));
+ }
+}