I’m pretty sure that your app contains a lot of text: titles, descriptions, hints, etc. And not all of those texts are necessary for the user to see. So, sometimes, you want to hide part of them. A short description of a movie provides enough information for a user to decide if he wants to read more. And the common pattern is to truncate this text, maybe ellipsize it, to allow the user to expand it in some way (e.g. tapping on the whole text or on the ellipsis).
我非常确定您的应用程序包含很多文本:标题,描述,提示等。而且,并非所有这些文本对于用户来说都是必需的。 因此,有时您想隐藏其中的一部分。 电影的简短描述为用户提供了足够的信息,以决定他是否想要阅读更多内容。 常见的模式是截断此文本,或者将其椭圆化,以允许用户以某种方式扩展它(例如,轻敲整个文本或省略号)。
If you want to crop the text and allow the user to expand it with a tap — there is a very simple solution. We just need to createStatefulWidget
with a boolean property indicating that the text is expanded (e.g. isExpanded
). Then we need to wrap the Text
widget with GestureDetector
and invert isExpanded
property in its onTap
callback. The overflow
property of Text
defines the way the text is cropped. Also, we define maxLines
property depending on the current state.
如果您想裁剪文本并允许用户轻按以展开文本-有一个非常简单的解决方案。 我们只需要创建一个具有布尔属性的StatefulWidget
,该布尔属性指示文本已展开(例如isExpanded
)。 然后,我们需要使用GestureDetector
包装Text
小部件,并在其onTap
回调中反转isExpanded
属性。 Text
的overflow
属性定义了裁剪Text
的方式。 另外,我们根据当前状态定义maxLines
属性。
class ExpandableText extends StatefulWidget {
final String text;
final int maxLines;
const ExpandableText({Key key, this.maxLines, this.text}) : super(key: key);
@override
_ExpandableTextState createState() => _ExpandableTextState();
}
class _ExpandableTextState extends State<ExpandableText> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: () {
setState(() {
_isExpanded = !_isExpanded;
});
},
child: Text(
widget.text,
overflow: TextOverflow.ellipsis,
maxLines: _isExpanded ? null : widget.maxLines,
),
);
}
Ok, it was easy. Default implementation of Text
offers only 4 options on overflow:
好吧,这很容易。 Text
默认实现在溢出时仅提供4个选项:
TextOverflow.clip
crops overflowing text;TextOverflow.clip
溢出的文本;TextOverflow.fade
fades the end of the last line to transparent;TextOverflow.fade
将最后一行的结尾淡化为透明;TextOverflow.ellipsis
uses ellipsis to indicate that the text has overflowed;TextOverflow.ellipsis
使用省略号表示文本已溢出;TextOverflow.visible
renders overflowing text outside of its container.TextOverflow.visible
呈现容器外的溢出文本。
But what if we need to customize the ellipsis?
但是,如果我们需要自定义省略号怎么办?
![Image for post](https://miro.medium.com/max/9999/1*exxJVE0Vyy0mEjedfkZO2Q.jpeg)
TextPainter
(Here comes TextPainter
)
TextPainter
allows you to draw your text right on Canvas
, and it provides you with this Canvas
and Size
in paint()
method. Also, TextPainter
has ellipsis
property, that allows you to override the string which will be substituted at the end of the cropped text. TextPainter
allows us to set ellipsis and define the maximum number of lines. But it needs CustomPaint
to render. And CustomPaint
works in pair with CustomPainter
, so we need to wrap use the TextPainter
inside of CustomPainter
. Note that you also must set textDirection
to let TextPainter
know where the line’s end is.
TextPainter
允许您在Canvas
上直接绘制文本,并在paint()
方法中提供此Canvas
和Size
。 另外, TextPainter
具有ellipsis
属性,该属性使您可以覆盖将在裁剪的文本末尾替换的字符串。 TextPainter
允许我们设置省略号并定义最大行数。 但是它需要CustomPaint
进行渲染。 而且CustomPaint
与CustomPainter
配合使用,因此我们需要在CustomPainter
包装TextPainter
。 请注意,您还必须设置textDirection
以使TextPainter
知道行的结尾在哪里。
class MyTextPainter extends CustomPainter {
final TextSpan text;
final int maxLines;
final String ellipsis;
MyTextPainter({this.text, this.ellipsis, this.maxLines}) : super();
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
@override
void paint(Canvas canvas, Size size) {
TextPainter painter = TextPainter(
text: text,
maxLines: maxLines,
textDirection: TextDirection.ltr,
)..ellipsis = this.ellipsis;
painter.layout(maxWidth: size.width);
painter.paint(canvas, Offset(0, 0));
}
}
class HomePage extends StatelessWidget {
HomePage({Key key}) : super(key: key);
var someVeryLongText = // Some realy long text
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: Container(
child: CustomPaint(
size: Size(double.infinity, 300),
painter: MyTextPainter(
text: TextSpan(text: someVeryLongText, style: TextStyle(color: Colors.black54)),
ellipsis: "... more",
maxLines: 4,
),
),
),
),
);
}
And voilà, there’s the custom ellipsis!
还有,自定义省略号!
![Image for post](https://miro.medium.com/max/9999/1*Rsm5qLSWU1Pmql0DRa9N_Q.png)
But this solution is not very flexible: “more” is just the part of the text. Also, you could notice that CustomPaint
requires the size of the area for painting. How to calculate it? We’ll find it out a bit later.
但是这种解决方案不是很灵活:“更多”只是本文的一部分。 此外,您可能会注意到CustomPaint
需要绘画区域的大小。 如何计算呢? 我们稍后会发现。
![Image for post](https://miro.medium.com/max/9999/1*ePflO3HHgIfZ2Gff8fI8Mw.jpeg)
一些带线的数学 (Some maths with lines)
But my goal was to implement an expandable text widget that ellipsizes the text based on the “maximum lines” attribute. It also must be expanded by a tap on the word “more” appended after the ellipsis. Also, I want this “more” word to be styled differently from the main text.
但是我的目标是实现一个可扩展的文本窗口小部件,该窗口小部件根据“最大行数”属性将文本椭圆化。 还必须通过点击省略号后面的“更多”一词来扩展它。 另外,我希望这个“更多”的词与正文的样式有所不同。
Different styles, tappable parts… Sounds like we need RichText
. But it can ellipsize text just like the regular Text
do (actually, Text
renders RichText
under the hood). So, if it had many spans in it, all of them would be cropped.
不同的样式,可轻敲的零件……听起来我们需要RichText
。 但是它可以像常规Text
一样对Text
进行椭圆化处理(实际上, Text
在后台显示RichText
)。 因此,如果其中有很多跨度,所有的跨度都会被裁剪。
Ok, let’s get to it.
好吧,让我们开始吧。
First of all, we need to know how the text is gonna be rendered. Unfortunately, I couldn’t figure out how text would render line by line, but we can get rectangles describing bounds of these lines. RenderObject
serves this purpose. It defines the base layout model. Hence we can get bounds of the text after layout. But we need Constraints
to perform layout, so let’s wrap it in LayoutBuilder
. Since text can either be expanded or stay the same, the widget must hold the state.
首先,我们需要知道如何渲染文本。 不幸的是,我无法弄清楚文本将如何逐行渲染,但是我们可以得到描述这些线条边界的矩形。 RenderObject
用于此目的。 它定义了基本布局模型。 因此,我们可以在布局后获得文本的边界。 但是我们需要Constraints
来执行布局,因此让我们将其包装在LayoutBuilder
。 由于文本可以扩展或保持不变,因此小部件必须保持状态。
class ExpandableText extends StatefulWidget {
final TextSpan textSpan;
final TextSpan moreSpan;
final int maxLines;
const ExpandableText({
Key key,
this.textSpan,
this.maxLines,
this.moreSpan,
})
: assert(textSpan != null),
assert(maxLines != null),
assert(moreSpan != null),
super(key: key);
@override
_ExpandableTextState createState() => _ExpandableTextState();
}
class _ExpandableTextState extends State<ExpandableText> {
static const String _ellipsis = "\u2026\u0020"; // Unicode symbols for "… "
bool _isExpanded = false;
GestureRecognizer get _tapRecognizer => TapGestureRecognizer()
..onTap = () {
setState(() {
_isExpanded = !_isExpanded;
});
};
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
// TODO: we'll do some maths here
},
);
}
Now we can get the RenderParapgraph
( RenderObject
that displays paragraph of text), layout it and get bounds of each line with getBoxesForSelection
method.
现在,我们可以获取RenderParapgraph
(显示文本段落的RenderObject
),对其进行布局并使用getBoxesForSelection
方法获取每一行的边界。
extension _TextMeasurer on RichText {
List<TextBox> measure(BuildContext context, Constraints constraints) {
final renderObject = createRenderObject(context)..layout(constraints);
return renderObject.getBoxesForSelection(
TextSelection(
baseOffset: 0,
extentOffset: text.toPlainText().length,
),
);
}
}
class _ExpandableTextState extends State<ExpandableText> {
// ...
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final maxLines = widget.maxLines;
final richText = Text.rich(widget.textSpan).build(context) as RichText;
final boxes = richText.measure(context, constraints);
if (boxes.length <= maxLines || _isExpanded) {
return RichText(text: widget.textSpan);
} else {
// TODO: deal with ellipsized text
}
},
);
}
If the number of lines is less than the maximum number or if the widget is in the expanded state we can just render our text as-is.
如果行数少于最大行数,或者窗口小部件处于展开状态,则可以按原样呈现文本。
Otherwise, let’s do some simple maths. We cannot get the content of each line. But we know the length of each line in pixels. So we can:
否则,让我们做一些简单的数学运算。 我们无法获取每一行的内容。 但是我们知道每行的长度(以像素为单位)。 所以我们可以:
- Calculate the total length of the maximum number of lines; 计算总长度的最大行数;
- Calculate the total length of all the lines; 计算所有行的总长度;
- Count the ratio of these values; 计算这些值的比率;
- Take the approximate length of cropped string, by multiplying the length of the full string on this ratio; 通过将整个琴弦的长度乘以该比例,得出裁剪琴弦的近似长度;
- Take substring of the length that we’ve got. 取我们已有长度的子字符串。
Here’s the relevant code:
以下是相关代码:
class _ExpandableTextState extends State<ExpandableText> {
static const String _ellipsis = "\u2026\u0020";
String get _lineEnding => "$_ellipsis${widget.moreSpan.text}";
// ...
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final maxLines = widget.maxLines;
final richText = Text.rich(widget.textSpan).build(context) as RichText;
final boxes = richText.measure(context, constraints);
if (boxes.length <= maxLines || _isExpanded) {
return RichText(text: widget.textSpan);
} else {
final croppedText = _ellipsizeText(boxes);
final ellipsizedText = _buildEllipsizedText(croppedText, _tapRecognizer);
if (ellipsizedText.measure(context, constraints).length <= maxLines) {
return ellipsizedText;
} else {
final fixedEllipsizedText = croppedText.substring(0, croppedText.length - _lineEnding.length);
return _buildEllipsizedText(fixedEllipsizedText, _tapRecognizer);
}
}
},
);
String _ellipsizeText(List<TextBox> boxes) {
final text = widget.textSpan.text;
final maxLines = widget.maxLines;
double _calculateLinesLength(List<TextBox> boxes) => boxes.map((box) => box.right - box.left).reduce((acc, value) => acc += value);
final requiredLength = _calculateLinesLength(boxes.sublist(0, maxLines));
final totalLength = _calculateLinesLength(boxes);
final requiredTextFraction = requiredLength / totalLength;
return text.substring(0, (text.length * requiredTextFraction).floor());
}
RichText _buildEllipsizedText(String text, GestureRecognizer tapRecognizer) => RichText(
text: TextSpan(
text: "$text$_ellipsis",
style: widget.textSpan.style,
children: [widget.moreSpan],
),
);
}
All we need to do now is to create TextSpan
with this new string and desired style and append ellipsis and “more” word with the other style. But the resulting string may be longer than needed, and in that case just layout it again and check the number of lines. If it’s greater than the desired maximum number of lines… Ok, let’s just crop the substring of length ellipsis + "more"
from the end of the truncated string. That’s all. No more layouts. Now we can prepare the final RichText
.
现在,我们要做的就是用这个新的字符串和所需的样式创建TextSpan
,并用其他样式附加省略号和“更多”字词。 但是结果字符串可能会比所需的更长,在这种情况下,只需重新布局并检查行数即可。 如果它大于所需的最大行数...好吧,让我们从截断字符串的末尾截去长度ellipsis + "more"
的子字符串。 就这样。 没有更多的布局。 现在我们可以准备最终的RichText
。
![Image for post](https://miro.medium.com/freeze/max/9999/1*kyfHasliPNuWqViyQevsfg.gif)
Not too accurate (I mean, the “More” word is not always at the very end of the last line), but you can be sure that a user will see the exact number of lines.
不太准确(我的意思是,“更多”一词并不总是在最后一行的末尾),但是您可以确定用户会看到确切的行数。
翻译自: https://medium.com/@VasyaFromRussia/expanding-text-in-flutter-92736226ace5