Flutter の Text Scale Factor に対応したデザインを実装する

2023/04/04に公開

Text Scale Factor とは

iPhone / Android のモバイル端末では、文字を小さくしたり大きくしたりと、文字サイズを変更できるようになっています。
Text Scale Factor とは、Flutter で端末の文字サイズを変更することでアプリ側の文字サイズも変更できる機能です。iOS の場合は Dynamic Type と呼ばれる機能です。

Flutter では、基本的に何もせずとも端末の文字サイズに合わせて Text の大きさが変わるようになっています。ただし、それによってデザインが崩れることもあるため文字サイズが変わることを意識した実装が必要になります。iOS16.2 では 310%まで拡大できます。最大まで拡大するとデザインはかなり崩れます。
この記事では、東急ホテルズ の Flutter アプリで行った Text Scale Factor の対応について記載します。

OS ごとの文字サイズ変更方法

文字サイズの変更方法は公式サイトで案内されており、主にアクセシビリティ用途です。設定方法は以下を参照ください。

https://support.apple.com/ja-jp/guide/iphone/iph3e2e1fb0/ios#:~:text=「設定」 >「アクセシビリティ」,サイズを調整します。

https://support.google.com/accessibility/android/answer/11183305?hl=ja

iPhone の場合、コントロールセンターにテキストサイズを設定しておくと確認が楽です。 (余談ですがダークモードもここにあると楽です)

Text Scale Factor 値の取得

Flutter では簡単に Text Scale Factor の値を確認できます。

print(MediaQuery.of(context).textScaleFactor);

例えば 310% の最大まで拡大してみると 3.1176470588235294 という値が取得できました。

設定を無効化することも可能

アクセシビリティ上良くないですが、文字サイズを固定することも可能です。

https://zenn.dev/mukkun69n/articles/eeb8786f00b2f6

また、無効化だけでなく最小値や最大値を決めて、大きく/小さくなりすぎないようにすることも可能です。

https://developers.cyberagent.co.jp/blog/archives/36310/

心構え

可変文字サイズに対応する心構えとして、どのような文字サイズでも完璧な見た目を追求するのは現実的ではありません。
標準(100%)のフォントサイズで最も綺麗に見えるようにし、フォントサイズが拡大された場合は多少デザインが崩れてもアプリが利用可能な状態を保つ、というくらいの心構えでいると良いと思います。

また、画像上の文字は拡大されないので画像を用いた文章レイアウトは避けたり、テーブルレイアウトなど横幅に制限がある場合は最大値を制限したりと、エンジニアだけの対応でに収まらずデザイナーや PdM などとも協働が必要なことがあります。

Text Scale Factor 実装時の気づかいポイント

ここからは実装時に気遣うポイントについて書いていきます。

普通に実装していてもフォントサイズは可変になるのですが、適切な見た目で表示されるかとは別問題です。例えば SizedBox で囲まれた Text は文字が大きくなっても SizexBox は大きくならないですし、Row の中にある Text は自動で改行されません。
デザインが崩れるならまだしも、文字が全く読めないということは避けなければなりません。

文字を囲む際は SizedBox ではなく BoxConstraints を使う

SizedBox/Container で height, width が固定されている場合、フォントサイズが大きくなっても SizedBox/Container の大きさは固定されます。
以下の図では、 Good の方は文字サイズに合わせて枠が拡大していますが、Bad の方は文字が見切れてしまっています。

通常時の見た目 310%まで拡大した見た目

Good では ConstrainedBox を使い minHeight, minWidth を指定することでデザインを維持しつつフォントサイズの変更に対応しています。

Good

ConstrainedBox(
  constraints: const BoxConstraints(
    minHeight: 30,
    minWidth: 60,
  ),
  child: Container(
    color: Colors.green,
    child: const Text('Good'),
  ),
);

Bad

Container(
  height: 30,
  width: 60,
  color: Colors.red,
  child: const Text(
    'Bad',
  ),
);

Row で Text を使うときは Expanded(child: Text()) するか Wrap を使う

Row で単純に Text を使っていると、端の文字が見切れても自動で改行してくれません。改行するためには Expanded(child: Text()) するか Wrap を利用する必要があります。
これらは実装方法によってデザインに差異が生じます。個人的には Wrap を利用することが多いです。

通常時の見た目 310%まで拡大した見た目

単純に Row を Text で囲んだだけでは文字が見切れますが、Wrap と Expanded を利用すると文字が改行されます。

Good - Wrap

Container(
  color: Colors.green,
  child: Wrap(
    children: const [
      Icon(Icons.info),
      Text('Good - Wrap(children: [Text])'),
      Icon(Icons.info),
    ],
  ),
);

Good - Expanded

Container(
  color: Colors.green,
  child: Row(
    children: const [
      Icon(Icons.info),
      Expanded(
        child: Text('Good - Row(children: [Expanded(Text)]'),
      ),
      Icon(Icons.info),
    ],
  ),
);

Bad

Container(
  color: Colors.red,
  child: Row(
    children: const [
      Icon(Icons.info),
      Text('Bad - Row(children: [Text])'),
      Icon(Icons.info),
    ],
  ),
);

RichText は textScaleFactor を指定する

RichText はデフォルト引数で this.textScaleFactor = 1.0 が指定されており、フォントサイズが固定されています。
https://github.com/flutter/flutter/blob/2ad6cd72c0/packages/flutter/lib/src/widgets/basic.dart#L5621

以下の画像を見るとわかりますが、310%まで拡大しても BaD と書かれた方は文字サイズが変わっていません。RichText の引数に textScaleFactor: MediaQuery.of(context).textScaleFactor を渡すことで文字サイズが変化します。

通常時の見た目 310%まで拡大した見た目

Good

RichText(
  textScaleFactor: MediaQuery.of(context).textScaleFactor,
  text: TextSpan(
    children: [
      TextSpan(text: 'G', style: Theme.of(context).textTheme.headline3),
      const TextSpan(text: 'OO'),
      TextSpan(text: 'D', style: Theme.of(context).textTheme.headline3),
    ],
  ),
);

Bad

RichText(
  text: TextSpan(
    children: [
      TextSpan(text: 'B', style: Theme.of(context).textTheme.headline3),
      const TextSpan(text: 'a'),
      TextSpan(text: 'D', style: Theme.of(context).textTheme.headline3),
    ],
  ),
);

埋め込み部分は Scroll View にしておく

API 実行に失敗した時、このように汎用的な ErrorView を画面に埋め込むことはよくあると思います。

通常時の見た目 Good: 310%まで拡大した見た目 Bad: 310%まで拡大した見た目

この View は通常時では綺麗に見れますが、文字を大きくすると見切れてしまいます。そうすると リトライ ボタンを押せなくなり詰んでしまいます。そうならないように、念の為 SingleChildScrollView で包んであげるとスクロールしてリトライボタンを押すことができます。

Good

リトライボタンが見切れてしまいますが、スクロール可能なためボタンをタップすることができます。

SingleChildScrollView(
  child: Padding(
    padding: const EdgeInsets.all(20),
    child: Column(
      children: [
        Text('エラー', style: Theme.of(context).textTheme.titleLarge),
        const SizedBox(height: 20),
        const Text('Good - 不明なエラーが発生しました。リトライボタンをタップするか、時間を置いてお試しください。'),
        const SizedBox(height: 20),
        ElevatedButton(
          onPressed: () {},
          child: const Text('リトライ'),
        ),
      ],
    ),
  ),
);
}

Bad

画面下部のリトライボタンが見切れてしまい、ボタンもタップできずアプリの再起動以外に復帰手段がありません。

Padding(
  padding: const EdgeInsets.all(20),
  child: Column(
    children: [
      Text('エラー', style: Theme.of(context).textTheme.titleLarge),
      const SizedBox(height: 20),
      const Text('Bad - 不明なエラーが発生しました。リトライボタンをタップするか、時間を置いてお試しください。'),
      const SizedBox(height: 20),
      ElevatedButton(
        onPressed: () {},
        child: const Text('リトライ'),
      ),
    ],
  ),
);
}

おわり

東急ホテルズ の Flutter アプリ開発で対応した Text Scale Factor について記載しました。
オフにするのは楽ではあるのですが、特殊な UI でなければ多少の対応でアクセシビリティを高めることができます。やっていきましょう。

東急URBAN HACKS

Discussion