在颤动中扩展文本

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属性。 Textoverflow属性定义了裁剪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个选项:

  1. TextOverflow.clip crops overflowing text;

    TextOverflow.clip溢出的文本;

  2. TextOverflow.fade fades the end of the last line to transparent;

    TextOverflow.fade将最后一行的结尾淡化为透明;

  3. TextOverflow.ellipsis uses ellipsis to indicate that the text has overflowed;

    TextOverflow.ellipsis使用省略号表示文本已溢出;

  4. TextOverflow.visible renders overflowing text outside of its container.

    TextOverflow.visible呈现容器外的溢出文本。

But what if we need to customize the ellipsis?

但是,如果我们需要自定义省略号怎么办?

Image for post

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()方法中提供此CanvasSize 。 另外, TextPainter具有ellipsis属性,该属性使您可以覆盖将在裁剪的文本末尾替换的字符串。 TextPainter允许我们设置省略号并定义最大行数。 但是它需要CustomPaint进行渲染。 而且CustomPaintCustomPainter配合使用,因此我们需要在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

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

一些带线的数学 (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. RenderObjectserves 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:

否则,让我们做一些简单的数学运算。 我们无法获取每一行的内容。 但是我们知道每行的长度(以像素为单位)。 所以我们可以:

  1. Calculate the total length of the maximum number of lines;

    计算总长度的最大行数;
  2. Calculate the total length of all the lines;

    计算所有行的总长度;
  3. Count the ratio of these values;

    计算这些值的比率;
  4. Take the approximate length of cropped string, by multiplying the length of the full string on this ratio;

    通过将整个琴弦的长度乘以该比例,得出裁剪琴弦的近似长度;
  5. 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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值