【Flutter】Text とは何か

12 min read読了の目安(約11500字

この記事は Flutter #3 アドベントカレンダー 2020 - Qiita 12 日目の記事です。


Flutter アプリ開発をしていて Text を使ったことがないという人はいないと思います。画面に文字を出したい時に必ず使うアレです。

使い方もとても簡単で、

const Text('Hello, Flutter!');

と、書くだけで画面上に Hello, Flutter! の文字が表示されます。

この誰もが使う Text ですが、API ドキュメントやソースコードを読んだことはあるでしょうか。

この記事では、Text のドキュメントやソースコードを読みながら、 Flutter のドキュメントにはどのようなことが書いてあるのか、ソースコードはどのように読み進めればよいのかを考えてみます。それにより、例えば Container や Row など、開発で頻繁に利用する他の各 Widget についても必要に応じて詳しく調べ、理解を深められるようになることを目的としています。

なお、すでに日常的に公式ドキュメントやソースコードを読みながら開発している、という方にとっては今更な内容ばかりかと思います。ご了承ください。

あわせて読みたい

https://api.flutter.dev/flutter/widgets/Text-class.html

Text の API ドキュメントです。この記事はこのドキュメントに沿って話が進みますが、所々省略したり誤訳があったりする可能性がありますので、この記事である程度理解ができたら一度自分でも全文を読んでみると良いでしょう。それほど長いドキュメントではありませんので、身構える必要はないと思います。

https://zenn.dev/chooyan/articles/77a2ba6b02dd4f

先日書いた Flutter の「3つのツリー」を理解するための記事です。
Text をはじめ様々な Widget の作りや Flutter の仕組みを理解する上でのベースとなる内容ですので、興味があれば読んでみてください。こちらは少々長い(参考文献はさらに長い)ので、年末年始にゆっくり読み進めてみるのがオススメです。

本文

API ドキュメントを読んでみる

それでは、まずは Text のドキュメントを上から順番に読んでみましょう。キリの良いところまで引用し、訳をつけた上で追加の説明を入れる形で進めていきます。

Text とは

まずドキュメントの冒頭に書かれているのは、「それは何か」という情報です。 Text のドキュメントの場合、以下のように記載されています。

The Text widget displays a string of text with single style.

訳: Text ウィジェットは単一のスタイルで文字列を表示します。

このような 「それは何か」 という説明はとても大切です。モノによっては「それは何か」よりも「どのように書けばどうなるか」のような情報が先行して技術記事などで広まっている場合があったりしますが、もしかしたらそれはそのモノのひとつの側面だけを説明したもので、本来の用途とは外れたものである可能性もあったりします。そのため、原理原則を抑える上でも、その Widget が「何」なのかを一言で理解することは大切です。

さて、 Text の「それは何か」の説明は上記の通りで、おそらく認識の通りかと思います。テキストを画面に表示するものですね。基本的にスタイルはテキスト全体でひとつであることも書かれています。

「ということは Twitter のハッシュタグのようにテキストの部分ごとにスタイルを変えたい場合はどうなるんだろう」という疑問がこの説明を読んで浮かんでくるかもしれませんが、それについては後で説明が出てきますので、一旦先に進みます。

テキストの改行について

「それは何か」の説明に続いて、以下のように Text の性質の説明が内容ごとに書かれています。まずは改行についての内容です。

The string might break across multiple lines or might all be displayed on the same line depending on the layout constraints.

訳: 指定した文字列は、レイアウトの制約によって複数行に改行されたり1行だけで表示されたりします。

layout constraints については "Inside Flutter" でも説明されていて、 RenderObject ツリーにおいて、レイアウトを決定する制約として親から子へと渡され、子がレイアウトを計算する上で考慮されるデータです。

ここで説明されている改行の制約については、 softWraptrue もしくは overflowTextOverflow.ellipsis の場合、さらに maxLines が指定されていない場合に 1 行のみの表示になる、というようなルールがあるようです。[1]

スタイルについて

続いて、テキストのスタイルの説明に入ります。

The style argument is optional. When omitted, the text will use the style from the closest enclosing DefaultTextStyle.

訳: style 引数は任意です。省略した場合、一番近い DefaultTextStyle を利用します。

これも認識の通りかと思いますが、何も指定しなければ DefaultTextStyle が利用されることは覚えておくと良いかと思います。例えば Scaffold で囲われていない場所で Text を表示すると見た目がだいぶ違うのは、この DefaultTextStyle が Scaffold で作られるものではなく MaterialApp(正確にはその中の WidgetApp) で作られるものになっているのが理由です。

「一番近い」については、 InheritedWidget の話が理解できているとイメージしやすいでしょう[2]。 DefaultTextStyle は InheritedWidget を継承しているため、 Widget ツリーの先祖に存在する DefaultTextStyle を子孫の Widget から Element を通じて参照できるようになっています。

また、 Element を通じて InheritedWidget を参照する際、同じ型のサブクラス(ここでは DefaultTextStyle)が複数存在する場合、直近のもので上書きされる仕様になっています。この仕様によって、上記の通り「一番近い DefaultTextStyle を利用します」という説明になっているわけです。

このように、「3つのツリー」の話など基礎となる知識を予め理解した上で、ドキュメントに記載されている内容が「なぜ」そうなのかまで併せて理解できると、だいぶ中の作りがイメージしやすくなると思います。

さて、先を読んでみると

If the given style's TextStyle.inherit property is true (the default), the given style will be merged with the closest enclosing DefaultTextStyle. This merging behavior is useful, for example, to make the text bold while using the default font family and size.

訳: 指定された style の TextStyle.inherit プロパティが true (既定値)だった場合、そのスタイルは直近の DefaultTextStyle とマージされます。これは、例えばテキストのフォントやサイズはそのまま、太字にだけしたい、という場合に便利です。

ということで、これは Text.build の実装をみるとわかります。

if (style == null || style.inherit)
  effectiveTextStyle = defaultTextStyle.style.merge(style);

我々 Text を使う側としては、テキストのスタイルを指定したい場合は style プロパティに TextStyle を指定するのが普通かと思いますが、内部的には TextStyle.merge を使って直近の DefaultTextStyle と 指定した TextStyle のマージが行われている、というわけですね。

逆に、DefaultTextStyle を完全に無視してスタイルを指定したければ、 TextStyle の inheritfalse にしておけば良いこともここから読み取れます。具体的な方法はともかく、「そのようなことができる」ということだけでも頭に入れておくといつか役に立つ内容がドキュメントには他にも記載されていたりします。

その後に使う側のサンプルコードが続きますが、この記事では割愛します。

Text.rich について

サンプルコードに囲まれて若干見落としてしまいそうですが、先ほどの「テキストの部分ごとにスタイルを変えたい場合」の説明がここに記載されています。

Using the Text.rich constructor, the Text widget can display a paragraph with differently styled TextSpans.

訳: Text.rich コンストラクタを使うことで、異なるスタイルが指定された複数の TextSpan を1つのテキストとして表示できます。

先述の通り、基本的に Text は単一のスタイルでテキストを表示します。一方で文字ごとにスタイルを変えたい場合は Text.rich を使い、その引数として TextSpan を指定することがここに記載されています。

TextSpan は表示する文字列を表す text とスタイルを表す style、さらに子要素となる children を持っていて、この children プロパティにさらに TextSpan を複数指定することで、複数の異なるスタイルの文字列をつなげて表示できるようになっています。詳しい書き方はその下に続くサンプルを読んでみてください。

タッチへの反応

サンプルコードの下は、 "Interactivity" というタイトルの内容が続いています。「ユーザーのタッチ操作へ反応する」やり方についてですね。

To make Text react to touch events, wrap it in a GestureDetector widget with a GestureDetector.onTap handler.

訳: Text をタッチイベントに反応させるためには、 GestureDetector.onTap ハンドラーを設定した GestureDetector ウィジェットで囲ってください。

In a material design application, consider using a TextButton instead, or if that isn't appropriate, at least using an InkWell instead of GestureDetector.

マテリアルデザインのアプリの場合は、代わりに TextButton を使うことを検討してください。もし TextButton が適切でない場合は、せめて InkWell を GestureDetector の代わりに利用してください。

ということで、 GestureDetector で囲ってもタッチ操作への反応はできるものの、 MaterialApp を使う場合は TextButton を使うのがオススメ[3]で、その次の案として Text を InkWell で囲うようにすると良いとのことです。おそらく GestureDetector では Ripple エフェクトが出なかったり、「押せる感」が出なかったり、というデザイン的な話だと思います。

このような「公式としてのオススメ」を知ることができるのも、公式ドキュメントを読むメリットだと思います。[4]

To make sections of the text interactive, use RichText and specify a TapGestureRecognizer as the TextSpan.recognizer of the relevant part of the text.

テキストの一部をインタラクティブにしたい場合は RichText を使い、インタラクティブにしたい部分のテキストの TextSpan.recognizer に TapGestureRecognizer を指定してください。

記載の通り、 RichText を使うとテキストの一部のスタイルを変えるだけでなく、テキストの一部分だけにタッチイベントを設定することも可能です。これを使うと、例えばテキスト中のハッシュタグや URL などをタップ可能にする、といった機能が実現できます。


これで一通りの説明文を読むことができました。あとは関連するクラスやコンストラクタ、メソッドなどの定義が続きますが、この記事では割愛します。

ソースコードを読んでみる

ドキュメントを読んだので、次にソースコードを抜粋して読んでみたいと思います。

ソースコードは「実際に動いているプログラムそのもの」です。ドキュメントでは言葉での説明のために多少抽象的な説明になっていたり省略されたりする部分も、プログラムを読むことで確実に「正解」と言える情報を得ることができます。読むのに手間はかかりますが、可能な限り読んでみることでより正確な理解に繋がるはずです。

StatelessWidget と build()

特に Flutter の Widget 関連のクラスを調べるとき、クラスの定義、特に継承関係はとても大切です。同じ Widget でも、親クラスが StatelessWidget なのか RenderObjectWidget なのか、それとも InheritedWidget なのかでは役割や作りが全く異なるためです。

Text の場合は、

class Text extends StatelessWidget {

ということで、StatelessWidget を継承したクラスです。 StatelessWidget は、我々アプリ開発者がよく作る Widget と同様 build() メソッドで子やそれより下に連なる Widget を構築します。ソースコードの該当部分を読んでみましょう。


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.boldTextOverride(context))
    effectiveTextStyle = effectiveTextStyle!.merge(const TextStyle(fontWeight: FontWeight.bold));
  Widget result = RichText(
    textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
    textDirection: textDirection, 
    ..省略..
  );
  if (semanticsLabel != null) {
    result = Semantics(
      textDirection: textDirection,
      label: semanticsLabel,
      child: ExcludeSemantics(
        child: result,
      ),
    );
  }
  return result;
}

build() が最終的に return している(つまり子 Widget となる)のは result 変数で、上記のコード(一部省略)の真ん中あたりをみると RichText を代入しているのが読み取れるかと思います。

RichText はテキストを部分部分ごとに色を変えたり装飾したり、タッチイベントをつけたりできる Text の拡張版のような Widget ですが、実際は RichText の一部の機能を簡単に使えるようにしたのが Text である 、というイメージが正確であることがここから分かります。

build() でやっているのは RichText の生成だけではありません。その上では先述した

指定された style の TextStyle.inherit プロパティが true (既定値)だった場合、そのスタイルは直近の DefaultTextStyle とマージされます。

の処理を行っていて、さらに RichText を生成した後の行では、 semanticLabel が指定されていた場合に RichText を SemanticsExcludeSemantics で囲ってあげる処理を行っています。

このように指定されたプロパティの内容によって Widget ツリーの構成を柔軟に組み立てるのが StatelessWidget や StatefulWidget の役割ですので[5]、この Text もその仕組みをうまく使っていることが読み取れます。

Text.rich コンストラクタ

ドキュメントにも書かれていた通り、 Text には Text.rich というコンストラクタが用意されています

const Text.rich(
  InlineSpan this.textSpan, {
  Key? key,
  this.style,
  ..省略..
}) : assert(
       textSpan != null,
       'A non-null TextSpan must be provided to a Text.rich widget.',
     ),
     data = null,
     super(key: key);

Text.rich はテキストを部分的に装飾する Widget を生成するためのコンストラクタです。同じ役割の Widget に RichText があるため、実際に使おうとするとどちらを使うのが良いか迷いがちですが、先ほど見た通り内部的に RichText を生成しているのはどちらも変わりません。

しかし、Text.rich を使う場合は build() でスタイルのマージや Semantics で囲う処理を適切に行ってくれていることを考えると、「部分的に装飾したテキスト」を生成したい場合は基本的には Text.rich コンストラクタを使うのが良いと言えるでしょう。

同じことを RichText でやろうとした場合、例えば DefaultTextStyle を適用するには Text.build でやっているように TextSpan の style に自分でそれを指定するコードを書かなければならなかったり、 Semantics についても同様、自分で Semantics と ExcludeSemantics で囲うコードを書かなければなりません。このあたりは RichText のドキュメントにも以下のように記載があります。

Consider using the Text widget to integrate with the DefaultTextStyle automatically. When all the text uses the same style, the default constructor is less verbose. The Text.rich constructor allows you to style multiple spans with the default text style while still allowing specified styles per span.
.
DefaultTextStyle を自動で適用したい場合は Text ウィジェットを使うことを検討してください。テキスト全体が同じスタイルの場合は、デフォルトコンストラクタは RichText より Text の方が単純です。 Text.rich コンストラクタを使うことで、テキストの一部に特定のスタイルをあてつつ、その他にはデフォルトスタイルを適用させることができます。

これで全てではありませんが、ソースコードを読むことでこのような発見をすることができ、例えば「RichText と Text.rich どちらを使えば良いのだろう?」という疑問に対し、コードレベルの比較を根拠に適切なものを判断できるようになります。

まとめ

以上、Text のドキュメントとソースコードをざっと読んでみました。

普段当たり前のように使っている Text ですが、改めて調べてみると中で使っているのは RichText であることが分かったり、 RichText をそのまま使うよりも Text.rich を使った方がスタイルや Semantics の指定の関係で適切であることが分かったり、タップ可能な Text として TextButton(と、それに関連する OverflowedText や OutlinedText)という Widget が最近追加されていたりと、新しい発見をいくつか得ることができました。

また、普段我々が作る Widget クラスと同じ StatelessWidget のサブクラスとして作られている Widget は他にも Flutter に用意されていますので、これらの Widget のコードを読んでみることでStatelessWidget / StatefulWidget の作り方のテクニックがいろいろと参考にできそうな気がしています。

Text などのコードは IDE の「定義へジャンプ」機能(Mac の VSCode の場合は Command + クリック)ですぐに読めるようになっていますので、他のクラスについても開発中に気軽に読んでみると勉強になることが多いでしょう。

この記事のような開発者個人の技術記事は調べ物のとっかかりとしては最適ですが、やはり可能な限りは公式ドキュメントやソースコードで裏を取ることで、より自分の理解を正確なものにし、開発においても要件に応じた最適な実装ができるようになると考えています。

脚注
  1. 根拠となるソースコードの場所は詳しく追えなかったので、動作確認した感じそうなっている様子でした、という程度ですが、、 ↩︎

  2. InheritedWidget については 「【Flutter】InheritedWidget とは何か」 も参考にしてみてください。 ↩︎

  3. TextButton は Flutter 1.22 で追加された Widget のようです。 ↩︎

  4. 「なぜ」オススメなのかも一緒に書いてくれるとより良いのですが、今回は書いてくれていないようです。 ↩︎

  5. RichText のような RenderObjectWidget のサブクラスには build() がなく、不変な child(もしくは children)フィールドがあるだけですので、場合によって child や children を柔軟に変えるようなことはできない作りになっています。 ↩︎