見出し画像

Flutterアクセシビリティ実装ハンズオン(後編)


はじめに


こんにちは、株式会社SHIFT DAAEテクノロジーグループでFlutterアプリ開発をしている大矢です。
前編に引き続き、今回は前編で確認したサンプルアプリのアクセシビリティ実装の不備を修正していきます。
前編をお読みで無い方は、先にお読みください。

要約

  • RichTextではなくText.richを使うと、拡大縮小に自動的に対応できる。

  • 色指定はできる限りThemeを使って実装する。独自の色指定もThemeDataにセットして使う。

    • 色指定の管理がしやすくなるだけでなく、適当なコントラストを保ちやすくなる。

  • ボタンを実装するときは、標準のButton系Widgetを使うとタップ範囲や読み上げで悩みにくい。

サンプルアプリ修正


前編でアクセシビリティ検証ツールでの検証と、Widgetテストの実装が終わりました。
両者に指摘された内容に沿って、アプリを修正していきます。

可変フォント確認

テストコードを使ってアプリを修正していく前に、前編でテストが書けないと説明した「Large fonts」の観点について確認していきます。
実は、サンプルアプリにはこの観点に違反した実装を仕込んであります。
以下はOS設定でフォントサイズを大きくした状態のスクリーンショットです。

OS設定でフォントサイズを大きくした場合の、サンプルアプリのログイン画面

画像を見ていただくとわかるかと思いますが、フォントサイズを大きくしたことで以下の問題が発生しました。

  1. RichTextで実装している「Click here ~ password」の大きさが変わっていない

  2. Login,Sign upボタンの文字がボタンの枠内に収まらなくなっている

いずれもアクセシビリティ検証ツールでも検出されませんが、よくない挙動です。

RichTextからText.richへの切り替え

1の対応は、デザインを全く変えず、かつOSのフォントサイズ設定を反映させるだけであれば極めて簡単です。
RichText Widgetを使わずText Widgetのrichコンストラクタを使います。

// 変更前
RichText(
  text: TextSpan(
    children: [
      ...
    ],
  ),
);
// 変更後
Text.rich(
  TextSpan(
    children: [
      ...
    ],
  ),
);

画像のように、Text.richに置き換えたことで文字列が拡大されました。

Text.richへの置き換えが反映された、サンプルアプリのログイン画面

サイズ指定を柔軟にする

2の対応は、ボタンの枠を構成しているContainerの大きさが固定値で指定されていることによるものです。
こちらは端末差異による画面サイズの違い等でも問題になりやすいところなのでご存じの方が多いと思いますが、今回はConstrainedBoxを使って実装してみます。

※ 今回の場合Containerの高さに何も指定しないことでもフォントサイズの変化に対応することは可能ですが、追ってタップ範囲の大きさ調整も行うため、サイズ指定しておきます。

// 修正前
Container(
  height: 40,
  width: 365,
  ...
),

// 修正後
ConstrainedBox(
  constraints: const BoxConstraints(minHeight: 40, maxWidth: 365),
  child: Container(
    // width,height指定は削除
    ...
  ),
)

画像のように、文字が大きくなっても、ボタンの枠が拡大して枠内に収まるようになります。

ConstrainedBoxによる実装後のサンプルアプリのログイン画面(文字を大きくした場合)

また、フォントサイズを元に戻すと縮小します。

ConstrainedBoxによる実装後のサンプルアプリのログイン画面(文字を小さくした場合)

注意点

なお、残念なことに上記修正を入れるとボタンタップ範囲のテストが通るようになります。
しかし実際に上記コードをデバッグ実行すると、フォントサイズがデフォルトのときタップ範囲の高さは41dp程度で、Androidのアクセシビリティ検証ツールでは指摘対象になります。

この辺りがWidgetテストの挙動の厄介なところで、BoxConstraintsのmaxHeightに基準値(Androidで48,iOSで44)未満を設定すると当初の通りテストが通らなくなります。
しかし、テストのためにBoxConstraintsのmaxHeightを設定してしまうと、せっかく先ほど対応したフォント拡大時のボタン拡大が機能しなくなるため、maxHeightは設定しないまま進みます。

ラベル設定

次に、ラベル設定を見直していきましょう。

ログイン画面

まずログイン画面ですが、先ほど実装したWidgetテストではラベル関連のエラーはありません。
ただ、Androidのアクセシビリティ検証ツールで、LoginボタンのラベルがAppBarのタイトルと重複しているという指摘がありました。
この指摘の直接的な対応(AppBarのタイトルを変えるなど)をしても良いのですが、その前に実際に読み上げ音声を聞いてみましょう。

  • AndroidでTalkBackをオンにしてアプリを操作してみると、AppBarの方は「ログイン 見出し」と読まれ、ボタンの方は「ログイン 有効にするにはダブルタップします」と読まれます。

  • iOSでAccessibility Inspectorの読み上げ機能を使って操作してみると、AppBarの方は「ログイン Header」と読まれます(若干発音が変ですが)。ボタンの方は「ログイン」とだけ読まれます。

Widgetによるこれらの違いですが、AppBarのタイトルに指定した文字列は自動的に見出し属性になり、さらにAndroidではonTapアクションが設定されているWidgetはアクションに対する説明が追加されるためと考えられます。(ちなみに、onTapをonDoubleTapやonLongTapに変えると音声も変わります)

iOSは一旦置いておくとして、少なくともAndroidでは「見出し」と「有効にするには〜」で読み上げが明確に違うので、「ログイン」の部分が同じでも別物であるという区別がつくように思われます。

では、属性の違いでさらにはっきり読み上げに区別をつけるというアプローチで、Semantics Widgetを使ってLoginボタンにボタン属性をつけてみましょう。

以下のように、Semantics Widgetを付け加えるだけです。

class LoginButton extends StatelessWidget {
  const LoginButton();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return Semantics(
      button: true,
      child: GestureDetector(
        ...
      ),
    );
  }
}

この変更で、以下のように読み上げられるようになります。

  • Androidでは「ログイン ボタン 有効にするにはダブルタップします」

  • iOSでは「ログイン ボタン

いずれも、ボタンであることが明言されるようになりました。
サインアップボタンも同じ要領でボタンであることをわかりやすくすることができます。

リスト画面

リスト画面には、そもそもラベルがないWidgetが2つありました。

  1. SingleChildScrollView Widgetで作ったリストWidget

  2. 追加ボタン("+"アイコンを表示したボタン)

いずれも、Semantics Widgetでラベルと属性をつけることができます。
こちらも読み上げを聞いてみると、設定したSemanticsに沿った読み上げがされます。

// リストウィジェット
Semantics(
  label: 'リストウィジェット',
  child: SingleChildScrollView(
    ...
  ),
)

// ボタン
Semantics(
  label: '追加ボタン',
  button: true,
  child: GestureDetector(
    ...
  ),
)

コントラスト

次にコントラスト設定をします。 今回コントラストの指摘を受けているのは検証ツール、Widgetテストを合わせてログイン画面の3箇所です。
そのうち、パスワードリセットボタンの対応は後々Widget自体を変える対応を入れますので一旦スキップし、ここではテキストフィールドのヒントテキストのみ対応します。 テキストフィールドのヒントテキストについては対応は極めて簡単で、あえて指定していたTextStyleを削除するだけです。

// 修正前
TextField(
  decoration: InputDecoration(
    hintText: 'Email address',
    hintStyle: TextStyle(color: Colors.grey),
  ),
)

// 修正後
TextField(
  decoration: InputDecoration(
    hintText: 'Email address',
  ),
)

これで自動的に背景色と適当なコントラストを保った色が指定されます。(背景色もテーマの範囲内で実装している場合)

ただ、デザイン指定でヒントテキストの色に指定がある場合、ただデフォルト色にすればよいというわけではないと思います。
その場合はデザイナーの方との調整の問題になるでしょうから、コントラストを保った新しい色を指定してもらうか、コントラスト対応を諦めるかになると思われます。

そして、色をWidgetごとに個別に指定するのではなくカラーテーマとして管理してMaterialApp Widgetのtheme(darkTheme)に指定してあげると、アプリ全体で統一的に実装しやすくなるはずです。
例えば、ヒントテキストであればThemeData.hintColorで設定できます。
テーマについては当記事では深掘りしません。

参考:

ただテーマ関連で一点だけ紹介したいのが、背景色と前景色の簡単な指定方法です。
実は最初から、ログイン、サインアップボタンなどで行っていますが、背景色にTheme.of(context).colorScheme.primaryやsecondary、backgroundなどの色指定をし、前景色にonPrimary,onSecondary,onBackgroundなど対応する色指定を行うと、簡単にコントラストを保った背,前景色の指定が可能です。

タップ範囲

最後にタップ範囲を変更します。
タップ範囲について、実はこれまで頑なにButton系Widgetを使ってこなかったのですが、Button Widgetに置き換えられるところは置き換えて対応していきます。

パスワードリセットボタン

パスワードリセットボタンは、現在縦にも横にもタップ範囲が短い状態になっていますが、"here"の部分だけにタップイベントを設定している以上、十分な範囲を確保することはほぼ不可能です。
ここは現状のデザインを維持することは諦めて、TextButton Widgetで置き換えていきます。
(RichTextとText.richのくだりのためだけにこのような形にしていました・・・)

// 修正前
class ForgotPasswordText extends StatelessWidget {
  const ForgotPasswordText();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return Text.rich(
      TextSpan(
        children: [
          TextSpan(
            text: 'Click ',
            style: TextStyle(color: colorScheme.onBackground),
          ),
          const WidgetSpan(child: PasswordResetButton()),
          TextSpan(
            text: ' if you forgot your password',
            style: TextStyle(color: colorScheme.onBackground),
          ),
        ],
      ),
    );
  }
}

class PasswordResetButton extends StatelessWidget {
  const PasswordResetButton();

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {},
      child: Container(
        color: Colors.white,
        width: 40,
        child: const Text(
          'here',
          style: TextStyle(color: Colors.grey),
        ),
      ),
    );
  }
}

//修正後 クラス名を変更しました。 
class ForgotPasswordTextButton extends StatelessWidget {
  const ForgotPasswordTextButton();

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () {},
      child: const Text('Click here if you forgot your password'),
    );
  }
}

上記のように、たくさんコードを書いていたのを、TextButton Widgetで一つにまとめました。
もちろんデザインをどの程度維持するかやボタンタップ時のアニメーションの有無などカスタマイズの要素はありますが、ここでご紹介したいのは、Button系Widgetを使っておけば基本的にタップ範囲が自動的に十分な大きさになるという点です。
上記の例はTextButton Widgetの子にText Widgetを指定しただけですが、横幅だけでなく高さも48dp以上になっているはずです。
このように、標準Widgetをできるだけ使って実装することも漏れなくアクセシビリティ対応をするために有用です。

ログイン、サインアップボタン

次にログインボタンもボタン系Widgetで対応したいのですが、どのWidgetを使うのかが若干悩ましいところです。
IconButtonやOutlineButtonでは、ボタンの周囲やタップ時のアニメーションで現状のデザインからすると不要な表現が追加されます。
今回の場合はElevatedButtonを使ってみますが、この場合でも少しカスタマイズが必要です。
個人的には使うWidgetは現状のままにしておいて、ConstrainedBoxのminHeightを48以上にするのもなくはない選択肢だと思います。

// 変更前
Semantics(
  button: true,
  child: GestureDetector(
    onTap: () {
      ...
    },
    child: ConstrainedBox(
      constraints: const BoxConstraints(minHeight: 40, maxWidth: 365),
      child: Container(
        ...
      ),
    ),
  ),
);

// 変更後
ElevatedButton(
  style: ButtonStyle(
    shape: MaterialStateProperty.all(
      RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(10), // 子要素の枠線と形が一致するように変更
      ),
    ),
    padding: MaterialStateProperty.all(EdgeInsets.zero), // パディングを0に変更
  ),
  onPressed: () {
    ...
  },
  child: ConstrainedBox(
    // ElevatedButtonによって最低の高さが48dp以上になるので、minHeight指定は削除
    constraints: const BoxConstraints(maxWidth: 365), 
    child: Container(
      ...
    ),
  ),
);

上記のようにGestureDetectorで実装していたタップイベントをElevatedButton Widgetに置き換えましたが、以前の変更で加えていたSemantics Widgetを削除しました。
これは、ElevatedButton Widgetを使うだけでボタン属性が付与され、読み上げにも「ボタン」という文言が入るためです。
サインアップボタンも同様に変更します。
これで、ログイン画面は全ての対応が完了しました。

追加ボタン

最後に、リスト画面の追加ボタンもタップ範囲を修正します。
これまでと同様Button系Widgetに置き換えるため、IconButton Widgetを使います。

// 修正前
Semantics(
  label: '追加ボタン',
  button: true,
  child: GestureDetector(
    onTap: () {},
    child: Container(
      height: 38,
      width: 38,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        color: colorScheme.primary,
      ),
      child: Icon(
        ...
      ),
    ),
  ),
);

// 修正後
IconButton(
  style: IconButton.styleFrom(
    backgroundColor: colorScheme.primary,
  ),
  onPressed: () {},
  icon: Semantics(
    label: '追加ボタン',
    child: Icon(
      ...
    ),
  ),
);

元々が意図的に回りくどい書き方だったのですが、追加ボタンをIconButton Widgetを使って書き換えました。
これで、タップ範囲の大きさは縦横とも48dp以上になり、アクセシビリティ検証ツールに引っ掛からなくなります。

テスト実行

ここまでで修正対象に挙げた指摘は全て修正が完了しましたので、テストも全て通るはずです。
PasswordResetButtonがForgotPasswordTextButtonに変わっている点に注意し、テストを再実行してみてください。

最終的なコード

アプリ本体の最終的なコードを貼っておきます。

// main.dart
import 'package:accessibility/login_page.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Accessibility Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          brightness: Brightness.dark,
          seedColor: Colors.blue,
        ),
      ),
      home: const LoginPage(),
    );
  }
}
// login_page.dart
import 'package:accessibility/list_page.dart';
import 'package:flutter/material.dart';

class LoginPage extends StatelessWidget {
  const LoginPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Login'),
        centerTitle: true,
      ),
      body: const Center(
        child: Padding(
          padding: EdgeInsets.all(15),
          child: Column(
            children: [
              EMailTextField(),
              SizedBox(height: 10),
              PasswordTextField(),
              SizedBox(height: 10),
              ForgotPasswordTextButton(),
              SizedBox(height: 10),
              LoginButton(),
              SizedBox(height: 20),
              SignUpButton(),
            ],
          ),
        ),
      ),
    );
  }
}

class EMailTextField extends StatelessWidget {
  const EMailTextField();

  @override
  Widget build(BuildContext context) {
    return const TextField(
      decoration: InputDecoration(
        hintText: 'Email address',
      ),
    );
  }
}

class PasswordTextField extends StatelessWidget {
  const PasswordTextField();

  @override
  Widget build(BuildContext context) {
    return const TextField(
      decoration: InputDecoration(
        hintText: 'Password',
      ),
      obscureText: true,
    );
  }
}

class ForgotPasswordTextButton extends StatelessWidget {
  const ForgotPasswordTextButton();

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () {},
      child: const Text('Click here if you forgot your password'),
    );
  }
}

class LoginButton extends StatelessWidget {
  const LoginButton();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return ElevatedButton(
      style: ButtonStyle(
        shape: MaterialStateProperty.all(
          RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(10),
          ),
        ),
        padding: MaterialStateProperty.all(EdgeInsets.zero),
      ),
      // ListPageを表示
      onPressed: () {
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => const ListPage(),
          ),
        );
      },
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 365),
        child: Container(
          padding: const EdgeInsets.all(10),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(10),
            color: colorScheme.primary,
            boxShadow: [
              BoxShadow(
                color: colorScheme.shadow,
                blurRadius: 3,
              ),
            ],
          ),
          child: Center(
            child: Text(
              'Login',
              style: TextStyle(fontSize: 15, color: colorScheme.onPrimary),
            ),
          ),
        ),
      ),
    );
  }
}

class SignUpButton extends StatelessWidget {
  const SignUpButton();
  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return ElevatedButton(
      style: ButtonStyle(
        shape: MaterialStateProperty.all(
          RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(10),
          ),
        ),
        padding: MaterialStateProperty.all(EdgeInsets.zero),
      ),
      onPressed: () {},
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 365),
        child: Container(
          padding: const EdgeInsets.all(10),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(10),
            color: colorScheme.secondary,
            boxShadow: [
              BoxShadow(
                color: colorScheme.shadow,
                blurRadius: 3,
              ),
            ],
          ),
          child: Center(
            child: Text(
              'Sign up',
              style: TextStyle(
                fontSize: 15,
                color: colorScheme.onSecondary,
              ),
            ),
          ),
        ),
      ),
    );
  }
}
// list_page.dart
import 'package:flutter/material.dart';

class ListPage extends StatelessWidget {
  const ListPage();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('List'),
        centerTitle: true,
      ),
      body: const Stack(
        children: [
          ItemList(),
          Align(
            alignment: Alignment.bottomRight,
            child: Padding(
              padding: EdgeInsets.only(bottom: 50, right: 30),
              child: AddButton(),
            ),
          ),
        ],
      ),
    );
  }
}

class ItemList extends StatelessWidget {
  const ItemList();
  @override
  Widget build(BuildContext context) {
    return Semantics(
      label: 'リストウィジェット',
      child: SingleChildScrollView(
        child: Column(
          children: List.generate(
            15,
            (index) => const TileComponent(),
          ),
        ),
      ),
    );
  }
}

class TileComponent extends StatelessWidget {
  const TileComponent();

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 15),
      child: ListTile(
        onTap: () {},
        leading: ClipRRect(
          borderRadius: BorderRadius.circular(10),
          child: Image.network('https://picsum.photos/200/200'),
        ),
        title: const Text('List Title'),
      ),
    );
  }
}

class AddButton extends StatelessWidget {
  const AddButton();
  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return IconButton(
      style: IconButton.styleFrom(
        backgroundColor: colorScheme.primary,
      ),
      onPressed: () {},
      icon: Semantics(
        label: '追加ボタン',
        child: Icon(
          Icons.add,
          color: colorScheme.onPrimary,
        ),
      ),
    );
  }
}

まとめ


いかがだったでしょうか。
サポートを必要とする当事者の視点に立つと検討の余地もあるでしょうが、そこは今後の課題としたいと思います。
実務においては、デザイナーとエンジニアの協調も必要であり、このような知見を互いに共有することも重要であると思います。

本記事が読者の方のお役に立てば幸いです。

\もっと身近にもっとリアルに!DAAE 公式 X/


執筆者プロフィール:大矢 茂士
SHIFT DAAE(ダーエ)テクノロジーグループ所属の開発エンジニアです。

お問合せはお気軽に

SHIFTについて(コーポレートサイト)

SHIFTのサービスについて(サービスサイト)

SHIFTの導入事例

お役立ち資料はこちら

SHIFTの採用情報はこちら

PHOTO:UnsplashAlex Presa