Commit 2fd78dcc authored by Matt Perry's avatar Matt Perry Committed by GitHub

Add multiline support to Input and friends. (#6155)

Fixes https://github.com/flutter/flutter/issues/6154
parent 73ff4198
......@@ -94,6 +94,12 @@ class TextFieldDemoState extends State<TextFieldDemo> {
validator: _validatePhoneNumber
)
),
new Input(
hintText: 'Tell us about yourself (optional)',
labelText: 'Life story',
multiline: true,
formField: new FormField<String>()
),
new Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
......
......@@ -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<String> formField;
......@@ -205,10 +210,10 @@ class _InputState extends State<Input> {
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,
......
......@@ -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();
......@@ -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.
......
......@@ -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 <TextSelectionPoint>[new TextSelectionPoint(localToGlobal(start), null)];
} else {
List<ui.TextBox> 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));
......
......@@ -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<RawInputLine> {
_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<RawInputLine> {
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,
......
......@@ -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<InputValue> 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<TextSelection> 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: <Widget>[
new Positioned(
left: point.x,
top: point.y,
child: config.builder(context, type)
child: config.selectionControls.buildHandle(context, type)
)
]
)
......
......@@ -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: <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),
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<TextSelectionPoint> 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);
});
}
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