组件

封装好的组件,可以直接替代 Text 使用,LRUCache 是一种更高效的缓存结构,具体实现可以参考 Dart 实现 LRUCache

class EllipsisText extends StatelessWidget {
  final String text;
  final TextStyle? style;
  final String ellipsis;
  final int? maxLines;

  static final _cache = LRUCache<int, String>(maxSize: 1000);

  /// 当字体相关属性发生变化时,需要清空缓存
  static void clearCache() {
    _cache.clear();
  }

  const EllipsisText(
    this.text, {
    super.key,
    this.style,
    this.ellipsis = '...',
    this.maxLines,
  });

  int _getCacheKey(
    String text,
    String ellipsis,
    double maxWidth,
    int? maxLines,
    TextStyle? style,
    TextScaler textScaler,
  ) {
    return Object.hash(
      text,
      ellipsis,
      maxWidth.toStringAsFixed(2),
      maxLines,
      style,
      textScaler,
    );
  }

  double _calculateTextWidth(String text, TextScaler textScaler) {
    final span = TextSpan(text: text.fixAutoLines(), style: style);
    final tp = TextPainter(
      text: span,
      maxLines: 1,
      textDirection: TextDirection.ltr,
      textScaler: textScaler,
    )..layout();
    return tp.width;
  }

  TextPainter _createTextPainter(
    String displayText,
    double maxWidth,
    TextScaler textScaler,
  ) {
    return TextPainter(
      text: TextSpan(text: displayText.fixAutoLines(), style: style),
      maxLines: maxLines,
      textDirection: TextDirection.ltr,
      textScaler: textScaler,
    )..layout(maxWidth: maxWidth);
  }

  @override
  Widget build(BuildContext context) {
    final textScaler = MediaQuery.textScalerOf(context);

    return LayoutBuilder(builder: (context, constraints) {
      final cacheKey = _getCacheKey(
        text,
        ellipsis,
        constraints.maxWidth,
        maxLines,
        style,
        textScaler,
      );
      if (text.isEmpty) {
        return const SizedBox.shrink();
      }
      final cachedResult = _cache.get(cacheKey);
      if (cachedResult != null) {
        return Text(
          cachedResult,
          maxLines: maxLines,
          overflow: TextOverflow.ellipsis,
          style: style,
          textScaler: textScaler,
        );
      }

      if (!_createTextPainter(
        text,
        constraints.maxWidth,
        textScaler,
      ).didExceedMaxLines) {
        _cache.put(cacheKey, text.fixAutoLines());
        return Text(
          text.fixAutoLines(),
          maxLines: maxLines,
          overflow: TextOverflow.ellipsis,
          style: style,
          textScaler: textScaler,
        );
      }

      int leftIndex = 0;
      int rightIndex = text.characters.length;
      double leftWidth = 0;
      double rightWidth = 0;

      int lastValidLeftIndex = 0;
      int lastValidRightIndex = text.characters.length;

      while (leftIndex < rightIndex) {
        final nextLeftWidth = _calculateTextWidth(
                text.characters.elementAt(leftIndex), textScaler) +
            leftWidth;
        final nextRightWidth = _calculateTextWidth(
                text.characters.elementAt(rightIndex - 1), textScaler) +
            rightWidth;
        final currentText =
            '${text.charSubstring(0, leftIndex)}$ellipsis${text.charSubstring(rightIndex)}';

        if (_createTextPainter(
          currentText,
          constraints.maxWidth,
          textScaler,
        ).didExceedMaxLines) {
          break;
        } else {
          lastValidLeftIndex = leftIndex;
          lastValidRightIndex = rightIndex;
          if (leftWidth <= rightWidth) {
            leftWidth = nextLeftWidth;
            leftIndex++;
          } else {
            rightWidth = nextRightWidth;
            rightIndex--;
          }
        }
      }

      final truncatedText =
          '${text.charSubstring(0, lastValidLeftIndex)}$ellipsis${text.charSubstring(lastValidRightIndex)}';

      _cache.put(cacheKey, truncatedText.fixAutoLines());

      return Text(
        truncatedText.fixAutoLines(),
        maxLines: maxLines,
        style: style,
        textScaler: textScaler,
      );
    });
  }
}

extension StringExt on String {
  String fixAutoLines() => characters.join('\u{200B}');

  String charSubstring(int start, [int? end]) =>
      characters.getRange(start, end).join();

  String removeLineBreaks() => replaceAll(RegExp(r'[\r\n]+'), '');
}

效果图

实现思路

由于我们平时所用的字体基本都不是等宽字体,所以想要文本在中间省略就不能单纯的通过计算字符的数量来判断,因为我们需要在实际渲染宽度的中间省略。

TextPainter

好在 Flutter 为我们提供了一个可以高效计算文本渲染相关的 Object,通过使用 TextPainter,我们可以在不实际渲染文本的情况下获取文本的信息。

基本原理

对于多行文本来说,我们首先需要判断能不能放得下,如果没有超出所需要的行数,直接返回即可,这样可以减少计算的开销。

TextPainter 提供了一个 didExceedMaxLines 方法来知道有没有超出最大行数。

双指针

知道基本原理之后实现起来就比较容易了,我们可以选择使用一个比较简单的双指针,分别从文字开头和文字末尾往中间收缩。为了保证省略两侧宽度相等,当左侧宽度较小时,右移左指针,当右侧宽度较小时,左移右指针。

实现细节

Text 组件按字符换行

可能你会注意到上面的方法中使用了一个扩展,这个扩展的意义是,Flutter 在 Text 组件中,默认会按照词换行,也就是说 Flutter 总是希望一个单词能够完整的显示,如果是这样的话,可能会出现空行的情况,因为并不是所有的单词都能够刚好占满一整行。如果我们需要实现字符换行,一种比较简易的方法是在文本中插入零宽字符,这种字符不会显示出来,但是会破坏字符串原本的完整性,很多隐形水印也是通过零宽字符来实现的。

字符串中的 Characters

为了考虑更多的情况,比如带有 emoji 的字符串,我们就不能通过 String 中的方法对字符串进行操作了,因为编码格式的原因,对带有 emoji 的字符串进行 substringlength 时,可能会得到预期之外的值,甚至导致异常。所以对于比较通用的组件,在可预见的未来中无法知道会不会出现 emoji 的情况,我们就需要通过 Characters 的方式对字符串进行操作,虽然这样可能会带来额外的开销。有关字符串编码的更多内容可以查看 Emoji 与 Unicode 编码