Skip to content

Commit

Permalink
Add a maxLines parameter for multiline Input. (#6310)
Browse files Browse the repository at this point in the history
* 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 #6271

* oops

* comments

* import

* test and RO.update fix

* constant

* fix.caretRect
  • Loading branch information
mpcomplete authored and Hixie committed Oct 14, 2016
1 parent 71e05ff commit c13a6e2
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 101 deletions.
2 changes: 1 addition & 1 deletion examples/flutter_gallery/lib/demo/text_field_demo.dart
Expand Up @@ -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(
Expand Down
11 changes: 6 additions & 5 deletions packages/flutter/lib/src/material/input.dart
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 42 additions & 14 deletions packages/flutter/lib/src/rendering/editable_line.dart
Expand Up @@ -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';

Expand Down Expand Up @@ -37,14 +36,16 @@ 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.
RenderEditableLine({
TextSpan text,
Color cursorColor,
bool showCursor: false,
bool multiline: false,
int maxLines: 1,
Color selectionColor,
double textScaleFactor: 1.0,
TextSelection selection,
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
97 changes: 62 additions & 35 deletions packages/flutter/lib/src/widgets/editable.dart
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
);
}
}
Expand All @@ -461,7 +487,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
this.style,
this.cursorColor,
this.showCursor,
this.multiline,
this.maxLines,
this.selectionColor,
this.textScaleFactor,
this.hideText,
Expand All @@ -474,21 +500,21 @@ 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) {
return new RenderEditableLine(
text: _styledTextSpan,
cursorColor: cursorColor,
showCursor: showCursor,
multiline: multiline,
maxLines: maxLines,
selectionColor: selectionColor,
textScaleFactor: textScaleFactor,
selection: value.selection,
Expand All @@ -504,6 +530,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
..text = _styledTextSpan
..cursorColor = cursorColor
..showCursor = showCursor
..maxLines = maxLines
..selectionColor = selectionColor
..textScaleFactor = textScaleFactor
..selection = value.selection
Expand Down

0 comments on commit c13a6e2

Please sign in to comment.