Scroll text fields when the caret moves outside the viewport (#10323)
Fixes https://github.com/flutter/flutter/issues/9923
This commit is contained in:
parent
0809a4fcdc
commit
2ab60e93ef
@ -25,6 +25,11 @@ final String _kZeroWidthSpace = new String.fromCharCode(0x200B);
|
|||||||
/// Used by [RenderEditable.onSelectionChanged].
|
/// Used by [RenderEditable.onSelectionChanged].
|
||||||
typedef void SelectionChangedHandler(TextSelection selection, RenderEditable renderObject, bool longPress);
|
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
|
/// Represents a global screen coordinate of the point in a selection, and the
|
||||||
/// text direction at that point.
|
/// text direction at that point.
|
||||||
@immutable
|
@immutable
|
||||||
@ -65,6 +70,7 @@ class RenderEditable extends RenderBox {
|
|||||||
TextSelection selection,
|
TextSelection selection,
|
||||||
@required ViewportOffset offset,
|
@required ViewportOffset offset,
|
||||||
this.onSelectionChanged,
|
this.onSelectionChanged,
|
||||||
|
this.onCaretChanged,
|
||||||
}) : _textPainter = new TextPainter(text: text, textAlign: textAlign, textScaleFactor: textScaleFactor),
|
}) : _textPainter = new TextPainter(text: text, textAlign: textAlign, textScaleFactor: textScaleFactor),
|
||||||
_cursorColor = cursorColor,
|
_cursorColor = cursorColor,
|
||||||
_showCursor = showCursor ?? new ValueNotifier<bool>(false),
|
_showCursor = showCursor ?? new ValueNotifier<bool>(false),
|
||||||
@ -89,6 +95,11 @@ class RenderEditable extends RenderBox {
|
|||||||
|
|
||||||
double _textLayoutLastWidth;
|
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
|
/// Marks the render object as needing to be laid out again and have its text
|
||||||
/// metrics recomputed.
|
/// metrics recomputed.
|
||||||
///
|
///
|
||||||
@ -422,7 +433,13 @@ class RenderEditable extends RenderBox {
|
|||||||
assert(_textLayoutLastWidth == constraints.maxWidth);
|
assert(_textLayoutLastWidth == constraints.maxWidth);
|
||||||
final Offset caretOffset = _textPainter.getOffsetForCaret(_selection.extent, _caretPrototype);
|
final Offset caretOffset = _textPainter.getOffsetForCaret(_selection.extent, _caretPrototype);
|
||||||
final Paint paint = new Paint()..color = _cursorColor;
|
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) {
|
void _paintSelection(Canvas canvas, Offset effectiveOffset) {
|
||||||
|
@ -445,6 +445,21 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
|
|||||||
_scrollController.jumpTo(_getScrollOffsetForCaret(caretRect));
|
_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) {
|
void _formatAndSetValue(TextEditingValue value) {
|
||||||
if (widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) {
|
if (widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) {
|
||||||
for (TextInputFormatter formatter in widget.inputFormatters)
|
for (TextInputFormatter formatter in widget.inputFormatters)
|
||||||
@ -493,6 +508,7 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
|
|||||||
_updateRemoteEditingValueIfNeeded();
|
_updateRemoteEditingValueIfNeeded();
|
||||||
_startOrStopCursorTimerIfNeeded();
|
_startOrStopCursorTimerIfNeeded();
|
||||||
_updateOrDisposeSelectionOverlayIfNeeded();
|
_updateOrDisposeSelectionOverlayIfNeeded();
|
||||||
|
_textChangedSinceLastCaretUpdate = true;
|
||||||
// TODO(abarth): Teach RenderEditable about ValueNotifier<TextEditingValue>
|
// TODO(abarth): Teach RenderEditable about ValueNotifier<TextEditingValue>
|
||||||
// to avoid this setState().
|
// to avoid this setState().
|
||||||
setState(() { /* We use widget.controller.value in build(). */ });
|
setState(() { /* We use widget.controller.value in build(). */ });
|
||||||
@ -524,6 +540,7 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
|
|||||||
obscureText: widget.obscureText,
|
obscureText: widget.obscureText,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
onSelectionChanged: _handleSelectionChanged,
|
onSelectionChanged: _handleSelectionChanged,
|
||||||
|
onCaretChanged: _handleCaretChanged,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -544,6 +561,7 @@ class _Editable extends LeafRenderObjectWidget {
|
|||||||
this.obscureText,
|
this.obscureText,
|
||||||
this.offset,
|
this.offset,
|
||||||
this.onSelectionChanged,
|
this.onSelectionChanged,
|
||||||
|
this.onCaretChanged,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final TextEditingValue value;
|
final TextEditingValue value;
|
||||||
@ -557,6 +575,7 @@ class _Editable extends LeafRenderObjectWidget {
|
|||||||
final bool obscureText;
|
final bool obscureText;
|
||||||
final ViewportOffset offset;
|
final ViewportOffset offset;
|
||||||
final SelectionChangedHandler onSelectionChanged;
|
final SelectionChangedHandler onSelectionChanged;
|
||||||
|
final CaretChangedHandler onCaretChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
RenderEditable createRenderObject(BuildContext context) {
|
RenderEditable createRenderObject(BuildContext context) {
|
||||||
@ -571,6 +590,7 @@ class _Editable extends LeafRenderObjectWidget {
|
|||||||
selection: value.selection,
|
selection: value.selection,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
onSelectionChanged: onSelectionChanged,
|
onSelectionChanged: onSelectionChanged,
|
||||||
|
onCaretChanged: onCaretChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -586,7 +606,8 @@ class _Editable extends LeafRenderObjectWidget {
|
|||||||
..textAlign = textAlign
|
..textAlign = textAlign
|
||||||
..selection = value.selection
|
..selection = value.selection
|
||||||
..offset = offset
|
..offset = offset
|
||||||
..onSelectionChanged = onSelectionChanged;
|
..onSelectionChanged = onSelectionChanged
|
||||||
|
..onCaretChanged = onCaretChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
TextSpan get _styledTextSpan {
|
TextSpan get _styledTextSpan {
|
||||||
|
@ -112,6 +112,8 @@ void main() {
|
|||||||
expect(textFieldValue, equals(testValue));
|
expect(textFieldValue, equals(testValue));
|
||||||
|
|
||||||
await tester.pumpWidget(builder());
|
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.enterText(find.byType(TextField), testValue);
|
||||||
|
|
||||||
await tester.pumpWidget(builder());
|
await tester.pumpWidget(builder());
|
||||||
|
// skip past scrolling animation
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
// Tap to reposition the caret.
|
// Tap to reposition the caret.
|
||||||
final int tapIndex = testValue.indexOf('e');
|
final int tapIndex = testValue.indexOf('e');
|
||||||
@ -259,6 +263,8 @@ void main() {
|
|||||||
expect(controller.value.text, testValue);
|
expect(controller.value.text, testValue);
|
||||||
|
|
||||||
await tester.pumpWidget(builder());
|
await tester.pumpWidget(builder());
|
||||||
|
// skip past scrolling animation
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
expect(controller.selection.isCollapsed, true);
|
expect(controller.selection.isCollapsed, true);
|
||||||
|
|
||||||
@ -293,6 +299,8 @@ void main() {
|
|||||||
await tester.enterText(find.byType(TextField), testValue);
|
await tester.enterText(find.byType(TextField), testValue);
|
||||||
|
|
||||||
await tester.pumpWidget(builder());
|
await tester.pumpWidget(builder());
|
||||||
|
// skip past scrolling animation
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
// Long press the 'e' to select 'def'.
|
// Long press the 'e' to select 'def'.
|
||||||
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
|
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
|
||||||
@ -354,6 +362,8 @@ void main() {
|
|||||||
final String testValue = 'abc def ghi';
|
final String testValue = 'abc def ghi';
|
||||||
await tester.enterText(find.byType(TextField), testValue);
|
await tester.enterText(find.byType(TextField), testValue);
|
||||||
await tester.pumpWidget(builder());
|
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.
|
// Tap the selection handle to bring up the "paste / select all" menu.
|
||||||
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
|
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
|
||||||
@ -372,6 +382,8 @@ void main() {
|
|||||||
// COPY should reset the selection.
|
// COPY should reset the selection.
|
||||||
await tester.tap(find.text('COPY'));
|
await tester.tap(find.text('COPY'));
|
||||||
await tester.pumpWidget(builder());
|
await tester.pumpWidget(builder());
|
||||||
|
// skip past scrolling animation
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
expect(controller.selection.isCollapsed, true);
|
expect(controller.selection.isCollapsed, true);
|
||||||
|
|
||||||
// Tap again to bring back the menu.
|
// Tap again to bring back the menu.
|
||||||
@ -406,6 +418,8 @@ void main() {
|
|||||||
final String testValue = 'abc def ghi';
|
final String testValue = 'abc def ghi';
|
||||||
await tester.enterText(find.byType(TextField), testValue);
|
await tester.enterText(find.byType(TextField), testValue);
|
||||||
await tester.pumpWidget(builder());
|
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.
|
// Tap the selection handle to bring up the "paste / select all" menu.
|
||||||
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
|
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
|
||||||
@ -502,6 +516,8 @@ void main() {
|
|||||||
await tester.enterText(find.byType(TextField), testValue);
|
await tester.enterText(find.byType(TextField), testValue);
|
||||||
|
|
||||||
await tester.pumpWidget(builder());
|
await tester.pumpWidget(builder());
|
||||||
|
// skip past scrolling animation
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
// Check that the text spans multiple lines.
|
// Check that the text spans multiple lines.
|
||||||
final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First'));
|
final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First'));
|
||||||
@ -1066,6 +1082,8 @@ void main() {
|
|||||||
await tester.enterText(find.byType(TextField), 'a1b\n2c3');
|
await tester.enterText(find.byType(TextField), 'a1b\n2c3');
|
||||||
expect(textController.text, '123');
|
expect(textController.text, '123');
|
||||||
await tester.pumpWidget(builder());
|
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.tapAt(textOffsetToPosition(tester, '123'.indexOf('2')));
|
||||||
await tester.pumpWidget(builder());
|
await tester.pumpWidget(builder());
|
||||||
@ -1082,4 +1100,45 @@ void main() {
|
|||||||
expect(textController.text, '145623');
|
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)));
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user