Unverified Commit bbb95e57 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

EditableText.bringIntoView calls showOnScreen (#58346)

parent 5267d987
......@@ -1487,45 +1487,57 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
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),
);
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;
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);
}
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;
}
// 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;
// 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 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
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 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
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 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
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 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@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.
......
......@@ -4083,6 +4083,95 @@ void main() {
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 _TransformedEditableTextState extends State<TransformedEditableText> {
);
}
}
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));
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment