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> { ...@@ -97,7 +97,7 @@ class TextFieldDemoState extends State<TextFieldDemo> {
new Input( new Input(
hintText: 'Tell us about yourself (optional)', hintText: 'Tell us about yourself (optional)',
labelText: 'Life story', labelText: 'Life story',
multiline: true, maxLines: 3,
formField: new FormField<String>() formField: new FormField<String>()
), ),
new Row( new Row(
......
...@@ -43,7 +43,7 @@ class Input extends StatefulWidget { ...@@ -43,7 +43,7 @@ class Input extends StatefulWidget {
this.hideText: false, this.hideText: false,
this.isDense: false, this.isDense: false,
this.autofocus: false, this.autofocus: false,
this.multiline: false, this.maxLines: 1,
this.formField, this.formField,
this.onChanged, this.onChanged,
this.onSubmitted this.onSubmitted
...@@ -85,9 +85,10 @@ class Input extends StatefulWidget { ...@@ -85,9 +85,10 @@ class Input extends StatefulWidget {
/// Whether this input field should focus itself is nothing else is already focused. /// Whether this input field should focus itself is nothing else is already focused.
final bool autofocus; final bool autofocus;
/// True if the text should wrap and span multiple lines, false if it should /// The maximum number of lines for the text to span, wrapping if necessary.
/// stay on a single line and scroll when overflowed. /// If this is 1 (the default), the text will not wrap, but will scroll
final bool multiline; /// horizontally instead.
final int maxLines;
/// Form-specific data, required if this Input is part of a Form. /// Form-specific data, required if this Input is part of a Form.
final FormField<String> formField; final FormField<String> formField;
...@@ -212,7 +213,7 @@ class _InputState extends State<Input> { ...@@ -212,7 +213,7 @@ class _InputState extends State<Input> {
focusKey: focusKey, focusKey: focusKey,
style: textStyle, style: textStyle,
hideText: config.hideText, hideText: config.hideText,
multiline: config.multiline, maxLines: config.maxLines,
cursorColor: themeData.textSelectionColor, cursorColor: themeData.textSelectionColor,
selectionColor: themeData.textSelectionColor, selectionColor: themeData.textSelectionColor,
selectionControls: materialTextSelectionControls, selectionControls: materialTextSelectionControls,
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle, TextBox; import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle, TextBox;
import 'dart:math' as math;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
...@@ -37,6 +36,8 @@ class TextSelectionPoint { ...@@ -37,6 +36,8 @@ class TextSelectionPoint {
final TextDirection direction; final TextDirection direction;
} }
typedef Offset RenderEditableLinePaintOffsetNeededCallback(ViewportDimensions dimensions, Rect caretRect);
/// A single line of editable text. /// A single line of editable text.
class RenderEditableLine extends RenderBox { class RenderEditableLine extends RenderBox {
/// Creates a render object for a single line of editable text. /// Creates a render object for a single line of editable text.
...@@ -44,7 +45,7 @@ class RenderEditableLine extends RenderBox { ...@@ -44,7 +45,7 @@ class RenderEditableLine extends RenderBox {
TextSpan text, TextSpan text,
Color cursorColor, Color cursorColor,
bool showCursor: false, bool showCursor: false,
bool multiline: false, int maxLines: 1,
Color selectionColor, Color selectionColor,
double textScaleFactor: 1.0, double textScaleFactor: 1.0,
TextSelection selection, TextSelection selection,
...@@ -54,7 +55,7 @@ class RenderEditableLine extends RenderBox { ...@@ -54,7 +55,7 @@ class RenderEditableLine extends RenderBox {
}) : _textPainter = new TextPainter(text: text, textScaleFactor: textScaleFactor), }) : _textPainter = new TextPainter(text: text, textScaleFactor: textScaleFactor),
_cursorColor = cursorColor, _cursorColor = cursorColor,
_showCursor = showCursor, _showCursor = showCursor,
_multiline = multiline, _maxLines = maxLines,
_selection = selection, _selection = selection,
_paintOffset = paintOffset { _paintOffset = paintOffset {
assert(!showCursor || cursorColor != null); assert(!showCursor || cursorColor != null);
...@@ -70,7 +71,7 @@ class RenderEditableLine extends RenderBox { ...@@ -70,7 +71,7 @@ class RenderEditableLine extends RenderBox {
SelectionChangedHandler onSelectionChanged; SelectionChangedHandler onSelectionChanged;
/// Called when the inner or outer dimensions of this render object change. /// Called when the inner or outer dimensions of this render object change.
ViewportDimensionsChangeCallback onPaintOffsetUpdateNeeded; RenderEditableLinePaintOffsetNeededCallback onPaintOffsetUpdateNeeded;
/// The text to display /// The text to display
TextSpan get text => _textPainter.text; TextSpan get text => _textPainter.text;
...@@ -105,6 +106,16 @@ class RenderEditableLine extends RenderBox { ...@@ -105,6 +106,16 @@ class RenderEditableLine extends RenderBox {
markNeedsPaint(); 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. /// The color to use when painting the selection.
Color get selectionColor => _selectionColor; Color get selectionColor => _selectionColor;
Color _selectionColor; Color _selectionColor;
...@@ -173,7 +184,7 @@ class RenderEditableLine extends RenderBox { ...@@ -173,7 +184,7 @@ class RenderEditableLine extends RenderBox {
if (selection.isCollapsed) { if (selection.isCollapsed) {
// TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary. // TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary.
Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype); 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)]; return <TextSelectionPoint>[new TextSelectionPoint(localToGlobal(start), null)];
} else { } else {
List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(selection); List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(selection);
...@@ -192,10 +203,19 @@ class RenderEditableLine extends RenderBox { ...@@ -192,10 +203,19 @@ class RenderEditableLine extends RenderBox {
return _textPainter.getPositionForOffset(globalToLocal(globalPosition).toOffset()); 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; Size _contentSize;
ui.Paragraph _layoutTemplate; ui.Paragraph _layoutTemplate;
double get _preferredHeight { double get _preferredLineHeight {
if (_layoutTemplate == null) { if (_layoutTemplate == null) {
ui.ParagraphBuilder builder = new ui.ParagraphBuilder() ui.ParagraphBuilder builder = new ui.ParagraphBuilder()
..pushStyle(text.style.getTextStyle(textScaleFactor: textScaleFactor)) ..pushStyle(text.style.getTextStyle(textScaleFactor: textScaleFactor))
...@@ -208,21 +228,20 @@ class RenderEditableLine extends RenderBox { ...@@ -208,21 +228,20 @@ class RenderEditableLine extends RenderBox {
return _layoutTemplate.height; return _layoutTemplate.height;
} }
bool _multiline;
double get _maxContentWidth { double get _maxContentWidth {
return _multiline ? return _maxLines > 1 ?
constraints.maxWidth - (_kCaretGap + _kCaretWidth) : constraints.maxWidth - (_kCaretGap + _kCaretWidth) :
double.INFINITY; double.INFINITY;
} }
@override @override
double computeMinIntrinsicHeight(double width) { double computeMinIntrinsicHeight(double width) {
return _preferredHeight; return _preferredLineHeight;
} }
@override @override
double computeMaxIntrinsicHeight(double width) { double computeMaxIntrinsicHeight(double width) {
return _preferredHeight; return _preferredLineHeight;
} }
@override @override
...@@ -284,17 +303,26 @@ class RenderEditableLine extends RenderBox { ...@@ -284,17 +303,26 @@ class RenderEditableLine extends RenderBox {
@override @override
void performLayout() { void performLayout() {
Size oldSize = hasSize ? size : null; 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); _caretPrototype = new Rect.fromLTWH(0.0, _kCaretHeightOffset, _kCaretWidth, lineHeight - 2.0 * _kCaretHeightOffset);
_selectionRects = null; _selectionRects = null;
_textPainter.layout(maxWidth: _maxContentWidth); _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); Size contentSize = new Size(_textPainter.width + _kCaretGap + _kCaretWidth, _textPainter.height);
if (onPaintOffsetUpdateNeeded != null && (size != oldSize || contentSize != _contentSize)) assert(_selection != null);
onPaintOffsetUpdateNeeded(new ViewportDimensions(containerSize: size, contentSize: contentSize)); Rect caretRect = getLocalRectForCaret(_selection.extent);
if (onPaintOffsetUpdateNeeded != null && (size != oldSize || contentSize != _contentSize || !_withinBounds(caretRect)))
onPaintOffsetUpdateNeeded(new ViewportDimensions(containerSize: size, contentSize: contentSize), caretRect);
_contentSize = contentSize; _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) { void _paintCaret(Canvas canvas, Offset effectiveOffset) {
Offset caretOffset = _textPainter.getOffsetForCaret(_selection.extent, _caretPrototype); Offset caretOffset = _textPainter.getOffsetForCaret(_selection.extent, _caretPrototype);
Paint paint = new Paint()..color = _cursorColor; Paint paint = new Paint()..color = _cursorColor;
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
import 'dart:async'; 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:flutter/services.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:flutter_services/editing.dart' as mojom; import 'package:flutter_services/editing.dart' as mojom;
...@@ -170,17 +170,17 @@ class RawInputLine extends Scrollable { ...@@ -170,17 +170,17 @@ class RawInputLine extends Scrollable {
this.style, this.style,
this.cursorColor, this.cursorColor,
this.textScaleFactor, this.textScaleFactor,
this.multiline, int maxLines: 1,
this.selectionColor, this.selectionColor,
this.selectionControls, this.selectionControls,
@required this.platform, @required this.platform,
this.keyboardType, this.keyboardType,
this.onChanged, this.onChanged,
this.onSubmitted this.onSubmitted
}) : super( }) : maxLines = maxLines, super(
key: key, key: key,
initialScrollOffset: 0.0, initialScrollOffset: 0.0,
scrollDirection: Axis.horizontal scrollDirection: maxLines > 1 ? Axis.vertical : Axis.horizontal
) { ) {
assert(value != null); assert(value != null);
} }
...@@ -208,9 +208,10 @@ class RawInputLine extends Scrollable { ...@@ -208,9 +208,10 @@ class RawInputLine extends Scrollable {
/// The color to use when painting the cursor. /// The color to use when painting the cursor.
final Color cursorColor; final Color cursorColor;
/// True if the text should wrap and span multiple lines, false if it should /// The maximum number of lines for the text to span, wrapping if necessary.
/// stay on a single line and scroll when overflowed. /// If this is 1 (the default), the text will not wrap, but will scroll
final bool multiline; /// horizontally instead.
final int maxLines;
/// The color to use when painting the selection. /// The color to use when painting the selection.
final Color selectionColor; final Color selectionColor;
...@@ -273,30 +274,45 @@ class RawInputLineState extends ScrollableState<RawInputLine> { ...@@ -273,30 +274,45 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
bool get _isAttachedToKeyboard => _keyboardHandle != null && _keyboardHandle.attached; bool get _isAttachedToKeyboard => _keyboardHandle != null && _keyboardHandle.attached;
double _contentWidth = 0.0; bool get _isMultiline => config.maxLines > 1;
double _containerWidth = 0.0;
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 // 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 // setState() callback because we are called during layout and all
// we're updating is the new offset, which we are providing to the // we're updating is the new offset, which we are providing to the
// render object via our return value. // render object via our return value.
_containerWidth = dimensions.containerSize.width; _contentExtent = _isMultiline ?
_contentWidth = dimensions.contentSize.width; dimensions.contentSize.height :
dimensions.contentSize.width;
_containerExtent = _isMultiline ?
dimensions.containerSize.height :
dimensions.containerSize.width;
didUpdateScrollBehavior(scrollBehavior.updateExtents( didUpdateScrollBehavior(scrollBehavior.updateExtents(
contentExtent: _contentWidth, contentExtent: _contentExtent,
containerExtent: _containerWidth, containerExtent: _containerExtent,
// 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.
// TODO(ianh): We should really only do this when text is added, // TODO(ianh): We should really only do this when text is added,
// not generally any time the size changes. // not generally any time the size changes.
scrollOffset: pixelOffsetToScrollOffset(-_contentWidth) scrollOffset: _getScrollOffsetForCaret(caretRect, _containerExtent)
)); ));
updateGestureDetector(); updateGestureDetector();
return scrollOffsetToPixelDelta(scrollOffset); 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) { void _attachOrDetachKeyboard(bool focused) {
if (focused && !_isAttachedToKeyboard) { if (focused && !_isAttachedToKeyboard) {
_keyboardHandle = keyboard.attach(_keyboardClient.createStub(), _keyboardHandle = keyboard.attach(_keyboardClient.createStub(),
...@@ -373,10 +389,18 @@ class RawInputLineState extends ScrollableState<RawInputLine> { ...@@ -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 assert(!newInput.composing.isValid); // composing range must be empty while selecting
if (config.onChanged != null) if (config.onChanged != null)
config.onChanged(newInput); 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 /// Whether the blinking cursor is actually visible at this precise moment
...@@ -438,18 +462,20 @@ class RawInputLineState extends ScrollableState<RawInputLine> { ...@@ -438,18 +462,20 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
} }
} }
return new _EditableLineWidget( return new ClipRect(
value: _keyboardClient.inputValue, child: new _EditableLineWidget(
style: config.style, value: _keyboardClient.inputValue,
cursorColor: config.cursorColor, style: config.style,
showCursor: _showCursor, cursorColor: config.cursorColor,
multiline: config.multiline, showCursor: _showCursor,
selectionColor: config.selectionColor, maxLines: config.maxLines,
textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor, selectionColor: config.selectionColor,
hideText: config.hideText, textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor,
onSelectionChanged: _handleSelectionChanged, hideText: config.hideText,
paintOffset: scrollOffsetToPixelDelta(scrollOffset), onSelectionChanged: _handleSelectionChanged,
onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded paintOffset: scrollOffsetToPixelDelta(scrollOffset),
onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded
)
); );
} }
} }
...@@ -461,7 +487,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { ...@@ -461,7 +487,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
this.style, this.style,
this.cursorColor, this.cursorColor,
this.showCursor, this.showCursor,
this.multiline, this.maxLines,
this.selectionColor, this.selectionColor,
this.textScaleFactor, this.textScaleFactor,
this.hideText, this.hideText,
...@@ -474,13 +500,13 @@ class _EditableLineWidget extends LeafRenderObjectWidget { ...@@ -474,13 +500,13 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
final TextStyle style; final TextStyle style;
final Color cursorColor; final Color cursorColor;
final bool showCursor; final bool showCursor;
final bool multiline; final int maxLines;
final Color selectionColor; final Color selectionColor;
final double textScaleFactor; final double textScaleFactor;
final bool hideText; final bool hideText;
final SelectionChangedHandler onSelectionChanged; final SelectionChangedHandler onSelectionChanged;
final Offset paintOffset; final Offset paintOffset;
final ViewportDimensionsChangeCallback onPaintOffsetUpdateNeeded; final RenderEditableLinePaintOffsetNeededCallback onPaintOffsetUpdateNeeded;
@override @override
RenderEditableLine createRenderObject(BuildContext context) { RenderEditableLine createRenderObject(BuildContext context) {
...@@ -488,7 +514,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { ...@@ -488,7 +514,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
text: _styledTextSpan, text: _styledTextSpan,
cursorColor: cursorColor, cursorColor: cursorColor,
showCursor: showCursor, showCursor: showCursor,
multiline: multiline, maxLines: maxLines,
selectionColor: selectionColor, selectionColor: selectionColor,
textScaleFactor: textScaleFactor, textScaleFactor: textScaleFactor,
selection: value.selection, selection: value.selection,
...@@ -504,6 +530,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { ...@@ -504,6 +530,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
..text = _styledTextSpan ..text = _styledTextSpan
..cursorColor = cursorColor ..cursorColor = cursorColor
..showCursor = showCursor ..showCursor = showCursor
..maxLines = maxLines
..selectionColor = selectionColor ..selectionColor = selectionColor
..textScaleFactor = textScaleFactor ..textScaleFactor = textScaleFactor
..selection = value.selection ..selection = value.selection
......
...@@ -8,6 +8,7 @@ import 'dart:ui' as ui show window; ...@@ -8,6 +8,7 @@ import 'dart:ui' as ui show window;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart'; import 'package:flutter/physics.dart';
import 'package:flutter/scheduler.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'basic.dart'; import 'basic.dart';
...@@ -436,7 +437,7 @@ class ScrollableState<T extends Scrollable> extends State<T> with SingleTickerPr ...@@ -436,7 +437,7 @@ class ScrollableState<T extends Scrollable> extends State<T> with SingleTickerPr
final ClampOverscrolls clampOverscrolls = ClampOverscrolls.of(context); final ClampOverscrolls clampOverscrolls = ClampOverscrolls.of(context);
final double clampedScrollOffset = clampOverscrolls?.clampScrollOffset(this, newScrollOffset) ?? newScrollOffset; final double clampedScrollOffset = clampOverscrolls?.clampScrollOffset(this, newScrollOffset) ?? newScrollOffset;
setState(() { _setStateMaybeDuringBuild(() {
_virtualScrollOffset = newScrollOffset; _virtualScrollOffset = newScrollOffset;
_scrollUnderway = _scrollOffset != clampedScrollOffset; _scrollUnderway = _scrollOffset != clampedScrollOffset;
_scrollOffset = clampedScrollOffset; _scrollOffset = clampedScrollOffset;
...@@ -705,6 +706,18 @@ class ScrollableState<T extends Scrollable> extends State<T> with SingleTickerPr ...@@ -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 }) { void _endScroll({ DragEndDetails details }) {
_numberOfInProgressScrolls -= 1; _numberOfInProgressScrolls -= 1;
if (_numberOfInProgressScrolls == 0) { if (_numberOfInProgressScrolls == 0) {
...@@ -713,7 +726,7 @@ class ScrollableState<T extends Scrollable> extends State<T> with SingleTickerPr ...@@ -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 // If the scroll hasn't already stopped because we've hit a clamped
// edge or the controller stopped animating, then rebuild the Scrollable // edge or the controller stopped animating, then rebuild the Scrollable
// with the IgnorePointer widget turned off. // with the IgnorePointer widget turned off.
setState(() { _setStateMaybeDuringBuild(() {
_scrollUnderway = false; _scrollUnderway = false;
}); });
} }
......
...@@ -43,6 +43,8 @@ enum TextSelectionHandleType { ...@@ -43,6 +43,8 @@ enum TextSelectionHandleType {
/// [start] handle always moves the [start]/[baseOffset] of the selection. /// [start] handle always moves the [start]/[baseOffset] of the selection.
enum _TextSelectionHandlePosition { start, end } enum _TextSelectionHandlePosition { start, end }
typedef void TextSelectionOverlayChanged(InputValue value, Rect caretRect);
/// An interface for manipulating the selection, to be used by the implementor /// An interface for manipulating the selection, to be used by the implementor
/// of the toolbar widget. /// of the toolbar widget.
abstract class TextSelectionDelegate { abstract class TextSelectionDelegate {
...@@ -113,7 +115,7 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -113,7 +115,7 @@ class TextSelectionOverlay implements TextSelectionDelegate {
/// ///
/// For example, if the use drags one of the selection handles, this function /// 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. /// 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. /// Builds text selection handles and toolbar.
final TextSelectionControls selectionControls; final TextSelectionControls selectionControls;
...@@ -212,7 +214,7 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -212,7 +214,7 @@ class TextSelectionOverlay implements TextSelectionDelegate {
return new FadeTransition( return new FadeTransition(
opacity: _handleOpacity, opacity: _handleOpacity,
child: new _TextSelectionHandleOverlay( child: new _TextSelectionHandleOverlay(
onSelectionHandleChanged: _handleSelectionHandleChanged, onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); },
onSelectionHandleTapped: _handleSelectionHandleTapped, onSelectionHandleTapped: _handleSelectionHandleTapped,
renderObject: renderObject, renderObject: renderObject,
selection: _selection, selection: _selection,
...@@ -241,8 +243,19 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -241,8 +243,19 @@ class TextSelectionOverlay implements TextSelectionDelegate {
); );
} }
void _handleSelectionHandleChanged(TextSelection newSelection) { void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) {
inputValue = _input.copyWith(selection: newSelection, composing: TextRange.empty); 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() { void _handleSelectionHandleTapped() {
...@@ -262,8 +275,10 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -262,8 +275,10 @@ class TextSelectionOverlay implements TextSelectionDelegate {
@override @override
set inputValue(InputValue value) { set inputValue(InputValue value) {
update(value); update(value);
if (onSelectionOverlayChanged != null) if (onSelectionOverlayChanged != null) {
onSelectionOverlayChanged(value); Rect caretRect = renderObject.getLocalRectForCaret(value.selection.extent);
onSelectionOverlayChanged(value, caretRect);
}
} }
@override @override
......
...@@ -54,6 +54,14 @@ void main() { ...@@ -54,6 +54,14 @@ void main() {
MockClipboard mockClipboard = new MockClipboard(); MockClipboard mockClipboard = new MockClipboard();
serviceMocker.registerMockService(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) { void enterText(String testValue) {
// Simulate entry of text through the keyboard. // Simulate entry of text through the keyboard.
expect(mockKeyboard.client, isNotNull); expect(mockKeyboard.client, isNotNull);
...@@ -63,6 +71,32 @@ void main() { ...@@ -63,6 +71,32 @@ void main() {
..composingExtent = testValue.length); ..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 { testWidgets('Editable text has consistent size', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey(); GlobalKey inputKey = new GlobalKey();
InputValue inputValue = InputValue.empty; InputValue inputValue = InputValue.empty;
...@@ -174,32 +208,6 @@ void main() { ...@@ -174,32 +208,6 @@ void main() {
await tester.pump(); 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 { testWidgets('Can long press to select', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey(); GlobalKey inputKey = new GlobalKey();
InputValue inputValue = InputValue.empty; InputValue inputValue = InputValue.empty;
...@@ -438,18 +446,18 @@ void main() { ...@@ -438,18 +446,18 @@ void main() {
// End the test here to ensure the animation is properly disposed of. // 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(); GlobalKey inputKey = new GlobalKey();
InputValue inputValue = InputValue.empty; InputValue inputValue = InputValue.empty;
Widget builder() { Widget builder(int maxLines) {
return new Center( return new Center(
child: new Material( child: new Material(
child: new Input( child: new Input(
value: inputValue, value: inputValue,
key: inputKey, key: inputKey,
style: const TextStyle(color: Colors.black, fontSize: 34.0), style: const TextStyle(color: Colors.black, fontSize: 34.0),
multiline: true, maxLines: maxLines,
hintText: 'Placeholder', hintText: 'Placeholder',
onChanged: (InputValue value) { inputValue = value; } onChanged: (InputValue value) { inputValue = value; }
) )
...@@ -457,22 +465,36 @@ void main() { ...@@ -457,22 +465,36 @@ void main() {
); );
} }
await tester.pumpWidget(builder()); await tester.pumpWidget(builder(3));
RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey)); RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey));
RenderBox inputBox = findInputBox(); RenderBox inputBox = findInputBox();
Size emptyInputSize = inputBox.size; Size emptyInputSize = inputBox.size;
enterText('This is a long line of text that will wrap to multiple lines.'); enterText('No wrapping here.');
await tester.pumpWidget(builder()); 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(findInputBox(), equals(inputBox));
expect(inputBox.size, greaterThan(emptyInputSize)); expect(inputBox.size, greaterThan(emptyInputSize));
enterText('No wrapping here.'); Size threeLineInputSize = inputBox.size;
await tester.pumpWidget(builder());
// 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(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 { testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async {
...@@ -490,7 +512,7 @@ void main() { ...@@ -490,7 +512,7 @@ void main() {
value: inputValue, value: inputValue,
key: inputKey, key: inputKey,
style: const TextStyle(color: Colors.black, fontSize: 34.0), style: const TextStyle(color: Colors.black, fontSize: 34.0),
multiline: true, maxLines: 3,
onChanged: (InputValue value) { inputValue = value; } onChanged: (InputValue value) { inputValue = value; }
) )
) )
...@@ -503,8 +525,8 @@ void main() { ...@@ -503,8 +525,8 @@ void main() {
await tester.pumpWidget(builder()); 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 testValue = kThreeLines;
String cutValue = 'First line of stuff.'; String cutValue = 'First line of stuff keeps going until abcdef ghijk. ';
enterText(testValue); enterText(testValue);
await tester.pumpWidget(builder()); await tester.pumpWidget(builder());
...@@ -565,4 +587,95 @@ void main() { ...@@ -565,4 +587,95 @@ void main() {
expect(inputValue.text, cutValue); 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