From 2ab60e93ef2bf3cd6fa32f76bb8ba296225dd7b8 Mon Sep 17 00:00:00 2001 From: Jason Simmons Date: Wed, 31 May 2017 10:33:23 -0700 Subject: [PATCH] Scroll text fields when the caret moves outside the viewport (#10323) Fixes https://github.com/flutter/flutter/issues/9923 --- .../flutter/lib/src/rendering/editable.dart | 19 +++++- .../lib/src/widgets/editable_text.dart | 23 +++++++- .../test/material/text_field_test.dart | 59 +++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 2cde1ae4a3..d3d0922e1b 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -25,6 +25,11 @@ final String _kZeroWidthSpace = new String.fromCharCode(0x200B); /// Used by [RenderEditable.onSelectionChanged]. typedef void SelectionChangedHandler(TextSelection selection, RenderEditable renderObject, bool longPress); +/// Signature for the callback that reports when the caret location changes. +/// +/// Used by [RenderEditable.onCaretChanged]. +typedef void CaretChangedHandler(Rect caretRect); + /// Represents a global screen coordinate of the point in a selection, and the /// text direction at that point. @immutable @@ -65,6 +70,7 @@ class RenderEditable extends RenderBox { TextSelection selection, @required ViewportOffset offset, this.onSelectionChanged, + this.onCaretChanged, }) : _textPainter = new TextPainter(text: text, textAlign: textAlign, textScaleFactor: textScaleFactor), _cursorColor = cursorColor, _showCursor = showCursor ?? new ValueNotifier(false), @@ -89,6 +95,11 @@ class RenderEditable extends RenderBox { double _textLayoutLastWidth; + /// Called during the paint phase when the caret location changes. + CaretChangedHandler onCaretChanged; + + Rect _lastCaretRect; + /// Marks the render object as needing to be laid out again and have its text /// metrics recomputed. /// @@ -422,7 +433,13 @@ class RenderEditable extends RenderBox { assert(_textLayoutLastWidth == constraints.maxWidth); final Offset caretOffset = _textPainter.getOffsetForCaret(_selection.extent, _caretPrototype); final Paint paint = new Paint()..color = _cursorColor; - canvas.drawRect(_caretPrototype.shift(caretOffset + effectiveOffset), paint); + final Rect caretRect = _caretPrototype.shift(caretOffset + effectiveOffset); + canvas.drawRect(caretRect, paint); + if (caretRect != _lastCaretRect) { + _lastCaretRect = caretRect; + if (onCaretChanged != null) + onCaretChanged(caretRect); + } } void _paintSelection(Canvas canvas, Offset effectiveOffset) { diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 9439a132d9..dddff7ef4b 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -445,6 +445,21 @@ class EditableTextState extends State implements TextInputClient { _scrollController.jumpTo(_getScrollOffsetForCaret(caretRect)); } + bool _textChangedSinceLastCaretUpdate = false; + + void _handleCaretChanged(Rect caretRect) { + // If the caret location has changed due to an update to the text or + // selection, then scroll the caret into view. + if (_textChangedSinceLastCaretUpdate) { + _textChangedSinceLastCaretUpdate = false; + _scrollController.animateTo( + _getScrollOffsetForCaret(caretRect), + curve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 50), + ); + } + } + void _formatAndSetValue(TextEditingValue value) { if (widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) { for (TextInputFormatter formatter in widget.inputFormatters) @@ -493,6 +508,7 @@ class EditableTextState extends State implements TextInputClient { _updateRemoteEditingValueIfNeeded(); _startOrStopCursorTimerIfNeeded(); _updateOrDisposeSelectionOverlayIfNeeded(); + _textChangedSinceLastCaretUpdate = true; // TODO(abarth): Teach RenderEditable about ValueNotifier // to avoid this setState(). setState(() { /* We use widget.controller.value in build(). */ }); @@ -524,6 +540,7 @@ class EditableTextState extends State implements TextInputClient { obscureText: widget.obscureText, offset: offset, onSelectionChanged: _handleSelectionChanged, + onCaretChanged: _handleCaretChanged, ); }, ); @@ -544,6 +561,7 @@ class _Editable extends LeafRenderObjectWidget { this.obscureText, this.offset, this.onSelectionChanged, + this.onCaretChanged, }) : super(key: key); final TextEditingValue value; @@ -557,6 +575,7 @@ class _Editable extends LeafRenderObjectWidget { final bool obscureText; final ViewportOffset offset; final SelectionChangedHandler onSelectionChanged; + final CaretChangedHandler onCaretChanged; @override RenderEditable createRenderObject(BuildContext context) { @@ -571,6 +590,7 @@ class _Editable extends LeafRenderObjectWidget { selection: value.selection, offset: offset, onSelectionChanged: onSelectionChanged, + onCaretChanged: onCaretChanged, ); } @@ -586,7 +606,8 @@ class _Editable extends LeafRenderObjectWidget { ..textAlign = textAlign ..selection = value.selection ..offset = offset - ..onSelectionChanged = onSelectionChanged; + ..onSelectionChanged = onSelectionChanged + ..onCaretChanged = onCaretChanged; } TextSpan get _styledTextSpan { diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 803c61ba91..d727d3dc17 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -112,6 +112,8 @@ void main() { expect(textFieldValue, equals(testValue)); await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); }); } @@ -217,6 +219,8 @@ void main() { await tester.enterText(find.byType(TextField), testValue); await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); // Tap to reposition the caret. final int tapIndex = testValue.indexOf('e'); @@ -259,6 +263,8 @@ void main() { expect(controller.value.text, testValue); await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); expect(controller.selection.isCollapsed, true); @@ -293,6 +299,8 @@ void main() { await tester.enterText(find.byType(TextField), testValue); await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); // Long press the 'e' to select 'def'. final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); @@ -354,6 +362,8 @@ void main() { final String testValue = 'abc def ghi'; await tester.enterText(find.byType(TextField), testValue); await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); // Tap the selection handle to bring up the "paste / select all" menu. await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); @@ -372,6 +382,8 @@ void main() { // COPY should reset the selection. await tester.tap(find.text('COPY')); await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); expect(controller.selection.isCollapsed, true); // Tap again to bring back the menu. @@ -406,6 +418,8 @@ void main() { final String testValue = 'abc def ghi'; await tester.enterText(find.byType(TextField), testValue); await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); // Tap the selection handle to bring up the "paste / select all" menu. await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); @@ -502,6 +516,8 @@ void main() { await tester.enterText(find.byType(TextField), testValue); await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); // Check that the text spans multiple lines. final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First')); @@ -1066,6 +1082,8 @@ void main() { await tester.enterText(find.byType(TextField), 'a1b\n2c3'); expect(textController.text, '123'); await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); await tester.tapAt(textOffsetToPosition(tester, '123'.indexOf('2'))); await tester.pumpWidget(builder()); @@ -1082,4 +1100,45 @@ void main() { expect(textController.text, '145623'); } ); + + testWidgets( + 'Text field scrolls the caret into view', + (WidgetTester tester) async { + final TextEditingController controller = new TextEditingController(); + + Widget builder() { + return overlay(new Center( + child: new Material( + child: new Container( + width: 100.0, + child: new TextField( + controller: controller, + ), + ), + ), + )); + } + + await tester.pumpWidget(builder()); + + final String longText = 'a' * 20; + await tester.enterText(find.byType(TextField), longText); + await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); + + ScrollableState scrollableState = tester.firstState(find.byType(Scrollable)); + expect(scrollableState.position.pixels, equals(0.0)); + + // Move the caret to the end of the text and check that the text field + // scrolls to make the caret visible. + controller.selection = new TextSelection.collapsed(offset: longText.length); + await tester.pumpWidget(builder()); + // skip past scrolling animation + await tester.pump(const Duration(milliseconds: 200)); + + scrollableState = tester.firstState(find.byType(Scrollable)); + expect(scrollableState.position.pixels, isNot(equals(0.0))); + } + ); }