Updated
この問題はFlutter 3.10で治ったようです。以下は Flutter 3.7以前の古い内容となります。
背景
Flutter標準のTextは、オーバーフロー処理がバグっています。
どうバグっているかというと、テキストボックスの幅より手前でオーバーフロー処理が行われてしまうことがあるのです。
例えば FLUTTER OVERFLOW-TEST
という文字列を表示したときに、単語の区切りのところで ...
が付いてしまいます。
理想はこうです。
このバグはこちらのIssueで数年前に報告されていますが、未だに解決の様子がありません。
簡易対策
とりあえず、標準のTextをラップして、簡易対策するコードを書いてみました。
小1時間で作ったものなので、以下の制限がありますが、一応うごいていると思います。
- 1行固定(softwarp, maxLinesは指定できません)
- オーバーフロー処理はTextOverflow.ellipsisで固定
- 入力テキストにUnicode結合文字が含まれるとおかしな動作になる(末尾の文字が結合文字にならない)可能性あり。
なお、内部ではざっくりあたりを付けた文字位置から、文字を1文字づつ増減して TextPainter でレイアウトしながらボックスに収まるかを計算しているのでパフォーマンスは良くないです。
ちゃんとやるなら Widget のレイヤーではなくて、TextPainterのレイヤーで対策するのだと思いますが、やるとたぶんけっこう時間が掛かる気がするので、とりあえず簡易対策ということで。
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;
}
}
}