📃

【Flutter】TextPainter で安全に、長文のテキストをアニメーションで隠したり展開させる

2024/04/28に公開

環境

# Flutter
3.19.5

完成系

https://github.com/Zudah228/flutter_riverpod_demo/blob/main/lib/presentation/pages/read_more_text/widgets/read_more_text.dart

展開の挙動 展開の必要がない場合

以下、コピペで使用可能なソースコードです。

完成ソースコード
read_more_text.dart
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 経由で、toggleexpandcollapse の関数が使える)

解説

TextPainter から行数の取得

https://api.flutter.dev/flutter/painting/TextPainter-class.html

TextPainter とは、TextRichText) ウィジェットの描画を担当する RenderObjectRenderParagraph)で使用されているクラスです。

TextRichText) ウィジェットの描画のほぼ全てが入っているので、このクラスから、描画したときの高さや行数などが取得できます。

RichTextText.rich のように、InlineSpan を渡して利用します。

final textPainter = TextPainter(
  text: TextSpan(
    text: widget.text,
  ),
);

layout の実行

TextPainter から、高さなどを取得するには、layout() を実行してからでないといけないので、インスタンス生成後に実行します。
この際、minWidthmaxWidth を渡さないと、使いたいウィジェットのサイズによってうまく動作しなくなります。
LayoutBuilder を使って取得しましょう。

read_more_text.dart
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)
read_more_text.dart
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 になってしまいます。

https://api.flutter.dev/flutter/painting/kDefaultFontSize-constant.html

ここは、Text ウィジェットの TextStyle の決定方法をまるパクりしちゃいましょう。

widgets/text.dart
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 の取得が必要です。

現状のソースコード
read_more_text.dart
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行より多ければ隠す」を実現するために、それらを設定できるようにしておきましょう。
自分のアプリの構成によって、デフォルト値を設定したり、あるいは固定値にしても、そこは自由にしてください。

read_more_text.dart
class ReadMoreText extends StatefulWidget {
  const ReadMoreText({
    required this.minimumLines,

  // ...

    final int minimumLines;

テキストが minimumLines に満たない場合は、Text ウィジェットをそのまま表示する制御も入れておきましょう。

read_more_text.dart
        final lines = textPainter.computeLineMetrics();

        // 文字が minimumLines に満たない場合、「もっと見る」ボタンは非表示にする
        if (lines.length <= minimumLines) {
          return child;
        }

        return _Togglable(

それに加えて、レイアウト計算に使用した TextPainerdispose しておきましょう。

read_more_text.dart
        final lines = textPainter.computeLineMetrics();

        // 文字が minimumLines に満たない場合、「もっと見る」ボタンは非表示にする
        if (lines.length <= minimumLines) {
          textPainter.dispose();

          return child;
        }

        return _Togglable(

次の章で、閉じたり広げたりをアニメーションで実現するためのウィジェットをつくっていきます。

そのほか必要な引数の設定

そのほか、TextPainterText ウィジェットには引数が用意されているので、切り替える機会は少ないですが、それらも一応設定しておきましょう。
それぞれの解説は割愛します。

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

アニメーションの動作

TextmaxLines を null にしたりすることで展開/折りたたみを実現することもできますが、それではアニメーションは実現できません。

以下のような複数のアニメーションを組み合わせるので、AnimationController を使っていきます。

  • テキスト全体のサイズ
  • テキストの下部を覆う Shader の透明度

アニメーションの用意

普通のアニメーションなので特に長い説明はしません。

  • テキスト全体のサイズ
  • テキストの下部を覆う Shader の透明度
read_more_text.dart
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 で全体のテキストの高さを取得できます。

最小の方は、LineMetrixheight があるので、最小表示行数である minimumLines の分高さを積算して取得します。

read_more_text.dart
// すべてのテキストを表示した時の高さ
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 をアニメーションにすることで、アニメーションをより自然にすることができます。

read_more_text.dart
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,
    );
  }

完成系

https://github.com/Zudah228/flutter_riverpod_demo/blob/main/lib/presentation/pages/read_more_text/widgets/read_more_text.dart

課題・展望

これで使えるようにはなりましたが、課題は色々残っています。

TextPainterlayout() 処理が 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 使えばいけるかも(試したことない)

追伸

パッケージもありました
https://pub.dev/packages/readmore

Discussion