diff --git a/examples/flutter_gallery/lib/demo/text_field_demo.dart b/examples/flutter_gallery/lib/demo/text_field_demo.dart index 8f8dab468f..0d98ea3f0f 100644 --- a/examples/flutter_gallery/lib/demo/text_field_demo.dart +++ b/examples/flutter_gallery/lib/demo/text_field_demo.dart @@ -94,6 +94,12 @@ class TextFieldDemoState extends State { validator: _validatePhoneNumber ) ), + new Input( + hintText: 'Tell us about yourself (optional)', + labelText: 'Life story', + multiline: true, + formField: new FormField() + ), new Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart index c2b7850fe7..58d1861e1f 100644 --- a/packages/flutter/lib/src/material/input.dart +++ b/packages/flutter/lib/src/material/input.dart @@ -43,6 +43,7 @@ class Input extends StatefulWidget { this.hideText: false, this.isDense: false, this.autofocus: false, + this.multiline: false, this.formField, this.onChanged, this.onSubmitted @@ -84,6 +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; + /// Form-specific data, required if this Input is part of a Form. final FormField formField; @@ -205,10 +210,10 @@ class _InputState extends State { focusKey: focusKey, style: textStyle, hideText: config.hideText, + multiline: config.multiline, cursorColor: themeData.textSelectionColor, selectionColor: themeData.textSelectionColor, - selectionHandleBuilder: buildTextSelectionHandle, - selectionToolbarBuilder: buildTextSelectionToolbar, + selectionControls: materialTextSelectionControls, platform: Theme.of(context).platform, keyboardType: config.keyboardType, onChanged: onChanged, diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index eb3e820223..bb02b6cbca 100644 --- a/packages/flutter/lib/src/material/text_selection.dart +++ b/packages/flutter/lib/src/material/text_selection.dart @@ -143,49 +143,57 @@ class _TextSelectionHandlePainter extends CustomPainter { } } -/// Builder for material-style copy/paste text selection toolbar. -Widget buildTextSelectionToolbar( - BuildContext context, Point position, TextSelectionDelegate delegate) { - final Size screenSize = MediaQuery.of(context).size; - return new ConstrainedBox( - constraints: new BoxConstraints.loose(screenSize), - child: new CustomSingleChildLayout( - delegate: new _TextSelectionToolbarLayout(position), - child: new _TextSelectionToolbar(delegate) - ) - ); -} +class _MaterialTextSelectionControls extends TextSelectionControls { + @override + Size handleSize = const Size(_kHandleSize, _kHandleSize); -/// Builder for material-style text selection handles. -Widget buildTextSelectionHandle( - BuildContext context, TextSelectionHandleType type) { - Widget handle = new SizedBox( - width: _kHandleSize, - height: _kHandleSize, - child: new CustomPaint( - painter: new _TextSelectionHandlePainter( - color: Theme.of(context).textSelectionHandleColor + /// Builder for material-style copy/paste text selection toolbar. + @override + Widget buildToolbar( + BuildContext context, Point position, TextSelectionDelegate delegate) { + final Size screenSize = MediaQuery.of(context).size; + return new ConstrainedBox( + constraints: new BoxConstraints.loose(screenSize), + child: new CustomSingleChildLayout( + delegate: new _TextSelectionToolbarLayout(position), + child: new _TextSelectionToolbar(delegate) ) - ) - ); - - // [handle] is a circle, with a rectangle in the top left quadrant of that - // circle (an onion pointing to 10:30). We rotate [handle] to point - // straight up or up-right depending on the handle type. - switch (type) { - case TextSelectionHandleType.left: // points up-right - return new Transform( - transform: new Matrix4.rotationZ(math.PI / 2.0), - child: handle - ); - case TextSelectionHandleType.right: // points up-left - return handle; - case TextSelectionHandleType.collapsed: // points up - return new Transform( - transform: new Matrix4.rotationZ(math.PI / 4.0), - child: handle - ); + ); + } + + /// Builder for material-style text selection handles. + @override + Widget buildHandle(BuildContext context, TextSelectionHandleType type) { + Widget handle = new SizedBox( + width: _kHandleSize, + height: _kHandleSize, + child: new CustomPaint( + painter: new _TextSelectionHandlePainter( + color: Theme.of(context).textSelectionHandleColor + ) + ) + ); + + // [handle] is a circle, with a rectangle in the top left quadrant of that + // circle (an onion pointing to 10:30). We rotate [handle] to point + // straight up or up-right depending on the handle type. + switch (type) { + case TextSelectionHandleType.left: // points up-right + return new Transform( + transform: new Matrix4.rotationZ(math.PI / 2.0), + child: handle + ); + case TextSelectionHandleType.right: // points up-left + return handle; + case TextSelectionHandleType.collapsed: // points up + return new Transform( + transform: new Matrix4.rotationZ(math.PI / 4.0), + child: handle + ); + } + assert(type != null); + return null; } - assert(type != null); - return null; } + +final _MaterialTextSelectionControls materialTextSelectionControls = new _MaterialTextSelectionControls(); diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index 3761d6b1b2..459f8eb0fb 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -219,7 +219,7 @@ class TextPainter { ui.TextBox box = boxes[0]; double caretEnd = box.end; double dx = box.direction == TextDirection.rtl ? caretEnd : caretEnd - caretPrototype.width; - return new Offset(dx, 0.0); + return new Offset(dx, box.top); } Offset _getOffsetFromDownstream(int offset, Rect caretPrototype) { @@ -229,7 +229,7 @@ class TextPainter { ui.TextBox box = boxes[0]; double caretStart = box.start; double dx = box.direction == TextDirection.rtl ? caretStart - caretPrototype.width : caretStart; - return new Offset(dx, 0.0); + return new Offset(dx, box.top); } /// Returns the offset at which to paint the caret. diff --git a/packages/flutter/lib/src/rendering/editable_line.dart b/packages/flutter/lib/src/rendering/editable_line.dart index 359fe8ca4a..35671f504e 100644 --- a/packages/flutter/lib/src/rendering/editable_line.dart +++ b/packages/flutter/lib/src/rendering/editable_line.dart @@ -3,6 +3,7 @@ // 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'; @@ -43,15 +44,17 @@ class RenderEditableLine extends RenderBox { TextSpan text, Color cursorColor, bool showCursor: false, + bool multiline: false, Color selectionColor, double textScaleFactor: 1.0, TextSelection selection, this.onSelectionChanged, Offset paintOffset: Offset.zero, - this.onPaintOffsetUpdateNeeded + this.onPaintOffsetUpdateNeeded, }) : _textPainter = new TextPainter(text: text, textScaleFactor: textScaleFactor), _cursorColor = cursorColor, _showCursor = showCursor, + _multiline = multiline, _selection = selection, _paintOffset = paintOffset { assert(!showCursor || cursorColor != null); @@ -163,14 +166,14 @@ class RenderEditableLine extends RenderBox { // TODO(mpcomplete): We should be more disciplined about when we dirty the // layout state of the text painter so that we can know that the layout is // clean at this point. - _textPainter.layout(); + _textPainter.layout(maxWidth: _maxContentWidth); Offset offset = _paintOffset; 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(caretOffset.dx, size.height) + offset; + Point start = new Point(0.0, constraints.constrainHeight(_preferredHeight)) + caretOffset + offset; return [new TextSelectionPoint(localToGlobal(start), null)]; } else { List boxes = _textPainter.getBoxesForSelection(selection); @@ -200,11 +203,18 @@ class RenderEditableLine extends RenderBox { // TODO(abarth): ParagraphBuilder#build's argument should be optional. // TODO(abarth): These min/max values should be the default for ui.Paragraph. _layoutTemplate = builder.build(new ui.ParagraphStyle()) - ..layout(new ui.ParagraphConstraints(width: double.INFINITY)); + ..layout(new ui.ParagraphConstraints(width: _maxContentWidth)); } return _layoutTemplate.height; } + bool _multiline; + double get _maxContentWidth { + return _multiline ? + constraints.maxWidth - (_kCaretGap + _kCaretWidth) : + double.INFINITY; + } + @override double computeMinIntrinsicHeight(double width) { return _preferredHeight; @@ -274,10 +284,11 @@ class RenderEditableLine extends RenderBox { @override void performLayout() { Size oldSize = hasSize ? size : null; - size = new Size(constraints.maxWidth, constraints.constrainHeight(_preferredHeight)); - _caretPrototype = new Rect.fromLTWH(0.0, _kCaretHeightOffset, _kCaretWidth, size.height - 2.0 * _kCaretHeightOffset); + double lineHeight = constraints.constrainHeight(_preferredHeight); + _caretPrototype = new Rect.fromLTWH(0.0, _kCaretHeightOffset, _kCaretWidth, lineHeight - 2.0 * _kCaretHeightOffset); _selectionRects = null; - _textPainter.layout(); + _textPainter.layout(maxWidth: _maxContentWidth); + size = new Size(constraints.maxWidth, constraints.constrainHeight(math.max(lineHeight, _textPainter.height))); Size contentSize = new Size(_textPainter.width + _kCaretGap + _kCaretWidth, _textPainter.height); if (onPaintOffsetUpdateNeeded != null && (size != oldSize || contentSize != _contentSize)) onPaintOffsetUpdateNeeded(new ViewportDimensions(containerSize: size, contentSize: contentSize)); diff --git a/packages/flutter/lib/src/widgets/editable.dart b/packages/flutter/lib/src/widgets/editable.dart index 30b35571bd..85d1bd8c6c 100644 --- a/packages/flutter/lib/src/widgets/editable.dart +++ b/packages/flutter/lib/src/widgets/editable.dart @@ -156,6 +156,8 @@ class InputValue { /// /// This control is not intended to be used directly. Instead, consider using /// [Input], which provides focus management and material design. +// +// TODO(mpcomplete): rename RawInput since it can span multiple lines. class RawInputLine extends Scrollable { /// Creates a basic single-line input control. /// @@ -168,9 +170,9 @@ class RawInputLine extends Scrollable { this.style, this.cursorColor, this.textScaleFactor, + this.multiline, this.selectionColor, - this.selectionHandleBuilder, - this.selectionToolbarBuilder, + this.selectionControls, @required this.platform, this.keyboardType, this.onChanged, @@ -206,16 +208,15 @@ 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 color to use when painting the selection. final Color selectionColor; - /// Optional builder function for a widget that controls the boundary of a - /// text selection. - final TextSelectionHandleBuilder selectionHandleBuilder; - - /// Optional builder function for a set of controls for working with a - /// text selection (e.g. copy and paste). - final TextSelectionToolbarBuilder selectionToolbarBuilder; + /// Optional delegate for building the text selection handles and toolbar. + final TextSelectionControls selectionControls; /// The platform whose behavior should be approximated, in particular /// for scroll physics. (See [ScrollBehavior.platform].) @@ -356,15 +357,14 @@ class RawInputLineState extends ScrollableState { _selectionOverlay = null; } - if (config.selectionHandleBuilder != null) { + if (config.selectionControls != null) { _selectionOverlay = new TextSelectionOverlay( input: newInput, context: context, debugRequiredFor: config, renderObject: renderObject, onSelectionOverlayChanged: _handleSelectionOverlayChanged, - handleBuilder: config.selectionHandleBuilder, - toolbarBuilder: config.selectionToolbarBuilder + selectionControls: config.selectionControls, ); if (newInput.text.isNotEmpty || longPress) _selectionOverlay.showHandles(); @@ -443,6 +443,7 @@ class RawInputLineState extends ScrollableState { style: config.style, cursorColor: config.cursorColor, showCursor: _showCursor, + multiline: config.multiline, selectionColor: config.selectionColor, textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor, hideText: config.hideText, @@ -460,6 +461,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { this.style, this.cursorColor, this.showCursor, + this.multiline, this.selectionColor, this.textScaleFactor, this.hideText, @@ -472,6 +474,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { final TextStyle style; final Color cursorColor; final bool showCursor; + final bool multiline; final Color selectionColor; final double textScaleFactor; final bool hideText; @@ -485,6 +488,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { text: _styledTextSpan, cursorColor: cursorColor, showCursor: showCursor, + multiline: multiline, selectionColor: selectionColor, textScaleFactor: textScaleFactor, selection: value.selection, diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 2d930275b1..485df0a9bc 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -39,15 +39,6 @@ enum TextSelectionHandleType { collapsed, } -/// Builds a selection handle of the given type. -typedef Widget TextSelectionHandleBuilder(BuildContext context, TextSelectionHandleType type); - -/// Builds a toolbar near a text selection. -/// -/// Typically displays buttons for copying and pasting text. -// TODO(mpcomplete): A single position is probably insufficient. -typedef Widget TextSelectionToolbarBuilder(BuildContext context, Point position, TextSelectionDelegate delegate); - /// The text position that a give selection handle manipulates. Dragging the /// [start] handle always moves the [start]/[baseOffset] of the selection. enum _TextSelectionHandlePosition { start, end } @@ -65,6 +56,22 @@ abstract class TextSelectionDelegate { void hideToolbar(); } +// An interface for building the selection UI, to be provided by the +// implementor of the toolbar widget. +abstract class TextSelectionControls { + /// Builds a selection handle of the given type. + Widget buildHandle(BuildContext context, TextSelectionHandleType type); + + /// Builds a toolbar near a text selection. + /// + /// Typically displays buttons for copying and pasting text. + // TODO(mpcomplete): A single position is probably insufficient. + Widget buildToolbar(BuildContext context, Point position, TextSelectionDelegate delegate); + + /// Returns the size of the selection handle. + Size get handleSize; +} + /// An object that manages a pair of text selection handles. /// /// The selection handles are displayed in the [Overlay] that most closely @@ -79,8 +86,7 @@ class TextSelectionOverlay implements TextSelectionDelegate { this.debugRequiredFor, this.renderObject, this.onSelectionOverlayChanged, - this.handleBuilder, - this.toolbarBuilder + this.selectionControls, }): _input = input { assert(context != null); final OverlayState overlay = Overlay.of(context); @@ -109,16 +115,8 @@ class TextSelectionOverlay implements TextSelectionDelegate { /// will be called with a new input value with an updated selection. final ValueChanged onSelectionOverlayChanged; - /// Builds the selection handles. - /// - /// The selection handles let the user adjust which portion of the text is - /// selected. - final TextSelectionHandleBuilder handleBuilder; - - /// Builds a toolbar to display near the selection. - /// - /// The toolbar typically contains buttons for copying and pasting text. - final TextSelectionToolbarBuilder toolbarBuilder; + /// Builds text selection handles and toolbar. + final TextSelectionControls selectionControls; /// Controls the fade-in animations. static const Duration _kFadeDuration = const Duration(milliseconds: 150); @@ -208,7 +206,7 @@ class TextSelectionOverlay implements TextSelectionDelegate { Widget _buildHandle(BuildContext context, _TextSelectionHandlePosition position) { if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) || - handleBuilder == null) + selectionControls == null) return new Container(); // hide the second handle when collapsed return new FadeTransition( @@ -218,14 +216,14 @@ class TextSelectionOverlay implements TextSelectionDelegate { onSelectionHandleTapped: _handleSelectionHandleTapped, renderObject: renderObject, selection: _selection, - builder: handleBuilder, + selectionControls: selectionControls, position: position ) ); } Widget _buildToolbar(BuildContext context) { - if (toolbarBuilder == null) + if (selectionControls == null) return new Container(); // Find the horizontal midpoint, just above the selected text. @@ -239,7 +237,7 @@ class TextSelectionOverlay implements TextSelectionDelegate { return new FadeTransition( opacity: _toolbarOpacity, - child: toolbarBuilder(context, midpoint, this) + child: selectionControls.buildToolbar(context, midpoint, this) ); } @@ -283,7 +281,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { this.renderObject, this.onSelectionHandleChanged, this.onSelectionHandleTapped, - this.builder + this.selectionControls }) : super(key: key); final TextSelection selection; @@ -291,7 +289,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { final RenderEditableLine renderObject; final ValueChanged onSelectionHandleChanged; final VoidCallback onSelectionHandleTapped; - final TextSelectionHandleBuilder builder; + final TextSelectionControls selectionControls; @override _TextSelectionHandleOverlayState createState() => new _TextSelectionHandleOverlayState(); @@ -301,7 +299,7 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay Point _dragPosition; void _handleDragStart(DragStartDetails details) { - _dragPosition = details.globalPosition; + _dragPosition = details.globalPosition + new Offset(0.0, -config.selectionControls.handleSize.height); } void _handleDragUpdate(DragUpdateDetails details) { @@ -360,15 +358,15 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay } return new GestureDetector( - onHorizontalDragStart: _handleDragStart, - onHorizontalDragUpdate: _handleDragUpdate, + onPanStart: _handleDragStart, + onPanUpdate: _handleDragUpdate, onTap: _handleTap, child: new Stack( children: [ new Positioned( left: point.x, top: point.y, - child: config.builder(context, type) + child: config.selectionControls.buildHandle(context, type) ) ] ) diff --git a/packages/flutter/test/widget/input_test.dart b/packages/flutter/test/widget/input_test.dart index e26506a81e..c91bdff240 100644 --- a/packages/flutter/test/widget/input_test.dart +++ b/packages/flutter/test/widget/input_test.dart @@ -436,4 +436,132 @@ void main() { // End the test here to ensure the animation is properly disposed of. }); + + testWidgets('Multiline text will wrap', (WidgetTester tester) async { + GlobalKey inputKey = new GlobalKey(); + InputValue inputValue = InputValue.empty; + + Widget builder() { + return new Center( + child: new Material( + child: new Input( + value: inputValue, + key: inputKey, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + multiline: true, + hintText: 'Placeholder', + onChanged: (InputValue value) { inputValue = value; } + ) + ) + ); + } + + await tester.pumpWidget(builder()); + + 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()); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, greaterThan(emptyInputSize)); + + enterText('No wrapping here.'); + await tester.pumpWidget(builder()); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, equals(emptyInputSize)); + }); + + testWidgets('Can drag handles to change selection in multiline', (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), + multiline: true, + onChanged: (InputValue value) { inputValue = value; } + ) + ) + ); + } + ) + ] + ); + } + + 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.'; + enterText(testValue); + + await tester.pumpWidget(builder()); + + // Check that the text spans multiple lines. + Point firstPos = textOffsetToPosition(tester, testValue.indexOf('First')); + Point secondPos = textOffsetToPosition(tester, testValue.indexOf('Second')); + Point thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third')); + expect(firstPos.x, secondPos.x); + expect(firstPos.x, thirdPos.x); + expect(firstPos.y, lessThan(secondPos.y)); + expect(secondPos.y, lessThan(thirdPos.y)); + + // Long press the 'n' in 'until' to select the word. + Point untilPos = textOffsetToPosition(tester, testValue.indexOf('until')+1); + TestGesture gesture = await tester.startGesture(untilPos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + expect(inputValue.selection.baseOffset, 76); + expect(inputValue.selection.extentOffset, 81); + + RenderEditableLine renderLine = findRenderEditableLine(tester); + List endpoints = renderLine.getEndpointsForSelection( + inputValue.selection); + expect(endpoints.length, 2); + + // Drag the right handle to the third line, just after 'Third'. + Point handlePos = endpoints[1].point + new Offset(1.0, 1.0); + Point newHandlePos = textOffsetToPosition(tester, testValue.indexOf('Third') + 5); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(inputValue.selection.baseOffset, 76); + expect(inputValue.selection.extentOffset, 108); + + // Drag the left handle to the first line, just after 'First'. + handlePos = endpoints[0].point + new Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, testValue.indexOf('First') + 5); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pumpWidget(builder()); + + expect(inputValue.selection.baseOffset, 5); + expect(inputValue.selection.extentOffset, 108); + + await tester.tap(find.text('CUT')); + await tester.pumpWidget(builder()); + expect(inputValue.selection.isCollapsed, true); + expect(inputValue.text, cutValue); + }); + }