From 75f39789c7acab0bc140b2e88528a67e905766d6 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 18 Jan 2017 12:34:34 -0500 Subject: [PATCH] Add support for specifying maxLines for Text. (#7493) Overflow handling works with clipping, adding an ellipsis on the last line, or fading the last line. Fixes https://github.com/flutter/flutter/issues/7271 --- bin/internal/engine.version | 2 +- .../lib/src/painting/text_painter.dart | 45 +++++++++++++- .../flutter/lib/src/painting/text_style.dart | 2 + .../lib/src/rendering/editable_line.dart | 23 ++----- .../flutter/lib/src/rendering/paragraph.dart | 56 ++++++++++++----- packages/flutter/lib/src/widgets/basic.dart | 14 ++++- packages/flutter/lib/src/widgets/text.dart | 18 +++++- .../test/painting/text_style_test.dart | 4 +- .../test/rendering/paragraph_test.dart | 60 +++++++++++++++++++ 9 files changed, 181 insertions(+), 43 deletions(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index a438cc95d2a..2dfa27c7d6f 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -b3ed79122edd7172327ce415688ef674d6a7fa5d +2efc78cc24eac9439a5315ed9333fa8599aab3a1 diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index f0cb3f16c0c..5f242928898 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -11,6 +11,8 @@ import 'basic_types.dart'; import 'text_editing.dart'; import 'text_span.dart'; +final String _kZeroWidthSpace = new String.fromCharCode(0x200B); + /// An object that paints a [TextSpan] tree into a [Canvas]. /// /// To use a [TextPainter], follow these steps: @@ -34,8 +36,9 @@ class TextPainter { TextSpan text, TextAlign textAlign, double textScaleFactor: 1.0, + int maxLines, String ellipsis, - }) : _text = text, _textAlign = textAlign, _textScaleFactor = textScaleFactor, _ellipsis = ellipsis { + }) : _text = text, _textAlign = textAlign, _textScaleFactor = textScaleFactor, _maxLines = maxLines, _ellipsis = ellipsis { assert(text == null || text.debugAssertIsValid()); assert(textScaleFactor != null); } @@ -50,6 +53,8 @@ class TextPainter { assert(value == null || value.debugAssertIsValid()); if (_text == value) return; + if (_text?.style != value?.style) + _layoutTemplate = null; _text = value; _paragraph = null; _needsLayout = true; @@ -95,6 +100,32 @@ class TextPainter { _needsLayout = true; } + /// An optional maximum number of lines for the text to span, wrapping if necessary. + /// If the text exceeds the given number of lines, it will be truncated according + /// to [overflow]. + int get maxLines => _maxLines; + int _maxLines; + set maxLines(int value) { + if (_maxLines == value) + return; + _maxLines = value; + _paragraph = null; + _needsLayout = true; + } + + ui.Paragraph _layoutTemplate; + double get preferredLineHeight { + assert(text != null); + if (_layoutTemplate == null) { + ui.ParagraphBuilder builder = new ui.ParagraphBuilder(new ui.ParagraphStyle()); + if (text.style != null) + builder.pushStyle(text.style.getTextStyle(textScaleFactor: textScaleFactor)); + builder.addText(_kZeroWidthSpace); + _layoutTemplate = builder.build() + ..layout(new ui.ParagraphConstraints(width: double.INFINITY)); + } + return _layoutTemplate.height; + } // Unfortunately, using full precision floating point here causes bad layouts // because floating point math isn't associative. If we add and subtract @@ -162,6 +193,11 @@ class TextPainter { return null; } + bool get didExceedMaxLines { + assert(!_needsLayout); + return _paragraph.didExceedMaxLines; + } + double _lastMinWidth; double _lastMaxWidth; @@ -179,9 +215,14 @@ class TextPainter { ui.ParagraphStyle paragraphStyle = _text.style?.getParagraphStyle( textAlign: textAlign, textScaleFactor: textScaleFactor, + maxLines: _maxLines, ellipsis: _ellipsis, ); - paragraphStyle ??= new ui.ParagraphStyle(); + paragraphStyle ??= new ui.ParagraphStyle( + textAlign: textAlign, + maxLines: maxLines, + ellipsis: ellipsis, + ); ui.ParagraphBuilder builder = new ui.ParagraphBuilder(paragraphStyle); _text.build(builder, textScaleFactor: textScaleFactor); _paragraph = builder.build(); diff --git a/packages/flutter/lib/src/painting/text_style.dart b/packages/flutter/lib/src/painting/text_style.dart index 81365833f67..206bd9b6f94 100644 --- a/packages/flutter/lib/src/painting/text_style.dart +++ b/packages/flutter/lib/src/painting/text_style.dart @@ -233,6 +233,7 @@ class TextStyle { TextAlign textAlign, double textScaleFactor: 1.0, String ellipsis, + int maxLines, }) { return new ui.ParagraphStyle( textAlign: textAlign, @@ -241,6 +242,7 @@ class TextStyle { fontFamily: fontFamily, fontSize: fontSize == null ? null : fontSize * textScaleFactor, lineHeight: height, + maxLines: maxLines, ellipsis: ellipsis, ); } diff --git a/packages/flutter/lib/src/rendering/editable_line.dart b/packages/flutter/lib/src/rendering/editable_line.dart index 2a0e5b3bb03..7bb76abf168 100644 --- a/packages/flutter/lib/src/rendering/editable_line.dart +++ b/packages/flutter/lib/src/rendering/editable_line.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle, TextBox; +import 'dart:ui' as ui show TextBox; import 'package:flutter/gestures.dart'; @@ -88,9 +88,6 @@ class RenderEditable extends RenderBox { set text(TextSpan value) { if (_textPainter.text == value) return; - TextSpan oldStyledText = _textPainter.text; - if (oldStyledText.style != value.style) - _layoutTemplate = null; _textPainter.text = value; markNeedsLayout(); } @@ -115,7 +112,9 @@ class RenderEditable extends RenderBox { markNeedsPaint(); } - /// Whether to paint the cursor. + /// 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 extend + /// indefinitely instead. int get maxLines => _maxLines; int _maxLines; set maxLines(int value) { @@ -222,19 +221,7 @@ class RenderEditable extends RenderBox { Size _contentSize; - ui.Paragraph _layoutTemplate; - double get _preferredLineHeight { - if (_layoutTemplate == null) { - ui.ParagraphBuilder builder = new ui.ParagraphBuilder(new ui.ParagraphStyle()) - ..pushStyle(text.style.getTextStyle(textScaleFactor: textScaleFactor)) - ..addText(_kZeroWidthSpace); - // 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() - ..layout(new ui.ParagraphConstraints(width: double.INFINITY)); - } - return _layoutTemplate.height; - } + double get _preferredLineHeight => _textPainter.preferredLineHeight; double get _maxContentWidth { return _maxLines > 1 ? diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 3c30e891e09..6091ad9c73f 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -33,14 +33,16 @@ class RenderParagraph extends RenderBox { TextAlign textAlign, bool softWrap: true, TextOverflow overflow: TextOverflow.clip, - double textScaleFactor: 1.0 + double textScaleFactor: 1.0, + int maxLines, }) : _softWrap = softWrap, _overflow = overflow, _textPainter = new TextPainter( - text: text, - textAlign: textAlign, - textScaleFactor: textScaleFactor, - ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null, + text: text, + textAlign: textAlign, + textScaleFactor: textScaleFactor, + maxLines: maxLines, + ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null, ) { assert(text != null); assert(text.debugAssertIsValid()); @@ -110,8 +112,20 @@ class RenderParagraph extends RenderBox { markNeedsLayout(); } + /// An optional maximum number of lines for the text to span, wrapping if necessary. + /// If the text exceeds the given number of lines, it will be truncated according + /// to [overflow]. + int get maxLines => _textPainter.maxLines; + set maxLines(int value) { + if (_textPainter.maxLines == value) + return; + _textPainter.maxLines = value; + _overflowShader = null; + markNeedsLayout(); + } + void _layoutText({ double minWidth: 0.0, double maxWidth: double.INFINITY }) { - bool wrap = _softWrap || _overflow == TextOverflow.ellipsis; + bool wrap = _softWrap || (_overflow == TextOverflow.ellipsis && maxLines == null); _textPainter.layout(minWidth: minWidth, maxWidth: wrap ? maxWidth : double.INFINITY); } @@ -183,30 +197,40 @@ class RenderParagraph extends RenderBox { size = constraints.constrain(textSize); final bool didOverflowWidth = size.width < textSize.width; + final bool didOverflowHeight = _textPainter.didExceedMaxLines; // TODO(abarth): We're only measuring the sizes of the line boxes here. If // the glyphs draw outside the line boxes, we might think that there isn't // visual overflow when there actually is visual overflow. This can become // a problem if we start having horizontal overflow and introduce a clip // that affects the actual (but undetected) vertical overflow. - _hasVisualOverflow = didOverflowWidth || size.height < textSize.height; - if (didOverflowWidth) { + _hasVisualOverflow = didOverflowWidth || didOverflowHeight; + if (_hasVisualOverflow) { switch (_overflow) { case TextOverflow.clip: case TextOverflow.ellipsis: _overflowShader = null; break; case TextOverflow.fade: - TextPainter fadeWidthPainter = new TextPainter( + TextPainter fadeSizePainter = new TextPainter( text: new TextSpan(style: _textPainter.text.style, text: '\u2026'), textScaleFactor: textScaleFactor )..layout(); - final double fadeEnd = size.width; - final double fadeStart = fadeEnd - fadeWidthPainter.width; - // TODO(abarth): This shader has an LTR bias. - _overflowShader = new ui.Gradient.linear( - [new Point(fadeStart, 0.0), new Point(fadeEnd, 0.0)], - [const Color(0xFFFFFFFF), const Color(0x00FFFFFF)] - ); + if (didOverflowWidth) { + final double fadeEnd = size.width; + final double fadeStart = fadeEnd - fadeSizePainter.width; + // TODO(abarth): This shader has an LTR bias. + _overflowShader = new ui.Gradient.linear( + [new Point(fadeStart, 0.0), new Point(fadeEnd, 0.0)], + [const Color(0xFFFFFFFF), const Color(0x00FFFFFF)] + ); + } else { + final double fadeEnd = size.height; + final double fadeStart = fadeEnd - fadeSizePainter.height / 2.0; + _overflowShader = new ui.Gradient.linear( + [new Point(0.0, fadeStart), new Point(0.0, fadeEnd)], + [const Color(0xFFFFFFFF), const Color(0x00FFFFFF)] + ); + } break; } } else { diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index db4b5ba3b97..a2e825f0940 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -2442,7 +2442,8 @@ class RichText extends LeafRenderObjectWidget { this.textAlign, this.softWrap: true, this.overflow: TextOverflow.clip, - this.textScaleFactor: 1.0 + this.textScaleFactor: 1.0, + this.maxLines, }) : super(key: key) { assert(text != null); assert(softWrap != null); @@ -2470,13 +2471,19 @@ class RichText extends LeafRenderObjectWidget { /// the specified font size. final double textScaleFactor; + /// An optional maximum number of lines for the text to span, wrapping if necessary. + /// If the text exceeds the given number of lines, it will be truncated according + /// to [overflow]. + final int maxLines; + @override RenderParagraph createRenderObject(BuildContext context) { return new RenderParagraph(text, textAlign: textAlign, softWrap: softWrap, overflow: overflow, - textScaleFactor: textScaleFactor + textScaleFactor: textScaleFactor, + maxLines: maxLines, ); } @@ -2487,7 +2494,8 @@ class RichText extends LeafRenderObjectWidget { ..textAlign = textAlign ..softWrap = softWrap ..overflow = overflow - ..textScaleFactor = textScaleFactor; + ..textScaleFactor = textScaleFactor + ..maxLines = maxLines; } } diff --git a/packages/flutter/lib/src/widgets/text.dart b/packages/flutter/lib/src/widgets/text.dart index 6810a090c6e..fb7d082e24e 100644 --- a/packages/flutter/lib/src/widgets/text.dart +++ b/packages/flutter/lib/src/widgets/text.dart @@ -20,6 +20,7 @@ class DefaultTextStyle extends InheritedWidget { this.textAlign, this.softWrap: true, this.overflow: TextOverflow.clip, + this.maxLines, Widget child }) : super(key: key, child: child) { assert(style != null); @@ -35,6 +36,7 @@ class DefaultTextStyle extends InheritedWidget { : style = const TextStyle(), textAlign = null, softWrap = true, + maxLines = null, overflow = TextOverflow.clip; /// Creates a default text style that inherits from the given [BuildContext]. @@ -50,6 +52,7 @@ class DefaultTextStyle extends InheritedWidget { TextAlign textAlign, bool softWrap, TextOverflow overflow, + int maxLines, Widget child }) { assert(context != null); @@ -61,6 +64,7 @@ class DefaultTextStyle extends InheritedWidget { textAlign: textAlign ?? parent.textAlign, softWrap: softWrap ?? parent.softWrap, overflow: overflow ?? parent.overflow, + maxLines: maxLines ?? parent.maxLines, child: child ); } @@ -79,6 +83,11 @@ class DefaultTextStyle extends InheritedWidget { /// How visual overflow should be handled. final TextOverflow overflow; + /// An optional maximum number of lines for the text to span, wrapping if necessary. + /// If the text exceeds the given number of lines, it will be truncated according + /// to [overflow]. + final int maxLines; + /// The closest instance of this class that encloses the given context. /// /// If no such instance exists, returns an instance created by @@ -134,7 +143,8 @@ class Text extends StatelessWidget { this.textAlign, this.softWrap, this.overflow, - this.textScaleFactor + this.textScaleFactor, + this.maxLines, }) : super(key: key) { assert(data != null); } @@ -168,6 +178,11 @@ class Text extends StatelessWidget { /// Defaults to [MediaQuery.textScaleFactor]. final double textScaleFactor; + /// An optional maximum number of lines the text is allowed to take up. + /// If the text exceeds the given number of lines, it will be truncated according + /// to [overflow]. + final int maxLines; + @override Widget build(BuildContext context) { DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); @@ -179,6 +194,7 @@ class Text extends StatelessWidget { softWrap: softWrap ?? defaultTextStyle.softWrap, overflow: overflow ?? defaultTextStyle.overflow, textScaleFactor: textScaleFactor ?? MediaQuery.of(context).textScaleFactor, + maxLines: maxLines ?? defaultTextStyle.maxLines, text: new TextSpan( style: effectiveTextStyle, text: data diff --git a/packages/flutter/test/painting/text_style_test.dart b/packages/flutter/test/painting/text_style_test.dart index a4bf4b827dd..6c7437feea6 100644 --- a/packages/flutter/test/painting/text_style_test.dart +++ b/packages/flutter/test/painting/text_style_test.dart @@ -98,9 +98,9 @@ void main() { ui.ParagraphStyle ps2 = s2.getParagraphStyle(textAlign: TextAlign.center); expect(ps2, equals(new ui.ParagraphStyle(textAlign: TextAlign.center, fontWeight: FontWeight.w800, fontSize: 10.0, lineHeight: 100.0))); - expect(ps2.toString(), 'ParagraphStyle(textAlign: TextAlign.center, fontWeight: FontWeight.w800, fontStyle: unspecified, lineCount: unspecified, fontFamily: unspecified, fontSize: 10.0, lineHeight: 100.0x, ellipsis: unspecified)'); + expect(ps2.toString(), 'ParagraphStyle(textAlign: TextAlign.center, fontWeight: FontWeight.w800, fontStyle: unspecified, lineCount: unspecified, fontFamily: unspecified, fontSize: 10.0, lineHeight: 100.0x, maxLines: unspecified, ellipsis: unspecified)'); ui.ParagraphStyle ps5 = s5.getParagraphStyle(); expect(ps5, equals(new ui.ParagraphStyle(fontWeight: FontWeight.w700, fontSize: 12.0, lineHeight: 123.0))); - expect(ps5.toString(), 'ParagraphStyle(textAlign: unspecified, fontWeight: FontWeight.w700, fontStyle: unspecified, lineCount: unspecified, fontFamily: unspecified, fontSize: 12.0, lineHeight: 123.0x, ellipsis: unspecified)'); + expect(ps5.toString(), 'ParagraphStyle(textAlign: unspecified, fontWeight: FontWeight.w700, fontStyle: unspecified, lineCount: unspecified, fontFamily: unspecified, fontSize: 12.0, lineHeight: 123.0x, maxLines: unspecified, ellipsis: unspecified)'); }); } diff --git a/packages/flutter/test/rendering/paragraph_test.dart b/packages/flutter/test/rendering/paragraph_test.dart index 1f5f28f9c0b..28aa898293a 100644 --- a/packages/flutter/test/rendering/paragraph_test.dart +++ b/packages/flutter/test/rendering/paragraph_test.dart @@ -72,4 +72,64 @@ void main() { TextRange range85 = paragraph.getWordBoundary(new TextPosition(offset: 75)); expect(range85.textInside(_kText), equals('Queen\'s')); }); + + test('overflow test', () { + RenderParagraph paragraph = new RenderParagraph( + new TextSpan(text: 'This is\na wrapping test. It should wrap at manual newlines, and if softWrap is true, also at spaces.'), + maxLines: 1, + softWrap: true, + ); + + void relayoutWith({int maxLines, bool softWrap, TextOverflow overflow}) { + paragraph + ..maxLines = maxLines + ..softWrap = softWrap + ..overflow = overflow; + pumpFrame(); + } + + // Lay out in a narrow box to force wrapping. + layout(paragraph, constraints: new BoxConstraints(maxWidth: 50.0)); + double lineHeight = paragraph.size.height; + + relayoutWith(maxLines: 3, softWrap: true, overflow: TextOverflow.clip); + expect(paragraph.size.height, equals(3 * lineHeight)); + + relayoutWith(maxLines: null, softWrap: true, overflow: TextOverflow.clip); + expect(paragraph.size.height, greaterThan(5 * lineHeight)); + + // Try again with ellipsis overflow. We can't test that the ellipsis are + // drawn, but we can test the sizing. + relayoutWith(maxLines: 1, softWrap: true, overflow: TextOverflow.ellipsis); + expect(paragraph.size.height, equals(lineHeight)); + + relayoutWith(maxLines: 3, softWrap: true, overflow: TextOverflow.ellipsis); + expect(paragraph.size.height, equals(3 * lineHeight)); + + // This is the one weird case. If maxLines is null, we would expect to allow + // infinite wrapping. However, if we did, we'd never know when to append an + // ellipsis, so this really means "append ellipsis as soon as we exceed the + // width". + relayoutWith(maxLines: null, softWrap: true, overflow: TextOverflow.ellipsis); + expect(paragraph.size.height, equals(2 * lineHeight)); + + // Now with no soft wrapping. + relayoutWith(maxLines: 1, softWrap: false, overflow: TextOverflow.clip); + expect(paragraph.size.height, equals(lineHeight)); + + relayoutWith(maxLines: 3, softWrap: false, overflow: TextOverflow.clip); + expect(paragraph.size.height, equals(2 * lineHeight)); + + relayoutWith(maxLines: null, softWrap: false, overflow: TextOverflow.clip); + expect(paragraph.size.height, equals(2 * lineHeight)); + + relayoutWith(maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis); + expect(paragraph.size.height, equals(lineHeight)); + + relayoutWith(maxLines: 3, softWrap: false, overflow: TextOverflow.ellipsis); + expect(paragraph.size.height, equals(2 * lineHeight)); + + relayoutWith(maxLines: null, softWrap: false, overflow: TextOverflow.ellipsis); + expect(paragraph.size.height, equals(2 * lineHeight)); + }); }