diff --git a/examples/flutter_gallery/lib/demo/text_field_demo.dart b/examples/flutter_gallery/lib/demo/text_field_demo.dart index 0d98ea3f0fe548..241ef0031dfe4c 100644 --- a/examples/flutter_gallery/lib/demo/text_field_demo.dart +++ b/examples/flutter_gallery/lib/demo/text_field_demo.dart @@ -97,7 +97,7 @@ class TextFieldDemoState extends State { new Input( hintText: 'Tell us about yourself (optional)', labelText: 'Life story', - multiline: true, + maxLines: 3, formField: new FormField() ), new Row( diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart index 8c0c7f6abf767b..ca1c2c7026e22e 100644 --- a/packages/flutter/lib/src/material/input.dart +++ b/packages/flutter/lib/src/material/input.dart @@ -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 formField; @@ -212,7 +213,7 @@ class _InputState extends State { focusKey: focusKey, style: textStyle, hideText: config.hideText, - multiline: config.multiline, + maxLines: config.maxLines, cursorColor: themeData.textSelectionColor, selectionColor: themeData.textSelectionColor, selectionControls: materialTextSelectionControls, diff --git a/packages/flutter/lib/src/rendering/editable_line.dart b/packages/flutter/lib/src/rendering/editable_line.dart index 35671f504ef283..cd4a13cf722952 100644 --- a/packages/flutter/lib/src/rendering/editable_line.dart +++ b/packages/flutter/lib/src/rendering/editable_line.dart @@ -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 [new TextSelectionPoint(localToGlobal(start), null)]; } else { List 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; diff --git a/packages/flutter/lib/src/widgets/editable.dart b/packages/flutter/lib/src/widgets/editable.dart index 57955bc1bd8b00..4bdba026799260 100644 --- a/packages/flutter/lib/src/widgets/editable.dart +++ b/packages/flutter/lib/src/widgets/editable.dart @@ -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 { 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 { } } - 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 { } } - 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 diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 46529c7abab7bb..98cef895c58d21 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -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 extends State 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 extends State 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 extends State 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; }); } diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 485df0a9bca97d..0ec3db8a449712 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -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 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 diff --git a/packages/flutter/test/widget/input_test.dart b/packages/flutter/test/widget/input_test.dart index 971ccf10f882e1..09dd0820158218 100644 --- a/packages/flutter/test/widget/input_test.dart +++ b/packages/flutter/test/widget/input_test.dart @@ -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 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 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: [ + 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 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); + }); + }