Expanding text in Flutter
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.
Ok, it was easy. Default implementation of Text
offers only 4 options on overflow:
TextOverflow.clip
crops overflowing text;TextOverflow.fade
fades the end of the last line to transparent;TextOverflow.ellipsis
uses ellipsis to indicate that the text has overflowed;TextOverflow.visible
renders overflowing text outside of its container.
But what if we need to customize the ellipsis?
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.
And voilà, there’s the custom ellipsis!
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.
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.
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.
Now we can get the RenderParapgraph
( RenderObject
that displays paragraph of text), layout it and get bounds of each line with getBoxesForSelection
method.
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:
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
.
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.