【Flutter】Text Widgetに文字を正しく表示させるための調査結果

# はじめに
Flutterでアプリ開発をするとき、以下のようにデフォルトの設定でただ`Text`に表示したい文字列とフォントサイズなどが入った`style`を当てるだけでは実はうまくいかないことがあります。

```dart
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'あいうえお',
              style: TextStyle(fontSize: 40),
            ),
          ],
        ),
      ),
    );
  }
}
```

![Screen Shot 2021-06-11 at 9.09.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/e0f7e972-e34e-49de-5305-c0c6a29e7ed8.png)

このままではデザイナーさんが作ったデザインを正しく再現できません。

次の章で詳しく触れますが、デザインと同じフォントサイズでもデザイナーさんがデザインしたテキストが確保している領域と、FlutterのText Widgetが確保している領域に違いがあるため、そのまま使うとサイズの制約が厳しい場合にデザイン通りTextが収まりきらなかったり、縦にTextを並べたときなどにデザインが崩れていきます。

この記事ではなぜそのような問題が起こるのかと、解決策をまとめます。

# Textの構造
まずは先程のTextがどのような構造で作られているのか見てみましょう。

Inspectorでこの`Text`を見てみます。

![Screen Shot 2021-06-09 at 23.40.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/caab8140-3f74-188d-c6a3-c3a01a920c87.png)

下の図の`Font metrics default height`がText Widgetが確保する領域となり、Inspectorで見たときの文字列の周りの水色の四角い枠がそれに当たります。
`Alphabetic Baseline`が文字列の下の緑っぽい色の線です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/31ddbe38-09cc-7832-e49a-a4fff2836cec.png)


今回は`style`の`fontFamily`に何も設定していないので、iOSとAndroidそれぞれのプラットフォームのデフォルトのフォントが使われます。

iOSだと和文フォントにヒラギノ角ゴシック、欧文フォントにSan Francisco Pro、Androidだとその端末にデフォルトでインストールされている和文フォントと欧文フォントにRobotoが適用されます。

Text Widgetには、`style`、`strutStyle`などのパラメータが用意されていて、それらをそれぞれいじることで以下のようにText Widget内のフォントの位置をいじることができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/d0a64f78-8d19-f2ca-30ad-6d7c0fa53e28.png)


```
Configuration 1: The default. leadingDistribution is set to TextLeadingDistribution.proportional.
Configuration 2: same as Configuration 1, except TextHeightBehavior.applyHeightToFirstAscent is set to false.
Configuration 3: leadingDistribution is set to TextLeadingDistribution.even.
Configuration 4: same as Configuration 3, except TextHeightBehavior.applyHeightToLastDescent is set to false.
```
図リンク
https://api.flutter.dev/flutter/painting/TextStyle-class.html
https://api.flutter.dev/flutter/painting/StrutStyle-class.html

たとえば、上の例のように`TextStyle`の`height`に`4`を設定したとき、 `fontSize * height`でText Widgetの高さが計算されて`Configuration 1`のような結果になります。

今回のポイントとなるのは、Text Widgetに用意されているパラメータの1つの`textHeightBehavior`です。 

```dart
            Text(
              'あいうえお',
              style: TextStyle(fontSize: 40),
              textHeightBehavior: TextHeightBehavior(
                applyHeightToFirstAscent: false,
                applyHeightToLastDescent: false,
              ),
            ),
```

`TextHeightBehavior`には `applyHeightToFirstAscent`と `applyHeightToLastDescent`というbooleanを設定するパラメータがあります。これによって、`Configuration 1`のように`height`を設定していても、`applyHeightToFirstAscent: false`とすることで、`Configuration 2`のように`Text Top`と`Font Ascent`が同じラインに、`applyHeightToLastDescent: false`を設定することで`Configuration 4`にように`Text Bottom`と`Font Descent`が同じラインに設定されて、`height`の影響を受けなくなります。

ちなみに、以下のコードを実行した場合

```dart
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'あいう',
              style: TextStyle(fontSize: 30),
            ),
            Text(
              'あいう',
              style: TextStyle(fontSize: 30),
              textHeightBehavior: TextHeightBehavior(
                applyHeightToFirstAscent: false,
                applyHeightToLastDescent: false,
              ),
            ),
          ],
        ),
      ),
    );
  }
```
結果はこうなります。

![Screen Shot 2021-06-10 at 23.05.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/e77dda94-dd24-4b2c-d0dd-dfed228369eb.png)

デフォルトでは`Text Top`と`Font Ascent`、`Text Bottom`と`Font Descent`は同じラインにはおらず、無駄な隙間が入っているようです。

ちなみに、英文を文字列として与えて欧文フォントを出したときは下のような結果になります。
どうやら和文フォントの場合に先程の挙動を起こすようです。
![Screen Shot 2021-06-10 at 23.08.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/b91a03ca-b9de-476f-f837-c8e2b0c47b26.png)




# FlutterでのText Widgetの課題
ここでようやく本題のText Widgetを使うときの課題（注意点）をまとめたいと思います。

## デザイン上の課題
### デザインのテキストの高さとText Widgetの高さが異なる
先程述べたように、和文フォントでは`textHeightBehavior`を設定しないと上下に無駄な隙間が入ってしまっています。それによって、本来デザイナーさんがデザインしたUIよりも1つあたりのTextの高さが高くなってしまって、デザイン通りのピクセル数の高さのCardの中に同じフォントサイズてText Widgetを縦に配置しても入り切らなかったりして正しくデザインをFlutterで再現できない事が起こります。

### 文字列が下に寄って見える
この課題はFlutterのIssueにも上がっています。
https://github.com/flutter/flutter/issues/79931
和文フォント以外でも中国語でも`textHeightBehavior`を設定しないと上下に無駄な隙間が入ってしまうかつ、欧文フォントは`baseline`の上にぴったり文字列が並びますが和文フォントは`baselineに`文字列が少し被るまたは下にはみ出てしまっています。また、`descent`の下よりも`ascent`の上のほうが無駄な隙間が大きく取られるのも原因です。

以上の課題によって、例えば高さが52の背景がついた領域にpaddingを全方向に8、フォントサイズ30の日本語が入力されたText Widgetを配置することを考えてみます。

デザイン上ではもちろん高さ52の背景に対してテキストは垂直方向に真ん中に配置されてほしいですが、うまくいきません。

```dart
            Container(
              height: 52,
              color: Colors.grey,
              padding: const EdgeInsets.all(8),
              child: Center(
                child: Text(
                  'ああああああ',
                  style: TextStyle(
                    fontSize: 30,
                  ),
                  textHeightBehavior: TextHeightBehavior(
                    applyHeightToFirstAscent: false,
                    applyHeightToLastDescent: false,
                  ),
                ),
              ),
            ),
```
結果
![Screen Shot 2021-06-11 at 2.37.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/42bc305f-2a98-d8fa-87be-f6c8e857111f.png)


さらにこれがもっと高さ制限が強く、高さ48の背景に対してテキストを配置したい場合デザイン上は中に収まっているのにFlutterでは再現できない事が起こります。
![Screen Shot 2021-06-11 at 2.40.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/11736b7b-b510-4914-fcff-14ed8b35d52c.png)


## 実装上の課題
### iOSとAndroidでの`TextHeightBehavior`の挙動が違う
では、`TextHeightBehavior`を設定することで上下の無駄な隙間がなくなり、デザイン通りのテキストの高さをText Widgetでも実現できそうです。それにともなって文字列が下に寄って見える課題も解決できそうです。

ですが、先程の実行結果はiOSデバイスで実行したときです。
Androidデバイスで全く同じコードを実行してみます。
![Screen Shot 2021-06-10 at 23.12.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/7db26bd4-0696-077a-6191-8327a84b43ed.png)
どうやら、`textHeightBehavior`が効いていないようです。
これではiOSでは課題が解決できてAndroidでは解決できなさそうです。
Issueに上がっていたのもAndroidで起きているようでした。

これはFlutterのAndroidでのみ起こるバグなのでしょうか？
それとも他になにか原因があるのでしょうか？

次の章で解決策を模索します。

# 解決策
## Androidでも`TextHeightBehavior`を適用させるためには
なぜiOSとAndroidで`TextHeightBehavior`の挙動が違うのでしょうか。
Text Widgetの`TextStyle`に`backgroundColor`を設定してText Widgetの中でフォントが確保している領域を見てみましょう。以下のコードをiOSとAndroidで実行してみます。

```dart
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'あいう',
              style: TextStyle(
                fontSize: 30,
                backgroundColor: Colors.deepOrange,
              ),
            ),
          ],
        ),
      ),
    );
  }
```

実行結果

iOS | Android
:--:|:--:
![Screen Shot 2021-06-11 at 0.21.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/8d12e1a0-d20f-6100-30e0-ae207f8e2b1f.png)|![Screen Shot 2021-06-11 at 0.22.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/a9ed2830-0c6f-5b67-3acf-978cd26bd5e1.png)

どうやらiOSとAndroidでそもそもText Widgetの中でフォントが専有している領域の高さに違いがあることがわかります。

iOSだと和文フォントにヒラギノ角ゴシック、欧文フォントにSan Francisco Pro、Androidだとその端末にデフォルトでインストールされている和文フォントと欧文フォントだとRobotoが適用されます。PixelとかだとNoto Sans JPあたりが和文フォントに適用されてるようでした。

このiOSとAndroidで使われているフォントの違いによって`TextHeightBehavior`の挙動の違いが起こっていたようです。
`TextHeightBehavior`を設定してもフォント自体が確保している領域よりもascentとdescentが狭まらないということのようです。

`fontFamily`でヒラギノ角ゴシックを使うように修正してAndroidで実行してみます。

フォントの追加
![Screen Shot 2021-06-11 at 1.05.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/3c89a28f-ce17-710d-03d8-3f3c63bd4112.png)

`pubspec.yaml`に追記

```yaml:pubspec.yaml
flutter:
  uses-material-design: true
  fonts:
    - family: Hiragino
      fonts:
        - asset: assets/fonts/hiragino.ttc
```

`fontFamily`を設定

```dart
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'あいう',
              style: TextStyle(
                fontSize: 30,
                backgroundColor: Colors.deepOrange,
                fontFamily: 'Hiragino', // 追記
              ),
            ),
          ],
        ),
      ),
    );
  }
```

実行結果
![Screen Shot 2021-06-11 at 1.26.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/f6da81ac-efd4-b712-1a56-bfe2ec16a278.png)

`familyFont`適用前
![Screen Shot 2021-06-11 at 0.22.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/a9ed2830-0c6f-5b67-3acf-978cd26bd5e1.png)


Android OSがデフォルトで設定するフォントから自分でヒラギノ角ゴシックを`fontFamily`に設定すると、AndroidでもText Widgetの中のフォントが占有する領域を縮める事ができました。

これに`textHeightBehavior`を設定するともちろん

```dart
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'あいう',
              style: TextStyle(
                fontSize: 30,
                backgroundColor: Colors.deepOrange,
                fontFamily: 'Hiragino',
              ),
              textHeightBehavior: TextHeightBehavior(
                applyHeightToFirstAscent: false,
                applyHeightToLastDescent: false,
              ),
            ),
          ],
        ),
      ),
    );
  }
```

![Screen Shot 2021-06-11 at 1.38.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/49a2552e-8e46-6fdf-4faf-f251ea51e019.png)

Androidでも`TextHeightBehavior`を効かせることができるようになりました :tada: 

ここからはチームでFlutterでアプリが開発している場合はチーム内でデザイナーさんと相談なのですが、解決する方針としては以下のような方針が考えられそうです。

- iOSとAndroid両プラットフォームで`TextHeightBehavior`が効く同じフォント（例えばヒラギノ角ゴシック）を`fontFamily`に設定して使う
- 上の方針だとAndroidユーザからは普段目にするフォントと違って違和感が出そうなので、iOSはヒラギノ角ゴシックを使ってAndroidでは別のあまり違和感がなく`TextHeightBehavior`が効く同じフォントを使う
- ...

## 日本語と英語が混ざる場合

フォントを変更する場合もう1つ考慮しなければならないことがあります。
1つのTextに日本語と英語が混ざる場合です。

今回設定したヒラギノ角ゴシックですが、一応英語にも対応しているフォントのため英語を混ぜた場合以下のような結果になります。
![Screen Shot 2021-06-11 at 1.51.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/dd60e4d2-1384-9c3c-f7f7-ee9a9a3ce25a.png)
`j`のような`baseline`をはみ出す文字の場合、全体のフォント自体が専有する領域は一緒なのですが、descentよりも文字が突き出てしまい、Text Widgetを`Column`で連続で並べたときに下のText Widgetに食い込んでしまいます。
![Screen Shot 2021-06-11 at 1.55.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/4edc1449-ef41-b9c6-d440-9b213d9862ca.png)
また、デザイン上英語は半角っぽく表示されているのにこれだと全角の英語みたいに見えてしまっていてデザインと異なるということでも出てきそうです。

ちなみに、Androidで`fontFamily`を設定しないで実行した場合このような結果になります。
![Screen Shot 2021-06-11 at 1.58.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/62d25450-e70b-b109-8935-cba286d8ed86.png)
ascentの位置は違うものの、`j`のような文字でもdescentを突き出るようなことはありません。

以上のことから、ヒラギノ角ゴシックのように`textHeightBehavior`が日本語に効いてアルファベットにも対応している和文フォントを使いつつ、英語は別でいい感じに表示するようにしたいニーズが出てきます。

## 日本語と英語が混ざった場合の解決策
そこで、Text Widgetの`style`にある`fontFamilyFallback`というパラメータがあります。
https://api.flutter.dev/flutter/painting/TextStyle/fontFamilyFallback.html

これは、Text Widgetに入力された文字列がの中に`style`で設定した`fontFamily`で表せない文字列があった場合に使うフォントを設定することができます。`fontFamilyFallback`はフォント名の文字列の配列を渡せて、使って欲しいフォントの優先度順に設定することができます。
これを使うことで、`fontFamily`に欧文フォント（RobotoやSan Francisco Pro）を設定し、`fontFamilyFallback`にヒラギノ角ゴシックのようなアルファベットにも対応した和文フォントを設定することで、

日本語と英語が含まれた文字列がText Widgetに入力された 👉 一番優先されて`fontFamily`に設定された欧文フォントで文字列を出力しようとするので、英語は欧文フォントが適用される👉日本語は`fontFamily`で設定された欧文フォントでは出力できないので`fontFamilyFallback`で設定したヒラギノ角ゴシックで出力される

ということが実現でき、英語は別の欧文フォントで、日本語は別の和文フォントで出力するということが実現できるようになります。


しかし、このとき和文フォントと欧文フォントでascentとdescentがずれているのが気になります。

```dart
              Text(
                'あいうえおaaajjj',
                style: TextStyle(
                  backgroundColor: Colors.deepOrange,
                  fontFamily: 'SF',
                  fontFamilyFallback: ['Hiragino'],
                  fontSize: 30,
                ),
                textHeightBehavior: TextHeightBehavior(
                  applyHeightToLastDescent: false,
                  applyHeightToFirstAscent: false,
                ),
              ),
```

![スクリーンショット 2021-06-11 16.50.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/af0247ea-0fc1-edbb-37ac-2fa5947569dd.png)
欧文フォントのほうが高さが出ているので、Text Widgetを縦に積んだときなどは違和感出てきそうです。`baseline`は和文フォントも欧文フォントも揃っていて良さそうです。

そこで、heightを使ってこの高さの違いを吸収したいと思います。

```dart
              Text(
                'あいうえおaaajjj',
                style: TextStyle(
                  backgroundColor: Colors.deepOrange,
                  fontFamily: 'SF',
                  fontFamilyFallback: ['Hiragino'],
                  fontSize: 30,
                  height: 2, // 追加
                ),
              ),
```

![スクリーンショット 2021-06-11 16.55.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/b583808e-9926-96bd-0b9f-889dc0a6385e.png)
Text Widgetの構造で紹介したように、デフォルトで`style`の`leadingDistribution`に`TextLeadingDistribution. proportional`が設定されているためこのうような見た目になってしまいます。`TextLeadingDistribution`には`TextLeadingDistribution.even`があるためそれを使ってみます。

![スクリーンショット 2021-06-11 17.00.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/41a18422-e7ec-3807-2800-e0d553fd05fc.png)
上下に均等に余白が入ってくれるので、文字列が下に寄って見てる課題も解決できそうです。

これで、課題は解決できそうです。
最初の背景色に対して文字列が下に寄って見える課題と、高さ制限が厳しいときに文字列が見切れる課題も解決できました。
日本語と英語が混じらないことが保証されていれば、 `textHeightBehavior`を設定するだけで良さそうですが、混ざる場合はそれは不要で以下のような設定でうまく動くようになります。


```dart
              Container(
                height: 48,
                color: Colors.grey,
                padding: const EdgeInsets.all(8),
                child: Text(
                  'あいうえおaaajjj',
                  style: TextStyle(
                    fontFamily: 'SF',
                    fontFamilyFallback: ['Hiragino'],
                    fontSize: 30,
                    leadingDistribution: TextLeadingDistribution.even,
                    height: 1,
                  ),
                ),
              ),
```

![スクリーンショット 2021-06-11 17.05.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185927/491c0c8e-4a65-32e6-d043-8c9c96e81c1e.png)

# まとめ
結局の所、`fontFamily`に何も設定をしていないとプラットフォームごとで使われるデフォルトのフォントの違いに起因する問題でした。
もし、デザインが1つのフォントで作られていて`fontFamily`を特別指定しないでiOSとAndroidの両プラットフォームを実装しようとしていた場合、2つのプラットフォームで別々のフォントが使われるのにも関わらず1つのフォントで作られたデザインを再現しようとしているので無理が出てきそうです。

対処の仕方は数多くありそうなので、チームで話し合って最適な方法を探ると良さそうです。

今回の内容が少しでも解決の手助けになれれば幸いです。

# 参考記事
- https://api.flutter.dev/flutter/painting/TextStyle-class.html
- https://api.flutter.dev/flutter/painting/StrutStyle-class.html
- https://github.com/flutter/flutter/issues/79931
- https://api.flutter.dev/flutter/painting/TextStyle/fontFamilyFallback.html