【Flutter】TextPainter で安全に、長文のテキストをアニメーションで隠したり展開させる
環境
# Flutter
3.19.5
完成系
展開の挙動 | 展開の必要がない場合 |
---|---|
以下、コピペで使用可能なソースコードです。
完成ソースコード
import 'package:flutter/material.dart';
/// 「もっと見る」ボタンで隠せるテキスト
///
/// そもそもの表示が `minimumLines` を満たない場合、「もっと見る」ボタンは表示されない
class ReadMoreText extends StatefulWidget {
const ReadMoreText(
this.text, {
super.key,
this.style,
required this.minimumLines,
required this.overlayColor,
this.duration = const Duration(milliseconds: 240),
this.textHeightBehavior,
this.textWidthBasis = TextWidthBasis.parent,
this.strutStyle,
this.textDirection,
this.textScaler,
this.textAlign,
this.semanticsLabel,
this.selectionColor,
this.locale,
}) : assert(minimumLines > 0);
final String text;
final TextStyle? style;
final Duration duration;
final Color overlayColor;
final int minimumLines;
final TextHeightBehavior? textHeightBehavior;
final TextWidthBasis textWidthBasis;
final StrutStyle? strutStyle;
final TextDirection? textDirection;
final TextScaler? textScaler;
final TextAlign? textAlign;
final String? semanticsLabel;
final Color? selectionColor;
final Locale? locale;
ReadMoreTextState createState() => ReadMoreTextState();
}
class ReadMoreTextState extends State<ReadMoreText> {
final _toggleableKey = GlobalKey<_ToggleableState>();
void toggle() {
_toggleableKey.currentState!._toggle();
}
void expand() {
_toggleableKey.currentState!._expand();
}
void collapse() {
_toggleableKey.currentState!._collapse();
}
Widget build(BuildContext context) {
var effectiveTextStyle = widget.style;
if (effectiveTextStyle == null || widget.style!.inherit) {
effectiveTextStyle =
DefaultTextStyle.of(context).style.merge(widget.style);
}
if (MediaQuery.boldTextOf(context)) {
effectiveTextStyle = effectiveTextStyle
.merge(const TextStyle(fontWeight: FontWeight.bold));
}
return LayoutBuilder(
builder: (context, constraints) {
final textPainter = TextPainter(
text: TextSpan(
text: widget.text,
style: effectiveTextStyle,
),
textAlign: widget.textAlign ?? TextAlign.start,
textDirection: widget.textDirection ?? Directionality.of(context),
textScaler: widget.textScaler ?? MediaQuery.textScalerOf(context),
locale: widget.locale,
textHeightBehavior: widget.textHeightBehavior,
textWidthBasis: widget.textWidthBasis,
strutStyle: widget.strutStyle,
)..layout(
minWidth: constraints.minWidth,
maxWidth: constraints.maxWidth,
);
final child = Text.rich(
textPainter.text!,
overflow: TextOverflow.visible,
semanticsLabel: widget.semanticsLabel,
selectionColor: widget.selectionColor,
textHeightBehavior: textPainter.textHeightBehavior,
textWidthBasis: textPainter.textWidthBasis,
strutStyle: textPainter.strutStyle,
textDirection: textPainter.textDirection,
textScaler: textPainter.textScaler,
textAlign: textPainter.textAlign,
locale: widget.locale,
);
final lines = textPainter.computeLineMetrics();
// 文字が minimumLines に満たない場合、「もっと見る」ボタンは非表示にする
if (lines.length <= widget.minimumLines) {
textPainter.dispose();
return child;
}
// すべてのテキストを表示した時の高さ
final maximumHeight = textPainter.height;
// 隠している時の高さ
final minimumHeight = lines.take(widget.minimumLines).fold(
0.0,
(previousValue, element) => previousValue + element.height,
);
textPainter.dispose();
return _Toggleable(
key: _toggleableKey,
maximumHeight: maximumHeight,
minimumHeight: minimumHeight,
overlayColor: widget.overlayColor,
minimumLines: widget.minimumLines,
duration: widget.duration,
child: child,
);
},
);
}
}
class _Toggleable extends StatefulWidget {
const _Toggleable({
super.key,
required this.maximumHeight,
required this.minimumHeight,
required this.child,
required this.overlayColor,
required this.minimumLines,
required this.duration,
});
final Widget child;
final double maximumHeight;
final double minimumHeight;
final Color overlayColor;
final int minimumLines;
final Duration duration;
State<_Toggleable> createState() => _ToggleableState();
}
class _ToggleableState extends State<_Toggleable>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<double> _heightFactor;
late final Animation<double> _iconRotation;
late final Animation<double> _overlayOpacity;
static final _easeTween = CurveTween(curve: Curves.easeIn);
void _toggle() {
if (_animationController.isCompleted) {
_collapse();
} else {
_expand();
}
}
void _expand() {
_animationController.forward();
}
void _collapse() {
_animationController.reverse();
}
void initState() {
_animationController = AnimationController(
vsync: this,
duration: widget.duration,
);
final heightFactorBegin = widget.minimumHeight / widget.maximumHeight;
_heightFactor = Tween(begin: heightFactorBegin, end: 1.0)
.chain(_easeTween)
.animate(_animationController);
_iconRotation = Tween(begin: 0.0, end: 0.5)
.chain(_easeTween)
.animate(_animationController);
_overlayOpacity = Tween(begin: 0.0, end: 1.0)
.chain(_easeTween)
.animate(_animationController);
super.initState();
}
void dispose() {
_animationController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Column(
children: [
AnimatedBuilder(
animation: _overlayOpacity,
builder: (context, child) {
return ShaderMask(
shaderCallback: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
widget.overlayColor,
widget.overlayColor.withOpacity(_overlayOpacity.value),
],
).createShader,
child: child,
);
},
child: SizeTransition(
sizeFactor: _heightFactor,
axisAlignment: -1,
child: widget.child,
),
),
const SizedBox(height: 16),
// 「もっと見る」ボタン
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: _toggle,
icon: RotationTransition(
turns: _iconRotation,
child: const Icon(Icons.keyboard_arrow_down),
),
label: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
final text = switch (_animationController.status) {
AnimationStatus.completed || AnimationStatus.forward => '閉じる',
_ => 'もっと見る',
};
return Text(text);
},
),
),
),
],
);
}
}
持っている機能
- デフォルトで
minimumLines
引数(必須)より下を隠す - 「もっと見る」ボタンを押すと、アニメーションで展開される
- 展開後、「もっと見る」ボタンは「閉じる」ボタンに変わる
- (補助機能:
GlobalKey
経由で、toggle
、expand
、collapse
の関数が使える)
解説
TextPainter
から行数の取得
TextPainter
とは、Text
(RichText
) ウィジェットの描画を担当する RenderObject
(RenderParagraph
)で使用されているクラスです。
Text
(RichText
) ウィジェットの描画のほぼ全てが入っているので、このクラスから、描画したときの高さや行数などが取得できます。
RichText
や Text.rich
のように、InlineSpan
を渡して利用します。
final textPainter = TextPainter(
text: TextSpan(
text: widget.text,
),
);
layout の実行
TextPainter
から、高さなどを取得するには、layout()
を実行してからでないといけないので、インスタンス生成後に実行します。
この際、minWidth
と maxWidth
を渡さないと、使いたいウィジェットのサイズによってうまく動作しなくなります。
LayoutBuilder
を使って取得しましょう。
class ReadMoreText extends StatelessWidget {
const ReadMoreText(
this.text, {
super.key,
});
final String text;
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final textPainter = TextPainter(
text: TextSpan(
text: text,
),
)..layout(
minWidth: constraints.minWidth,
maxWidth: constraints.maxWidth,
);
TextDirection の取得
もう一つ必須な設定値があります。それは TextDirection
です。
これは、Directionality.of
を使えば取得できるので、それ経由で設定しましょう。
MaterialApp
を使っていれば取得可能なので、問題なく使用できます。
TextDirection
を引数で切り替えることはあまりないですが、標準の Text
ウィジェットに倣って、一応引数で切り替えれるようにしておきましょう。
textDirection: textDirection ?? Directionality.of(context)
class ReadMoreText extends StatelessWidget {
const ReadMoreText(
this.text, {
super.key,
this.textDirection,
});
final String text;
final TextDirection? textDirection;
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final textPainter = TextPainter(
text: TextSpan(
text: text,
),
textDirection: textDirection ?? Directionality.of(context),
)..layout(
minWidth: constraints.minWidth,
maxWidth: constraints.maxWidth,
);
TextStyle
を自然な挙動にする
次は、TextStyle
を自然な挙動にする必要があります。
TextStyle
をオプショナルな引数にするだけしてしまうと、style が渡されていない時に、標準の Text
ウィジェットとは違う挙動になってしまい、フォントサイズがデフォルト値として定数で管理されている 14.0
になってしまいます。
ここは、Text
ウィジェットの TextStyle
の決定方法をまるパクりしちゃいましょう。
class Text extends StatelessWidget {
// ...
Widget build(BuildContext context) {
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
TextStyle? effectiveTextStyle = style;
if (style == null || style!.inherit) {
effectiveTextStyle = defaultTextStyle.style.merge(style);
}
if (MediaQuery.boldTextOf(context)) {
effectiveTextStyle = effectiveTextStyle!.merge(const TextStyle(fontWeight: FontWeight.bold));
}
Text
ウィジェットのデフォルトのスタイルは、以下の要素で決定づけられます。
-
DefaultTextStyle
というInheritedWidget
からスタイルを取得。- 引数として渡されたスタイルの
inherit
が true の場合は、DefaultTextStyle
のスタイルとマージされる。
- 引数として渡されたスタイルの
-
MediaQuery
から、端末設定の「文字を太くする」の切り替えオンオフ。
これで安全な TextStyle
設定ができましたが、正確な TextPainter
を利用するには、あとひとつ、TextScaler
の取得が必要です。
現状のソースコード
class ReadMoreText extends StatelessWidget {
const ReadMoreText(
this.text, {
super.key,
this.style,
this.textDirection,
});
final String text;
final TextStyle? style;
final TextDirection? textDirection;
Widget build(BuildContext context) {
// BoxConstraints の変更のたびに effectiveTextStyle を取り直す必要はないので、
// LayoutBuilder の上に処理を書く
var effectiveTextStyle = widget.style;
if (effectiveTextStyle == null || widget.style!.inherit) {
effectiveTextStyle =
DefaultTextStyle.of(context).style.merge(widget.style);
}
if (MediaQuery.boldTextOf(context)) {
effectiveTextStyle = effectiveTextStyle
.merge(const TextStyle(fontWeight: FontWeight.bold));
}
return LayoutBuilder(
builder: (context, constraints) {
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: effectiveTextStyle,
),
textDirection: textDirection ?? Directionality.of(context),
)..layout(
minWidth: constraints.minWidth,
maxWidth: constraints.maxWidth,
);
TextScaler
の取得
TextScaler
に関しては複雑な要素がなくて、普通に MediaQuery
から取得すれば問題ないです。
final textPainter = TextPainter(
// ...
textScaler: textScaler ?? MediaQuery.textScalerOf(context),
行数の取得
行に関する情報は、computeLineMetrics
関数で取得できます。
ここで返されるのは、LineMetrics
の配列で、LineMetrics
は「1行ごとの情報」なので、配列の要素数が直接行数になります。
final lines = textPainter.computeLineMetrics();
final numberOfLines = lines.length;
描画
描画には、Text
ウィジェットを使います。
final child = Text.rich(
// textPainter.layout を実行している時点で、text が null 出ないことが保証されているので、
// null チェックをスキップしている
textPainter.text!,
overflow: TextOverflow.visible,
textDirection: textPainter.textDirection,
textScaler: textPainter.textScaler,
);
「N行以下だったら隠さない/N行より多ければ隠す」を実現するために、それらを設定できるようにしておきましょう。
自分のアプリの構成によって、デフォルト値を設定したり、あるいは固定値にしても、そこは自由にしてください。
class ReadMoreText extends StatefulWidget {
const ReadMoreText({
required this.minimumLines,
// ...
final int minimumLines;
テキストが minimumLines
に満たない場合は、Text
ウィジェットをそのまま表示する制御も入れておきましょう。
final lines = textPainter.computeLineMetrics();
// 文字が minimumLines に満たない場合、「もっと見る」ボタンは非表示にする
if (lines.length <= minimumLines) {
return child;
}
return _Togglable(
それに加えて、レイアウト計算に使用した TextPainer
は dispose
しておきましょう。
final lines = textPainter.computeLineMetrics();
// 文字が minimumLines に満たない場合、「もっと見る」ボタンは非表示にする
if (lines.length <= minimumLines) {
textPainter.dispose();
return child;
}
return _Togglable(
次の章で、閉じたり広げたりをアニメーションで実現するためのウィジェットをつくっていきます。
そのほか必要な引数の設定
そのほか、TextPainter
や Text
ウィジェットには引数が用意されているので、切り替える機会は少ないですが、それらも一応設定しておきましょう。
それぞれの解説は割愛します。
final textPainter = TextPainter(
// ...
textAlign: textAlign ?? TextAlign.start,
locale: locale,
textHeightBehavior: widget.textHeightBehavior,
textWidthBasis: textWidthBasis,
strutStyle: widget.strutStyle,
);
final child = Text(
// ...
textAlign: textPainter.textAlign,
semanticsLabel: semanticsLabel,
selectionColor: selectionColor,
textHeightBehavior: textHeightBehavior,
textWidthBasis: textWidthBasis,
strutStyle: strutStyle,
locale: locale,
このウィジェットで制御する、以下のパラメータは廃しています。
- ellipsis
- maxLines
- overflow
- softWrap
アニメーションの動作
Text
の maxLines
を null にしたりすることで展開/折りたたみを実現することもできますが、それではアニメーションは実現できません。
以下のような複数のアニメーションを組み合わせるので、AnimationController
を使っていきます。
- テキスト全体のサイズ
- テキストの下部を覆う Shader の透明度
アニメーションの用意
普通のアニメーションなので特に長い説明はしません。
- テキスト全体のサイズ
- テキストの下部を覆う Shader の透明度
class _Toggleable extends StatefulWidget {
// ...
class _ToggleableState extends State<_Toggleable>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<double> _heightFactor;
late final Animation<double> _overlayOpacity;
// ...
AnimatedBuilder(
animation: _overlayOpacity,
builder: (context, child) {
return ShaderMask(
shaderCallback: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
widget.overlayColor,
widget.overlayColor.withOpacity(_overlayOpacity.value),
],
).createShader,
},
child: child,
);
},
child: SizeTransition(
sizeFactor: _heightFactor,
axisAlignment: -1,
child: widget.child,
),
),
TextPainter
から高さの取得
まず、TextPainter
から、最小の場合の高さと、拡張した時の高さを取得します。
さっき生成した TextPainter
には maxLines
を設定していないので、textpainter.height
で全体のテキストの高さを取得できます。
最小の方は、LineMetrix
に height
があるので、最小表示行数である minimumLines
の分高さを積算して取得します。
// すべてのテキストを表示した時の高さ
final maximumHeight = textPainter.height;
// 隠している時の高さ
// 👇 これでも多分同じ
// final minimumHeight = widget.minimumLines * lines.first.height;
final minimumHeight = lines.take(widget.minimumLines).fold(
0.0,
(previousValue, element) => previousValue + element.height,
);
展開後を 1.0
展開前を minimumHeight / maximumHeight
として扱い、SizeTransition
に渡すことでアニメーションでの展開/折りたたみを実現します。
begin
に を渡します。
class _ToggleableState xtends State<_Toggleable>
with SingleTickerProviderStateMixin {
late final Animation<double> _heightFactor;
// ...
void initState() {
// ...
final heightFactorBegin = widget.minimumHeight / widget.maximumHeight;
_heightFactor = Tween(begin: heightFactorBegin, end: 1.0)
.animate(_animationController);
}
Widget build(BuildContext context) {
// ...
SizeTransition(
sizeFactor: _heightFactor,
axisAlignment: -1,
child: widget.child,
)
折りたたみ中のテキストを覆う Shader をアニメーションにすることで、アニメーションをより自然にすることができます。
AnimatedBuilder(
animation: _overlayOpacity,
builder: (context, child) {
return ShaderMask(
shaderCallback: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
widget.overlayColor,
widget.overlayColor.withOpacity(_overlayOpacity.value),
],
).createShader,
child: child,
);
}
完成系
課題・展望
これで使えるようにはなりましたが、課題は色々残っています。
TextPainter
の layout()
処理が build 関数内で実行されている
リビルドが伝播されると、そのたびに TextPainter
の生成、layout()
の計算が行われてしまっています。
これは、Widget
ツリーと RenderObject
ツリーを分離している Flutter の思想に反した実装になっています。
それと、TextPainter
でレイアウト計算したあと、Text
ウィジェットで普通に表示するので、もちろんその内部で TextPainter
が使われます。
なので、重複した計算になっています。
解決策としては、自前の RenderObject
を実装するのが良さそうではあるが、リペイントの条件やその他諸々を自前実装するのは、結構な手間で、そこまで大きなインパクトはないので、今回は仕組みのシンプルさを目指して build 関数内での制御で済ませました。
これらの問題を緩和する対策としては、以下の2つが考えられますが、どちらもバグなく作るには割と骨が折れます。
-
TextPainter
の制御を自作のRenderObject
で済ませる。 - リビルドが伝播されづらい仕組みにする。
- 文字列が同じ場合は、
TextPainter
周りの再生成を行わないようにする。
- 文字列が同じ場合は、
Selectable にできるようにする
現状、選択可能にする方法は、SelectionArea
で囲むしかありません。
SelectionArea(
child: ReadMoreText(
'選択可能テキスト',
minimumLines: 5,
overlayColor: overlayColor,
),
),
このような展開可能な長文テキストは、選択可能だと便利なことが多いので、デフォルトで選択可能などにしておくのもいいと思います。
しかし、そうなると色々パラメータも増やさないといけないので割愛しました。
以下のような拡張で対応可能なので、各々で好きにカスタマイズするのがいいと思います。
-
Text
ではなくSelectableText
を内部で使用する。 -
SelectionArea
で囲む自前のコンストラクタを用意する。 -
selectable
という真偽値を含めて制御する。
その他細かいこと
- overlayColor のデフォルト値
- デフォルト値を
Theme.of(context).scaffoldBackgroundColor
にするのもアリかも。- 少し過剰かもしれないが、ThemeExtension も悪くない気がする。
- デフォルト値を
- もっと見るボタンではなく、テキストの末尾に「..show more」みたいなのを挿入する
- TextPainter の ellipsis 使えばいけるかも(試したことない)
追伸
パッケージもありました
Discussion