Commit c13a6e27 authored by Matt Perry's avatar Matt Perry Committed by Ian Hickson

Add a maxLines parameter for multiline Input. (#6310)

* Add a maxLines parameter for multiline Input.

If maxLines is 1, it's a single line Input that scrolls horizontally.
Otherwise, overflowed text wraps and scrolls vertically, taking up at
most `maxLines`.

Also fixed scrolling behavior so that the Input scrolls ensuring the
cursor is always visible.

Fixes https://github.com/flutter/flutter/issues/6271

* oops

* comments

* import

* test and RO.update fix

* constant

* fix.caretRect
parent 71e05ff8
......@@ -97,7 +97,7 @@ class TextFieldDemoState extends State<TextFieldDemo> {
new Input(
hintText: 'Tell us about yourself (optional)',
labelText: 'Life story',
multiline: true,
maxLines: 3,
formField: new FormField<String>()
),
new Row(
......
......@@ -43,7 +43,7 @@ class Input extends StatefulWidget {
this.hideText: false,
this.isDense: false,
this.autofocus: false,
this.multiline: false,
this.maxLines: 1,
this.formField,
this.onChanged,
this.onSubmitted
......@@ -85,9 +85,10 @@ class Input extends StatefulWidget {
/// Whether this input field should focus itself is nothing else is already focused.
final bool autofocus;
/// True if the text should wrap and span multiple lines, false if it should
/// stay on a single line and scroll when overflowed.
final bool multiline;
/// The maximum number of lines for the text to span, wrapping if necessary.
/// If this is 1 (the default), the text will not wrap, but will scroll
/// horizontally instead.
final int maxLines;
/// Form-specific data, required if this Input is part of a Form.
final FormField<String> formField;
......@@ -212,7 +213,7 @@ class _InputState extends State<Input> {
focusKey: focusKey,
style: textStyle,
hideText: config.hideText,
multiline: config.multiline,
maxLines: config.maxLines,
cursorColor: themeData.textSelectionColor,
selectionColor: themeData.textSelectionColor,
selectionControls: materialTextSelectionControls,
......
......@@ -3,7 +3,6 @@
// found in the LICENSE file.
import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle, TextBox;
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
......@@ -37,6 +36,8 @@ class TextSelectionPoint {
final TextDirection direction;
}
typedef Offset RenderEditableLinePaintOffsetNeededCallback(ViewportDimensions dimensions, Rect caretRect);
/// A single line of editable text.
class RenderEditableLine extends RenderBox {
/// Creates a render object for a single line of editable text.
......@@ -44,7 +45,7 @@ class RenderEditableLine extends RenderBox {
TextSpan text,
Color cursorColor,
bool showCursor: false,
bool multiline: false,
int maxLines: 1,
Color selectionColor,
double textScaleFactor: 1.0,
TextSelection selection,
......@@ -54,7 +55,7 @@ class RenderEditableLine extends RenderBox {
}) : _textPainter = new TextPainter(text: text, textScaleFactor: textScaleFactor),
_cursorColor = cursorColor,
_showCursor = showCursor,
_multiline = multiline,
_maxLines = maxLines,
_selection = selection,
_paintOffset = paintOffset {
assert(!showCursor || cursorColor != null);
......@@ -70,7 +71,7 @@ class RenderEditableLine extends RenderBox {
SelectionChangedHandler onSelectionChanged;
/// Called when the inner or outer dimensions of this render object change.
ViewportDimensionsChangeCallback onPaintOffsetUpdateNeeded;
RenderEditableLinePaintOffsetNeededCallback onPaintOffsetUpdateNeeded;
/// The text to display
TextSpan get text => _textPainter.text;
......@@ -105,6 +106,16 @@ class RenderEditableLine extends RenderBox {
markNeedsPaint();
}
/// Whether to paint the cursor.
int get maxLines => _maxLines;
int _maxLines;
set maxLines(int value) {
if (_maxLines == value)
return;
_maxLines = value;
markNeedsLayout();
}
/// The color to use when painting the selection.
Color get selectionColor => _selectionColor;
Color _selectionColor;
......@@ -173,7 +184,7 @@ class RenderEditableLine extends RenderBox {
if (selection.isCollapsed) {
// TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary.
Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype);
Point start = new Point(0.0, constraints.constrainHeight(_preferredHeight)) + caretOffset + offset;
Point start = new Point(0.0, constraints.constrainHeight(_preferredLineHeight)) + caretOffset + offset;
return <TextSelectionPoint>[new TextSelectionPoint(localToGlobal(start), null)];
} else {
List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(selection);
......@@ -192,10 +203,19 @@ class RenderEditableLine extends RenderBox {
return _textPainter.getPositionForOffset(globalToLocal(globalPosition).toOffset());
}
/// Returns the Rect in local coordinates for the caret at the given text
/// position.
Rect getLocalRectForCaret(TextPosition caretPosition) {
double lineHeight = constraints.constrainHeight(_preferredLineHeight);
Offset caretOffset = _textPainter.getOffsetForCaret(caretPosition, _caretPrototype);
// This rect is the same as _caretPrototype but without the vertical padding.
return new Rect.fromLTWH(0.0, 0.0, _kCaretWidth, lineHeight).shift(caretOffset + _paintOffset);
}
Size _contentSize;
ui.Paragraph _layoutTemplate;
double get _preferredHeight {
double get _preferredLineHeight {
if (_layoutTemplate == null) {
ui.ParagraphBuilder builder = new ui.ParagraphBuilder()
..pushStyle(text.style.getTextStyle(textScaleFactor: textScaleFactor))
......@@ -208,21 +228,20 @@ class RenderEditableLine extends RenderBox {
return _layoutTemplate.height;
}
bool _multiline;
double get _maxContentWidth {
return _multiline ?
return _maxLines > 1 ?
constraints.maxWidth - (_kCaretGap + _kCaretWidth) :
double.INFINITY;
}
@override
double computeMinIntrinsicHeight(double width) {
return _preferredHeight;
return _preferredLineHeight;
}
@override
double computeMaxIntrinsicHeight(double width) {
return _preferredHeight;
return _preferredLineHeight;
}
@override
......@@ -284,17 +303,26 @@ class RenderEditableLine extends RenderBox {
@override
void performLayout() {
Size oldSize = hasSize ? size : null;
double lineHeight = constraints.constrainHeight(_preferredHeight);
double lineHeight = constraints.constrainHeight(_preferredLineHeight);
_caretPrototype = new Rect.fromLTWH(0.0, _kCaretHeightOffset, _kCaretWidth, lineHeight - 2.0 * _kCaretHeightOffset);
_selectionRects = null;
_textPainter.layout(maxWidth: _maxContentWidth);
size = new Size(constraints.maxWidth, constraints.constrainHeight(math.max(lineHeight, _textPainter.height)));
size = new Size(constraints.maxWidth, constraints.constrainHeight(
_textPainter.height.clamp(lineHeight, lineHeight * _maxLines)
));
Size contentSize = new Size(_textPainter.width + _kCaretGap + _kCaretWidth, _textPainter.height);
if (onPaintOffsetUpdateNeeded != null && (size != oldSize || contentSize != _contentSize))
onPaintOffsetUpdateNeeded(new ViewportDimensions(containerSize: size, contentSize: contentSize));
assert(_selection != null);
Rect caretRect = getLocalRectForCaret(_selection.extent);
if (onPaintOffsetUpdateNeeded != null && (size != oldSize || contentSize != _contentSize || !_withinBounds(caretRect)))
onPaintOffsetUpdateNeeded(new ViewportDimensions(containerSize: size, contentSize: contentSize), caretRect);
_contentSize = contentSize;
}
bool _withinBounds(Rect caretRect) {
Rect bounds = new Rect.fromLTWH(0.0, 0.0, size.width, size.height);
return (bounds.contains(caretRect.topLeft) && bounds.contains(caretRect.bottomRight));
}
void _paintCaret(Canvas canvas, Offset effectiveOffset) {
Offset caretOffset = _textPainter.getOffsetForCaret(_selection.extent, _caretPrototype);
Paint paint = new Paint()..color = _cursorColor;
......
......@@ -4,7 +4,7 @@
import 'dart:async';
import 'package:flutter/rendering.dart' show RenderEditableLine, SelectionChangedHandler;
import 'package:flutter/rendering.dart' show RenderEditableLine, SelectionChangedHandler, RenderEditableLinePaintOffsetNeededCallback;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart';
import 'package:flutter_services/editing.dart' as mojom;
......@@ -170,17 +170,17 @@ class RawInputLine extends Scrollable {
this.style,
this.cursorColor,
this.textScaleFactor,
this.multiline,
int maxLines: 1,
this.selectionColor,
this.selectionControls,
@required this.platform,
this.keyboardType,
this.onChanged,
this.onSubmitted
}) : super(
}) : maxLines = maxLines, super(
key: key,
initialScrollOffset: 0.0,
scrollDirection: Axis.horizontal
scrollDirection: maxLines > 1 ? Axis.vertical : Axis.horizontal
) {
assert(value != null);
}
......@@ -208,9 +208,10 @@ class RawInputLine extends Scrollable {
/// The color to use when painting the cursor.
final Color cursorColor;
/// True if the text should wrap and span multiple lines, false if it should
/// stay on a single line and scroll when overflowed.
final bool multiline;
/// The maximum number of lines for the text to span, wrapping if necessary.
/// If this is 1 (the default), the text will not wrap, but will scroll
/// horizontally instead.
final int maxLines;
/// The color to use when painting the selection.
final Color selectionColor;
......@@ -273,30 +274,45 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
bool get _isAttachedToKeyboard => _keyboardHandle != null && _keyboardHandle.attached;
double _contentWidth = 0.0;
double _containerWidth = 0.0;
bool get _isMultiline => config.maxLines > 1;
Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions) {
double _contentExtent = 0.0;
double _containerExtent = 0.0;
Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions, Rect caretRect) {
// We make various state changes here but don't have to do so in a
// setState() callback because we are called during layout and all
// we're updating is the new offset, which we are providing to the
// render object via our return value.
_containerWidth = dimensions.containerSize.width;
_contentWidth = dimensions.contentSize.width;
_contentExtent = _isMultiline ?
dimensions.contentSize.height :
dimensions.contentSize.width;
_containerExtent = _isMultiline ?
dimensions.containerSize.height :
dimensions.containerSize.width;
didUpdateScrollBehavior(scrollBehavior.updateExtents(
contentExtent: _contentWidth,
containerExtent: _containerWidth,
// Set the scroll offset to match the content width so that the
// cursor (which is always at the end of the text) will be
// visible.
contentExtent: _contentExtent,
containerExtent: _containerExtent,
// TODO(ianh): We should really only do this when text is added,
// not generally any time the size changes.
scrollOffset: pixelOffsetToScrollOffset(-_contentWidth)
scrollOffset: _getScrollOffsetForCaret(caretRect, _containerExtent)
));
updateGestureDetector();
return scrollOffsetToPixelDelta(scrollOffset);
}
// Calculate the new scroll offset so the cursor remains visible.
double _getScrollOffsetForCaret(Rect caretRect, double containerExtent) {
double caretStart = _isMultiline ? caretRect.top : caretRect.left;
double caretEnd = _isMultiline ? caretRect.bottom : caretRect.right;
double newScrollOffset = scrollOffset;
if (caretStart < 0.0) // cursor before start of bounds
newScrollOffset += pixelOffsetToScrollOffset(-caretStart);
else if (caretEnd >= containerExtent) // cursor after end of bounds
newScrollOffset += pixelOffsetToScrollOffset(-(caretEnd - containerExtent));
return newScrollOffset;
}
void _attachOrDetachKeyboard(bool focused) {
if (focused && !_isAttachedToKeyboard) {
_keyboardHandle = keyboard.attach(_keyboardClient.createStub(),
......@@ -373,10 +389,18 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
}
}
void _handleSelectionOverlayChanged(InputValue newInput) {
void _handleSelectionOverlayChanged(InputValue newInput, Rect caretRect) {
assert(!newInput.composing.isValid); // composing range must be empty while selecting
if (config.onChanged != null)
config.onChanged(newInput);
didUpdateScrollBehavior(scrollBehavior.updateExtents(
// TODO(mpcomplete): should just be able to pass
// scrollBehavior.containerExtent here (and remove the member var), but
// scrollBehavior gets re-created too often, and is sometimes
// uninitialized here. Investigate if this is a bug.
scrollOffset: _getScrollOffsetForCaret(caretRect, _containerExtent)
));
}
/// Whether the blinking cursor is actually visible at this precise moment
......@@ -438,18 +462,20 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
}
}
return new _EditableLineWidget(
value: _keyboardClient.inputValue,
style: config.style,
cursorColor: config.cursorColor,
showCursor: _showCursor,
multiline: config.multiline,
selectionColor: config.selectionColor,
textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor,
hideText: config.hideText,
onSelectionChanged: _handleSelectionChanged,
paintOffset: scrollOffsetToPixelDelta(scrollOffset),
onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded
return new ClipRect(
child: new _EditableLineWidget(
value: _keyboardClient.inputValue,
style: config.style,
cursorColor: config.cursorColor,
showCursor: _showCursor,
maxLines: config.maxLines,
selectionColor: config.selectionColor,
textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor,
hideText: config.hideText,
onSelectionChanged: _handleSelectionChanged,
paintOffset: scrollOffsetToPixelDelta(scrollOffset),
onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded
)
);
}
}
......@@ -461,7 +487,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
this.style,
this.cursorColor,
this.showCursor,
this.multiline,
this.maxLines,
this.selectionColor,
this.textScaleFactor,
this.hideText,
......@@ -474,13 +500,13 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
final TextStyle style;
final Color cursorColor;
final bool showCursor;
final bool multiline;
final int maxLines;
final Color selectionColor;
final double textScaleFactor;
final bool hideText;
final SelectionChangedHandler onSelectionChanged;
final Offset paintOffset;
final ViewportDimensionsChangeCallback onPaintOffsetUpdateNeeded;
final RenderEditableLinePaintOffsetNeededCallback onPaintOffsetUpdateNeeded;
@override
RenderEditableLine createRenderObject(BuildContext context) {
......@@ -488,7 +514,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
text: _styledTextSpan,
cursorColor: cursorColor,
showCursor: showCursor,
multiline: multiline,
maxLines: maxLines,
selectionColor: selectionColor,
textScaleFactor: textScaleFactor,
selection: value.selection,
......@@ -504,6 +530,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
..text = _styledTextSpan
..cursorColor = cursorColor
..showCursor = showCursor
..maxLines = maxLines
..selectionColor = selectionColor
..textScaleFactor = textScaleFactor
..selection = value.selection
......
......@@ -8,6 +8,7 @@ import 'dart:ui' as ui show window;
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/scheduler.dart';
import 'package:meta/meta.dart';
import 'basic.dart';
......@@ -436,7 +437,7 @@ class ScrollableState<T extends Scrollable> extends State<T> with SingleTickerPr
final ClampOverscrolls clampOverscrolls = ClampOverscrolls.of(context);
final double clampedScrollOffset = clampOverscrolls?.clampScrollOffset(this, newScrollOffset) ?? newScrollOffset;
setState(() {
_setStateMaybeDuringBuild(() {
_virtualScrollOffset = newScrollOffset;
_scrollUnderway = _scrollOffset != clampedScrollOffset;
_scrollOffset = clampedScrollOffset;
......@@ -705,6 +706,18 @@ class ScrollableState<T extends Scrollable> extends State<T> with SingleTickerPr
});
}
// Used for state changes that sometimes occur during a build phase. If so,
// we skip calling setState, as the changes will apply to the next build.
// TODO(ianh): This is ugly and hopefully temporary. Ideally this won't be
// needed after Scrollable is rewritten.
void _setStateMaybeDuringBuild(VoidCallback fn) {
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
fn();
} else {
setState(fn);
}
}
void _endScroll({ DragEndDetails details }) {
_numberOfInProgressScrolls -= 1;
if (_numberOfInProgressScrolls == 0) {
......@@ -713,7 +726,7 @@ class ScrollableState<T extends Scrollable> extends State<T> with SingleTickerPr
// If the scroll hasn't already stopped because we've hit a clamped
// edge or the controller stopped animating, then rebuild the Scrollable
// with the IgnorePointer widget turned off.
setState(() {
_setStateMaybeDuringBuild(() {
_scrollUnderway = false;
});
}
......
......@@ -43,6 +43,8 @@ enum TextSelectionHandleType {
/// [start] handle always moves the [start]/[baseOffset] of the selection.
enum _TextSelectionHandlePosition { start, end }
typedef void TextSelectionOverlayChanged(InputValue value, Rect caretRect);
/// An interface for manipulating the selection, to be used by the implementor
/// of the toolbar widget.
abstract class TextSelectionDelegate {
......@@ -113,7 +115,7 @@ class TextSelectionOverlay implements TextSelectionDelegate {
///
/// For example, if the use drags one of the selection handles, this function
/// will be called with a new input value with an updated selection.
final ValueChanged<InputValue> onSelectionOverlayChanged;
final TextSelectionOverlayChanged onSelectionOverlayChanged;
/// Builds text selection handles and toolbar.
final TextSelectionControls selectionControls;
......@@ -212,7 +214,7 @@ class TextSelectionOverlay implements TextSelectionDelegate {
return new FadeTransition(
opacity: _handleOpacity,
child: new _TextSelectionHandleOverlay(
onSelectionHandleChanged: _handleSelectionHandleChanged,
onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); },
onSelectionHandleTapped: _handleSelectionHandleTapped,
renderObject: renderObject,
selection: _selection,
......@@ -241,8 +243,19 @@ class TextSelectionOverlay implements TextSelectionDelegate {
);
}
void _handleSelectionHandleChanged(TextSelection newSelection) {
inputValue = _input.copyWith(selection: newSelection, composing: TextRange.empty);
void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) {
Rect caretRect;
switch (position) {
case _TextSelectionHandlePosition.start:
caretRect = renderObject.getLocalRectForCaret(newSelection.base);
break;
case _TextSelectionHandlePosition.end:
caretRect = renderObject.getLocalRectForCaret(newSelection.extent);
break;
}
update(_input.copyWith(selection: newSelection, composing: TextRange.empty));
if (onSelectionOverlayChanged != null)
onSelectionOverlayChanged(_input, caretRect);
}
void _handleSelectionHandleTapped() {
......@@ -262,8 +275,10 @@ class TextSelectionOverlay implements TextSelectionDelegate {
@override
set inputValue(InputValue value) {
update(value);
if (onSelectionOverlayChanged != null)
onSelectionOverlayChanged(value);
if (onSelectionOverlayChanged != null) {
Rect caretRect = renderObject.getLocalRectForCaret(value.selection.extent);
onSelectionOverlayChanged(value, caretRect);
}
}
@override
......
......@@ -54,6 +54,14 @@ void main() {
MockClipboard mockClipboard = new MockClipboard();
serviceMocker.registerMockService(mockClipboard);
const String kThreeLines =
'First line of text is here abcdef ghijkl mnopqrst. ' +
'Second line of text goes until abcdef ghijkl mnopq. ' +
'Third line of stuff keeps going until abcdef ghijk. ';
const String kFourLines =
kThreeLines +
'Fourth line won\'t display and ends at abcdef ghi. ';
void enterText(String testValue) {
// Simulate entry of text through the keyboard.
expect(mockKeyboard.client, isNotNull);
......@@ -63,6 +71,32 @@ void main() {
..composingExtent = testValue.length);
}
// Returns the first RenderEditableLine.
RenderEditableLine findRenderEditableLine(WidgetTester tester) {
RenderObject root = tester.renderObject(find.byType(RawInputLine));
expect(root, isNotNull);
RenderEditableLine renderLine;
void recursiveFinder(RenderObject child) {
if (child is RenderEditableLine) {
renderLine = child;
return;
}
child.visitChildren(recursiveFinder);
}
root.visitChildren(recursiveFinder);
expect(renderLine, isNotNull);
return renderLine;
}
Point textOffsetToPosition(WidgetTester tester, int offset) {
RenderEditableLine renderLine = findRenderEditableLine(tester);
List<TextSelectionPoint> endpoints = renderLine.getEndpointsForSelection(
new TextSelection.collapsed(offset: offset));
expect(endpoints.length, 1);
return endpoints[0].point + new Offset(0.0, -2.0);
}
testWidgets('Editable text has consistent size', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey();
InputValue inputValue = InputValue.empty;
......@@ -174,32 +208,6 @@ void main() {
await tester.pump();
});
// Returns the first RenderEditableLine.
RenderEditableLine findRenderEditableLine(WidgetTester tester) {
RenderObject root = tester.renderObject(find.byType(RawInputLine));
expect(root, isNotNull);
RenderEditableLine renderLine;
void recursiveFinder(RenderObject child) {
if (child is RenderEditableLine) {
renderLine = child;
return;
}
child.visitChildren(recursiveFinder);
}
root.visitChildren(recursiveFinder);
expect(renderLine, isNotNull);
return renderLine;
}
Point textOffsetToPosition(WidgetTester tester, int offset) {
RenderEditableLine renderLine = findRenderEditableLine(tester);
List<TextSelectionPoint> endpoints = renderLine.getEndpointsForSelection(
new TextSelection.collapsed(offset: offset));
expect(endpoints.length, 1);
return endpoints[0].point + new Offset(0.0, -2.0);
}
testWidgets('Can long press to select', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey();
InputValue inputValue = InputValue.empty;
......@@ -438,18 +446,18 @@ void main() {
// End the test here to ensure the animation is properly disposed of.
});
testWidgets('Multiline text will wrap', (WidgetTester tester) async {
testWidgets('Multiline text will wrap up to maxLines', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey();
InputValue inputValue = InputValue.empty;
Widget builder() {
Widget builder(int maxLines) {
return new Center(
child: new Material(
child: new Input(
value: inputValue,
key: inputKey,
style: const TextStyle(color: Colors.black, fontSize: 34.0),
multiline: true,
maxLines: maxLines,
hintText: 'Placeholder',
onChanged: (InputValue value) { inputValue = value; }
)
......@@ -457,22 +465,36 @@ void main() {
);
}
await tester.pumpWidget(builder());
await tester.pumpWidget(builder(3));
RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey));
RenderBox inputBox = findInputBox();
Size emptyInputSize = inputBox.size;
enterText('This is a long line of text that will wrap to multiple lines.');
await tester.pumpWidget(builder());
enterText('No wrapping here.');
await tester.pumpWidget(builder(3));
expect(findInputBox(), equals(inputBox));
expect(inputBox.size, equals(emptyInputSize));
enterText(kThreeLines);
await tester.pumpWidget(builder(3));
expect(findInputBox(), equals(inputBox));
expect(inputBox.size, greaterThan(emptyInputSize));
enterText('No wrapping here.');
await tester.pumpWidget(builder());
Size threeLineInputSize = inputBox.size;
// An extra line won't increase the size because we max at 3.
enterText(kFourLines);
await tester.pumpWidget(builder(3));
expect(findInputBox(), equals(inputBox));
expect(inputBox.size, equals(emptyInputSize));
expect(inputBox.size, threeLineInputSize);
// But now it will.
enterText(kFourLines);
await tester.pumpWidget(builder(4));
expect(findInputBox(), equals(inputBox));
expect(inputBox.size, greaterThan(threeLineInputSize));
});
testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async {
......@@ -490,7 +512,7 @@ void main() {
value: inputValue,
key: inputKey,
style: const TextStyle(color: Colors.black, fontSize: 34.0),
multiline: true,
maxLines: 3,
onChanged: (InputValue value) { inputValue = value; }
)
)
......@@ -503,8 +525,8 @@ void main() {
await tester.pumpWidget(builder());
String testValue = 'First line of text is here abcdef ghijkl mnopqrst. Second line of text goes until abcdef ghijkl mnopq. Third line of stuff.';
String cutValue = 'First line of stuff.';
String testValue = kThreeLines;
String cutValue = 'First line of stuff keeps going until abcdef ghijk. ';
enterText(testValue);
await tester.pumpWidget(builder());
......@@ -565,4 +587,95 @@ void main() {
expect(inputValue.text, cutValue);
});
testWidgets('Can scroll multiline input', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey();
InputValue inputValue = InputValue.empty;
Widget builder() {
return new Overlay(
initialEntries: <OverlayEntry>[
new OverlayEntry(
builder: (BuildContext context) {
return new Center(
child: new Material(
child: new Input(
value: inputValue,
key: inputKey,
style: const TextStyle(color: Colors.black, fontSize: 34.0),
maxLines: 2,
onChanged: (InputValue value) { inputValue = value; }
)
)
);
}
)
]
);
}
await tester.pumpWidget(builder());
enterText(kFourLines);
await tester.pumpWidget(builder());
RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey));
RenderBox inputBox = findInputBox();
// Check that the last line of text is not displayed.
Point firstPos = textOffsetToPosition(tester, kFourLines.indexOf('First'));
Point fourthPos = textOffsetToPosition(tester, kFourLines.indexOf('Fourth'));
expect(firstPos.x, fourthPos.x);
expect(firstPos.y, lessThan(fourthPos.y));
expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(firstPos)), isTrue);
expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(fourthPos)), isFalse);
TestGesture gesture = await tester.startGesture(firstPos, pointer: 7);
await tester.pump();
await gesture.moveBy(new Offset(0.0, -1000.0));
await tester.pump();
await gesture.up();
await tester.pump();
// Now the first line is scrolled up, and the fourth line is visible.
Point newFirstPos = textOffsetToPosition(tester, kFourLines.indexOf('First'));
Point newFourthPos = textOffsetToPosition(tester, kFourLines.indexOf('Fourth'));
expect(newFirstPos.y, lessThan(firstPos.y));
expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isFalse);
expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isTrue);
// Now try scrolling by dragging the selection handle.
// Long press the 'i' in 'Fourth line' to select the word.
await tester.pump(const Duration(seconds: 2));
Point untilPos = textOffsetToPosition(tester, kFourLines.indexOf('Fourth line')+8);
gesture = await tester.startGesture(untilPos, pointer: 7);
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pump();
RenderEditableLine renderLine = findRenderEditableLine(tester);
List<TextSelectionPoint> endpoints = renderLine.getEndpointsForSelection(
inputValue.selection);
expect(endpoints.length, 2);
// Drag the left handle to the first line, just after 'First'.
Point handlePos = endpoints[0].point + new Offset(-1.0, 1.0);
Point newHandlePos = textOffsetToPosition(tester, kFourLines.indexOf('First') + 5);
gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos + new Offset(0.0, -10.0));
await tester.pump();
await gesture.up();
await tester.pump();
// The text should have scrolled up with the handle to keep the active
// cursor visible, back to its original position.
newFirstPos = textOffsetToPosition(tester, kFourLines.indexOf('First'));
newFourthPos = textOffsetToPosition(tester, kFourLines.indexOf('Fourth'));
expect(newFirstPos.y, firstPos.y);
expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(newFirstPos)), isTrue);
expect(inputBox.hitTest(new HitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isFalse);
});
}
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