Commit 2ab60e93 authored by Jason Simmons's avatar Jason Simmons Committed by GitHub

Scroll text fields when the caret moves outside the viewport (#10323)

Fixes https://github.com/flutter/flutter/issues/9923
parent 0809a4fc
......@@ -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<bool>(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) {
......
......@@ -445,6 +445,21 @@ class EditableTextState extends State<EditableText> 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<EditableText> implements TextInputClient {
_updateRemoteEditingValueIfNeeded();
_startOrStopCursorTimerIfNeeded();
_updateOrDisposeSelectionOverlayIfNeeded();
_textChangedSinceLastCaretUpdate = true;
// TODO(abarth): Teach RenderEditable about ValueNotifier<TextEditingValue>
// to avoid this setState().
setState(() { /* We use widget.controller.value in build(). */ });
......@@ -524,6 +540,7 @@ class EditableTextState extends State<EditableText> 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 {
......
......@@ -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)));
}
);
}
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