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
This commit is contained in:
Matt Perry 2017-01-18 12:34:34 -05:00 committed by GitHub
parent 60847a1ded
commit 75f39789c7
9 changed files with 181 additions and 43 deletions

View file

@ -1 +1 @@
b3ed79122edd7172327ce415688ef674d6a7fa5d
2efc78cc24eac9439a5315ed9333fa8599aab3a1

View file

@ -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();

View file

@ -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,
);
}

View file

@ -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 ?

View file

@ -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(
<Point>[new Point(fadeStart, 0.0), new Point(fadeEnd, 0.0)],
<Color>[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(
<Point>[new Point(fadeStart, 0.0), new Point(fadeEnd, 0.0)],
<Color>[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(
<Point>[new Point(0.0, fadeStart), new Point(0.0, fadeEnd)],
<Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)]
);
}
break;
}
} else {

View file

@ -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;
}
}

View file

@ -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

View file

@ -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)');
});
}

View file

@ -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));
});
}