はじめに

株式会社 WinTicket のアプリチームの@b4tchknです。

競輪とオートレースの投票ができるWINTICKETの Flutter アプリは、使いやすさを意識した UI/UX やレース情報を閲覧し投票を完了するまでの早さを意識したパフォーマンスなど様々な箇所にこだわりを持って開発しました。

今回の記事では、WINTICKET の Flutter アプリで対応した accessibility(a11y) について、特にその中でも特に注力して取り組んだ Text Scale Factor を変更した際のレイアウトの表示崩れに関して紹介します。

別のトピックについてもメンバーがブログを書いていますので、ぜひ御覧ください。

お手元に Android 端末がある方はぜひ、Google Play ストアからインストールして頂き、触りながらこの記事を読んで頂くと今回対応した内容が直感的にわかります。

また、Flutter アプリ開発における a11y に関しては弊社の @akihisasen が以前 FlutterKaigi2021 で登壇していますので、そちらも合わせて見て頂くとより理解が深まると思います。 アクセシビリティが高い Flutter アプリケーションを開発する @FlutterKaigi2021

※ 記事内で登場するスクリーンショット画像は全て Android 12 を搭載した Pixel 4 で撮影した画像です。

Text Scale Factor とは

Text Scale Factor とは、a11y 界隈の一般的な用語ではなく Flutter を用いたアプリ開発で出てくる用語です。 端末のテキストサイズで設定されているフォントの倍率値のことです。 Flutter のアプリでは Text Scale Factor を用いて、論理ピクセルでのフォントサイズが決定されます。 端末や OS によって設定方法は異なりますが、iOS・Android 共に設定からテキストサイズを変更することできます。

前述の設定値が Flutter のアプリ内で使用される Text Scale Factor の値になります。

WINTICKET アプリと Text Scale Factor

WINTICKET は、レース締め切り時刻までの限られた時間の中で予想に必要な多くのデータをユーザーに提供するため、レース情報などの膨大な情報を高密度で表示する必要があります。

出走表画面 投票シート画面
競輪詳細出走表画面のスクリーンショット 競輪詳細投票シート画面のスクリーンショット

さらにサービスの性質上、出走表、オッズ表、着順の表など細かく区切られたテーブルのセルの中に細かい文字が入るケースが多いです。

このような場合でも Text Scale Factor が変化したときに表示崩れを起こすことなく、ロービジョンのユーザーが健常者と同じようにアプリを使用できる必要がありました。

Flutter 版 Android アプリをリリースする前に実施したベータテストで、テキストサイズを拡大してアプリを使用しているユーザーから表示崩れに対するお問い合わせを数多く頂きました。お問い合わせ結果を参考にして、アプリで担保すべき項目と位置付けをして対応を行いました。

Text Scale Factor の使い方

MediaQueryData から現在の Text Scale Factor の値を取得できます。

実際に取得するときのコードは以下です。

final textScaleFactor = MediaQuery.of(context).textScaleFactor;

例えば、textScaleFactor の値が 1.5 であればデフォルトのテキストサイズよりも 50%大きくなります。

また Widget ごとに Text Scale Factor の値を固定できます。 以下のコードの場合、MediaQuery の子孫の Widget では Text Scale Factor は 1.0 で固定になります。

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);

    return MediaQuery(
      data: mediaQuery.copyWith(
        textScaleFactor: 1.0, // ここで固定
      ),
      child: ...,
    );
  }

Android の表示サイズ拡大への対応

「Text Scale Factor とは」のセクションで iOS と Android 端末のテキストサイズの設定の仕方を紹介しましたが、 テキストサイズの設定に加えて iOS では拡大表示、 Android では表示サイズという別の設定項目があります。 表示サイズを大きくすることによってアプリの表示領域が小さくなり、ボタンやテキストが大きく表示されているように見えます。 表示サイズの調整ができることで、ユーザーは好みの表示サイズでアプリを使用できるようになっています。 例えば、Pixel 4 の Android 12 では表示サイズは デフォルト特大 の 4 段階で設定できます。

ちなみに、WINTICKET の Flutter アプリで表示サイズを最大まで拡大させた場合、以下のような違いになります。

テキストサイズ:デフォルト
表示サイズ:デフォルト
テキストサイズ:デフォルト
表示サイズ:特大
テキストサイズがデフォルト、表示サイズがデフォルトの競輪トップ画面のスクリーンショット テキストサイズがデフォルト、表示サイズが特大の競輪トップ画面のスクリーンショット

表示サイズを拡大した場合、小さい画面サイズの端末でアプリを実行したようになります。 表示サイズの設定もレイアウト崩れの原因になります。

具体的にどのくら拡大されたような状態になるかは MediaQueryDatasize で取得できます。

final screenSize = MediaQuery.of(context).size;

例えば、Android 12 を搭載した Pixel 4 で表示サイズをデフォルトのまま実行すると Size(392.7, 813.1) 、特大に変更した場合は、Size(320.0, 659.6) が得られます。

Text Scale Factor の対応方針

「WINTICKET アプリと Text Scale Factor」で説明した通り、WINTICKET の Flutter アプリでは Android 端末のテキストサイズと表示サイズを最大まで拡大した場合の対応が必要でした。

そこでいくつか対応方針をチームで定めました。 具体的な対応事例は次の章で紹介しますが、以下の方針を決めました。

  • アプリ全体で表示サイズを考慮して Text Scale Factor の上限値を設定する
  • テキストが他の UI に被ったり、画面からはみ出たりするような情報欠損を起こさない
  • 金銭取引と法的行為に関するテキストは省略/途中改行しない
  • Widget のサイズ固定と Text Scale Factor の固定は避け、固定するときは最終手段にする
  • 上記の対応でも対応しきれない場合は、Text Scale Factor の値に応じて Widget のレイアウトを変える

Text Scale Factor の上限値

iOS 端末のテキストサイズ設定を最大にしたとき、Text Scale Factor の値が 3~4 と非常に大きくなるため、表示崩れに対応できない画面がありました。 また、Android 端末の表示サイズとテキストサイズをどちらも最大にした場合も同様です。 サービスの性質上、UI の情報密度が高い画面が多く既存 UI では却って視認性が悪化する懸念がありました。 そのため、レース情報閲覧から投票までのメインループを確認してアプリ全体で適切な Text Scale Factor の上限値を設定しました。

金銭取引と法的行為に関するテキスト

また、WINTICKET ではレース結果の払戻金額やユーザーが所持しているポイント数など、情報欠損を起こしてはいけない金銭取引と法的行為に関するテキストが様々な箇所で表示されます。 これらの数字は、途中で改行をするとユーザーが誤った金額でレースに投票するなどのリスクがあるため、1 行で収まるようにしています。 桁数が増えた場合も正確に全ての桁数を表示する必要があるため、文末三点リーダーを禁止して、テキストサイズを動的に小さくして 1 行に収めるような対応をしています。 それでも、数字が小さくなりすぎて視認性が低いときは Text Scale Factor に応じてレイアウトを変え、数字を表示する十分な領域を確保して視認性を担保するようにしています。

WINTICKET アプリの対応事例

対応方針をもとに Flutter でどのように対応したのか具体的な対応事例をいくつか紹介します。

表示サイズを考慮した Text Scale Factor の上限値設定の対応

最初にアプリ全体の Text Scale Factor の上限値の設定を行いました。

TextScaleFactor という Widget を作って、MaterialApp.builder を介してアプリ全体の Text Scale Factor の上限値を設定しています。 具体的な上限値は下の表を見て頂ければわかると思いますが、アプリの表示領域の横幅ごとに決めていてます。 各上限値に関しては、実際に UI を確認しながら調整した結果です。

表示領域の横幅 ~320 320 ~ 370 370 ~
TextScaleFactor の最大値 1.1 1.2 1.5
実際のソースコード

class TextScaleFactor extends StatelessWidget {

...

static const _maxTextScaleFactor = 1.5;
static const _midTextScaleFactor = 1.2;
static const _minTextScaleFactor = 1.1;
static const _minDeviceSizeWidth = 320.0;
static const _maxDeviceSizeWidth = 370.0;

final Widget child;

@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);

    // TextScaleFactorの最大値
    // 370px以上:1.5
    // 370px未満 ~ 320以上:1.2
    // 320px未満:1.1
    final screenWidthSize = MediaQuery.of(context).size.width;
    double upperTextScaleFactor;
    if (screenWidthSize >= _maxDeviceSizeWidth) {
      upperTextScaleFactor = _maxTextScaleFactor;
    } else if (screenWidthSize >= _minDeviceSizeWidth) {
      upperTextScaleFactor = _midTextScaleFactor;
    } else {
      upperTextScaleFactor = _minTextScaleFactor;
    }

    final textScaleFactor =
        mediaQuery.textScaleFactor.clamp(1.0, upperTextScaleFactor).toDouble();

    return MediaQuery(
      data: mediaQuery.copyWith(
        textScaleFactor: textScaleFactor,
      ),
      child: child,
    );

}
}

上限値の設定により上限値以上で発生する表示崩れがなくなったので、上限値以内で発生する表示崩れやテキストの見切れる問題を対応していきました。

金額やポイント表示部分の対応

対応方針として、この部分の数字は TextoverflowTextOverflow.ellipsis を設定して文末三点リーダーにすることを禁止しています。 そのため基本的に FittedBox でラップして Text Widget が確保している領域内に全桁収まるよに実装しています。

桁数が少ない場合 桁数が多い場合
桁数が少ない場合のUIコンポーネント 桁数が多い場合のUIコンポーネント

しかし、それでも Text Widget が確保している領域が狭いと、桁数が多くなった場合にテキストサイズが極端に小さくなり視認性が低くなってしまうケースがあります。 その場合はまず、同じ画面内に視認性が低くなってしまっている情報を補完するものがないかを考えます。

例えば、チャージ画面にある金額入力のショートカットボタンは、テキストサイズが大きくなった際に視認性が低くなってしまっていました。 テキストサイズと表示サイズを拡大した場合、デザインの都合上これは避けられなかったのですが、仮にユーザーがこのボタンテキストが見えなかったとしても入力フォームで機能を補えると判断したため、Text Scale Factor の値を固定にしています。

チャージ画面のスクリーンショットの説明

もし補完する機能や情報が同じ画面内になかった場合は、 次に紹介する「Text Scale Factor に応じてレイアウト変更の対応」を行います。

Text Scale Factor に応じたレイアウト変更の対応

金額などの数字が FittedBox を使った場合に視認性が低くなってしまったり、デザイン上 Text Scale Factor の対応が難しい場合はレイアウトを変更する対応をしています。 具体的には、Text Scale Factor に応じてデザイン上確保している padding の数値を調整したり、全く別のレイアウトを表示する対応をしています。

例えば、トップの 開催中のレース のカードですが対応前は他の UI にテキストが隠れてしまっていました。 対応後はテキストの情報欠損を回避することを最優先として判断し、周りの padding の値を調整しています。

対応前 対応後
対応前の競輪トップ画面のスクリーンショット 対応後の競輪トップ画面のスクリーンショット

padding の調整でもデザインの都合で対応が難しい場合は、画面のサイズ幅に応じて全く別のレイアウトを出し分けるようにしています。

下のような投票ボックス画面では、買い目の数を増やして払戻金と収支の桁数が増えた場合の対応が困難でした。 Text Widget が確保している横幅も狭いため FittedBox を使うと、テキストサイズが小さくなりすぎて視認がほぼ不可能でした。 そのため、以下のように MediaQuery.of(context).size.width (サイズ幅) に応じて Widget を出し分ける対応をしました。

サイズ幅が 320px 以上 サイズ幅が 320px 未満
テキストサイズがデフォルト、表示サイズがデフォルトの投票ボックス画面のスクリーンショット テキストサイズがデフォルト、表示サイズが特大の投票ボックス画面のスクリーンショット

テーブル UI の対応

出走表などテーブルの UI が数多くあるのですが、対応前はキストサイズと表示サイズを拡大した場合セルの中のテキストが不自然な位置で改行が起きていました。

実装上、テーブルの宣言時に各セルの高さと横のピクセル数を固定にする必要があったため発生していました。 そのため、 セルの高さ(幅) = 高さ(幅) * Text Scale Factor のようにして Text Scale Factor に応じてセルのサイズを可変にするようにしています。 対応前後のスクリーンショットは以下の通りです。

対応前 対応後
対応前の競輪詳細出走表画面のスクリーンショット 対応後の競輪詳細出走表画面のスクリーンショット

運用

リプレース後の現在の運用ですが、対応方針をもとに各開発メンバーが Text Scale Factor を意識した開発をすることで品質を担保しています。 基本的なサイズ固定をしていなかったり、金額の数字の桁数が多くなった場合の対応ができていなかったりする場合は PR で指摘しています。 それでもどうしても漏れるケースはあるため、定期的な表示サイズとテキストサイズを最大にして実機テストを実施したり、弊プロジェクトでは VRT(Visual Regression Testing) を導入をしているため、Text Scale Factor を上げたときの SnapshotTest ができないか検討しています。

まとめ

WINTICKET の Flutter アプリで取り組んだテキストサイズ周りの a11y 対応について紹介しました。 紹介した対応方針と具体的な Flutter での対応が、同じくテキストサイズへの対応に迷っている方の参考になれば幸いです。 サービスによりますが、WINTICKET の場合想像以上にテキストサイズと表示サイズを拡大しているユーザーがいました。 全てのユーザーが皆同じような体験でアプリを使ってもらえるように積極的に対応していきたいです。