20
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Flutter #2Advent Calendar 2020

Day 9

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

Last updated at Posted at 2020-12-09

この記事は Flutter #2 Advent Calendar 2020 の9日目の記事です。

はじめに

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

株式会社アトラエで普段は yenta のAndroidアプリを書いています。

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

やったこと

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

read_more_or_less.gif

ポイント

zennで @hayabusabusa さんが似たような記事を書いてらっしゃったので、こちら から勉強させていただきました🙇‍♂️

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

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のwidthtextのtextPainterのwidth の差を比較し、「もっと見る」のテキストを追加する場合の元のテキストのendIndexを取得し、文章をカットすることです。

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

全文

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


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

20
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?