[Flutter] 長文が「もっと見る」「閉じる」で拡大・縮小するWidgetを作成した

この記事は [Flutter #2 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/flutter-2) の9日目の記事です。

# はじめに

（執筆が遅くなりすみません🙇‍♂️💦）

株式会社アトラエで普段は [yenta](https://page.yenta-app.com/jp) のAndroidアプリを書いています。

また、最近は他事業部でFlutterでAndroidアプリの開発も兼任しております。

# やったこと

以下のGIFのようなFacebookなどでみる「もっと見る」「閉じる」を実装しました。

![read_more_or_less.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178357/0ffe57f1-e089-cea1-caa8-ff1bcc75f27c.gif)

# ポイント

zennで @hayabusabusa さんが似たような記事を書いてらっしゃったので、[こちら](https://zenn.dev/hayabusabusa/articles/7bf73f007584aa4e0ee8) から勉強させていただきました🙇‍♂️

基本的には、上記の記事の内容のように、 `textPainter.didExceedMaxLines` によって `trimLines` よりも文章が多いか少ないかを判別し、それに応じてウィジェットを切り替えたりします。

```dart
TextSpan textSpan;
if (textPainter.didExceedMaxLines) {
  textSpan = TextSpan(
    text: _readMore
        ? widget.text.substring(0, endIndex)
        : widget.text,
    style: DefaultTextStyle.of(context).style,
    children: _readMore
        ? <TextSpan>[
          ellipsizeTextSp,
          readMoreOrLessTextSp,
        ]
        : <TextSpan>[
          readMoreOrLessTextSp,
        ],
  );
} else {
  textSpan = textSpan = TextSpan(
    text: widget.text,
    style: DefaultTextStyle.of(context).style,
  );
}
```

ボタンではなく、文章中に文字を表示するところのポイントですが、 `「もっと見る」のtextPainterのwidth` と `textのtextPainterのwidth` の差を比較し、「もっと見る」のテキストを追加する場合の元のテキストのendIndexを取得し、文章をカットすることです。

```dart
final pos = textPainter.getPositionForOffset(Offset(
  textSize.width - readMoreOrLessTextSize.width,        
  textSize.height,
));
final endIndex = textPainter.getOffsetBefore(pos.offset);
```

# 全文

```dart
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Read More Text App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(title: 'Read More Text'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: ExpandableText(
            'aaaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbb\nccccccccccccccc\ndddddddddddddddd\neeeeeeeeeeee'),
      ),
    );
  }
}

class ExpandableText extends StatefulWidget {
  const ExpandableText(
    this.text, {
    Key key,
    this.trimLines = 2,
  })  : assert(text != null),
        super(key: key);

  final String text;
  final String ellipsizeText = '...';
  final String readMoreText = ' もっと見る';
  final String readLessText = ' 閉じる';
  final int trimLines;

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

class ExpandableTextState extends State<ExpandableText> {
  bool _readMore = true;
  void _onTapReadMoreOrLess() {
    setState(() => _readMore = !_readMore);
  }

  final colorClickableText = Color(0xFF00ADFE);
  final textColor = Colors.black;

  @override
  Widget build(BuildContext context) {
    final readMoreOrLessTextSp = TextSpan(
      text: _readMore ? widget.readMoreText : widget.readLessText,
      style: TextStyle(color: colorClickableText),
      recognizer: TapGestureRecognizer()..onTap = _onTapReadMoreOrLess,
    );
    final ellipsizeTextSp = TextSpan(
      text: widget.ellipsizeText,
      style: DefaultTextStyle.of(context).style,
    );
    final textSp = TextSpan(
      text: widget.text,
      style: DefaultTextStyle.of(context).style,
    );

    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        assert(constraints.hasBoundedWidth);

        final textPainter = TextPainter(
          text: readMoreOrLessTextSp,
          textDirection: TextDirection.ltr,
          maxLines: widget.trimLines,
        )..layout(
            minWidth: constraints.minWidth,
            maxWidth: constraints.maxWidth,
          );
        final readMoreOrLessTextSize = textPainter.size;

        textPainter.text = textSp;
        textPainter.layout(
          minWidth: constraints.minWidth,
          maxWidth: constraints.maxWidth,
        );
        final textSize = textPainter.size;

        final pos = textPainter.getPositionForOffset(Offset(
          textSize.width - readMoreOrLessTextSize.width,
          textSize.height,
        ));
        final endIndex = textPainter.getOffsetBefore(pos.offset);

        TextSpan textSpan;
        if (textPainter.didExceedMaxLines) {
          textSpan = TextSpan(
            text: _readMore
                ? widget.text.substring(0, endIndex)
                : widget.text,
            style: DefaultTextStyle.of(context).style,
            children: _readMore
                ? <TextSpan>[
                    ellipsizeTextSp,
                    readMoreOrLessTextSp,
                  ]
                : <TextSpan>[
                    readMoreOrLessTextSp,
                  ],
          );
        } else {
          textSpan = textSpan = TextSpan(
            text: widget.text,
            style: DefaultTextStyle.of(context).style,
          );
        }

        return RichText(
          text: textSpan,
          overflow: TextOverflow.ellipsis,
          maxLines: _readMore ? widget.trimLines : null,
        );
      },
    );
  }
}
```


# おわりに

いつもよくみるレイアウトを実装してみようとすると、意外と知らない知識を得ることができますね。

TextSpanやTextPainterは今回初めて触ったので、勉強になりました。

変なところがあったらコメントお願いします🙇‍♂️

TwitterでFlutterのこと呟いてたりするのでもしよかったらフォローお願いします！ 
[@muttsu_623](https://twitter.com/muttsu_623/)

---

明日は @granoeste さんです！
よろしくお願いします👏