FlutterのTextOverflow.ellipsisがうまく効かない問題に簡易対策する

# Updated

この問題は[Flutter 3.10で治ったようです](https://github.com/flutter/flutter/issues/18761#issuecomment-1513592402)。以下は Flutter 3.7以前の古い内容となります。

# 背景
Flutter標準のTextは、オーバーフロー処理がバグっています。
どうバグっているかというと、テキストボックスの幅より手前でオーバーフロー処理が行われてしまうことがあるのです。

例えば `FLUTTER OVERFLOW-TEST` という文字列を表示したときに、単語の区切りのところで `...` が付いてしまいます。

![Screen Shot 2022-01-18 at 5.40.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43781/a7476307-38ba-cd42-bee4-ab1f4845cd59.png)

理想はこうです。

![Screen Shot 2022-01-18 at 5.39.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43781/4772f53c-3bee-956d-772c-673fd282fe62.png)

このバグは[こちらのIssue](https://github.com/flutter/flutter/issues/18761)で数年前に報告されていますが、未だに解決の様子がありません。

# 簡易対策
とりあえず、標準のTextをラップして、簡易対策するコードを書いてみました。
小１時間で作ったものなので、以下の制限がありますが、一応うごいていると思います。

- 1行固定（softwarp, maxLinesは指定できません）
- オーバーフロー処理はTextOverflow.ellipsisで固定
- 入力テキストに[Unicode結合文字](https://ja.wikipedia.org/wiki/%E7%B5%90%E5%90%88%E6%96%87%E5%AD%97)が含まれるとおかしな動作になる（末尾の文字が結合文字にならない）可能性あり。
 
なお、内部ではざっくりあたりを付けた文字位置から、文字を1文字づつ増減して TextPainter でレイアウトしながらボックスに収まるかを計算しているのでパフォーマンスは良くないです。

ちゃんとやるなら Widget のレイヤーではなくて、TextPainterのレイヤーで対策するのだと思いますが、やるとたぶんけっこう時間が掛かる気がするので、とりあえず簡易対策ということで。

```dart

class SingleLineText extends StatefulWidget {
  const SingleLineText(
    this.data, {
    Key? key,
    this.style,
    this.strutStyle,
    this.textAlign,
    this.textDirection,
    this.locale,
    this.textScaleFactor,
    this.ellipsisText,
    this.semanticsLabel,
    this.textWidthBasis,
    this.textHeightBehavior = const TextHeightBehavior(
      leadingDistribution: TextLeadingDistribution.even,
    ),
  }) : super(key: key);

  final String data;
  final TextStyle? style;
  final StrutStyle? strutStyle;
  final TextAlign? textAlign;
  final TextDirection? textDirection;
  final Locale? locale;
  final double? textScaleFactor;
  final String? semanticsLabel;
  final TextWidthBasis? textWidthBasis;
  final TextHeightBehavior? textHeightBehavior;
  final String? ellipsisText;

  @override
  createState() => SingleLineTextState();
}

class SingleLineTextState extends State<SingleLineText> {
  String? _textCache;

  @override
  void didUpdateWidget(covariant SingleLineText oldWidget) {
    if (widget.data != oldWidget.data) {
      _textCache = null;
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (context, constraints) {
      // 初回 build 時に計算して結果をキャッシュする
      _textCache ??= TextTruncator(
        widget.style ?? DefaultTextStyle.of(context).style,
        widget.textDirection ?? Directionality.maybeOf(context),
        ellipsisText: widget.ellipsisText ?? '...',
      ).truncateTextForWidth(widget.data, constraints.maxWidth);
      return Text(
        _textCache!,
        style: widget.style,
        strutStyle: widget.strutStyle,
        textAlign: widget.textAlign,
        textDirection: widget.textDirection,
        locale: widget.locale,
        softWrap: false,
        overflow: TextOverflow.visible,
        textScaleFactor: widget.textScaleFactor,
        maxLines: 1,
        semanticsLabel: widget.semanticsLabel,
        textWidthBasis: widget.textWidthBasis,
        textHeightBehavior: widget.textHeightBehavior,
      );
    });
  }
}

class TextTruncator {
  const TextTruncator(this.style, this.textDirection, {this.ellipsisText = '...'});

  final TextStyle style;
  final TextDirection? textDirection;
  final String ellipsisText;

  /// [text]をレンダリングした時の幅を調べます
  double _getTextWidth(String text) {
    final painter = TextPainter(
      maxLines: 1,
      textDirection: textDirection ?? TextDirection.ltr,
      text: TextSpan(text: text, style: style),
    );
    painter.layout();
    return painter.width;
  }

  /// [text]の先頭から長さ[length]切り出したときのレンダリング幅を調べます
  double _getSubstringWidth(String text, int length) {
    return _getTextWidth(text.substring(0, length));
  }

  /// [text]が指定された[maxWidth]に収まるように文字列を切り捨てます。
  String truncateTextForWidth(String text, double maxWidth) {
    if (maxWidth == double.infinity || maxWidth < 0) {
      return text;
    }

    final width = _getTextWidth(text);
    if (width <= maxWidth) {
      return text;
    }

    /// テキストボックスに収まらない場合は maxWidth から ellipsisText 分減らした幅を目標とします。
    final limit = maxWidth - _getTextWidth(ellipsisText);

    /// ellipsisText すら収まらない場合は諦めて空文字列を返します。
    if (limit <= 0) {
      return '';
    }

    /// まず maxWidth に収まりそうなだいたいのテキスト長に検討をつけてレンダリング幅を計測します。
    int pos = max(1, (text.length * (maxWidth / width)).toInt());
    final measured = _getSubstringWidth(text, pos);

    if (measured > limit) {
      // レンダリング幅の方がlimitより大きい場合、
      // 1文字づつ減らしながら、レンダリング幅がlimit以下になる場所を探します。
      while (pos > 0) {
        pos--;
        if (_getSubstringWidth(text, pos) <= limit) break;
      }
      return text.substring(0, pos) + ellipsisText;
    } else {
      // レンダリング幅のがlimit以下の場合
      // 1文字づつ増やしながら、レンダリング幅がlimit以上になる場所を探します。
      while (pos < text.length) {
        pos++;
        if (_getSubstringWidth(text, pos) > limit) break;
      }
      return text.substring(0, pos - 1) + ellipsisText;
    }
  }
}

```