Flutterアクセシビリティ実装ハンズオン(前編)
はじめに
こんにちは、株式会社SHIFT DAAEテクノロジーグループでFlutterアプリ開発をしている大矢です。
Flutterアプリにおけるアクセシビリティ実装を調べてみましたので、今回はアクセシビリティ対応が不十分なサンプルアプリをステップバイステップで修正するハンズオン形式で記事にしてみました。
前後編で構成し、前編ではサンプルアプリの問題点の確認まで、後編でアプリの修正を行います。
環境のある方はぜひFlutterプロジェクトを新規作成して手を動かしてみてください。
必要なもの
Mac or WindowsPC
iOS用アプリの実行・検証にMacが必要ですが、Androidのみの場合はWindowsでも可能です。Android端末 or Androidシミュレータ
途中、Play Storeからのアプリのインストールが必要なため、実機の方がおすすめです。
要約
Flutterはフレームワークレベルでアクセシビリティ実装のための機能が用意されているので、効果的に使う方法を押さえておきましょう。
アクセシビリティ実装ガイドラインに準拠しているかどうかは、実行プラットフォームごとの検証ツールやWidgetテストで確認可能。
ただし、検証ツールとWidgetテストの結果が一致しない場合もあるので注意。
Flutter アクセシビリティ実装の基本情報
この項ではアクセシビリティ実装を始める前に、基本的な情報を確認します。
Flutterにおけるアクセシビリティ実装については、公式ドキュメントにガイドがあります。
まずはここを見ていきましょう。
標準的にサポートされる3要素
ガイドによると、Flutterでは以下三つの機能がフレームワークとしてサポートされています。
Large fonts
Screen readers
Sufficient contrast
Large fonts
Android、iOSともにOSの設定でフォントサイズを変更することができます。
例:Androidの設定画面
この設定をFlutterアプリにも適用する機能が標準で用意されています。
たとえば、Textウィジェットでは特に何もしなくても普通に使うだけでOS設定によってフォントサイズが変化します。
Screen reader
音声読み上げ機能です。AndroidならTalkBack、iOSならVoiceOverという機能があります。
こちらも特別意識しなくてもある程度対応できるようになっており、Textウィジェットの表示テキストや、BottomNavigationBarItemクラスのlabel要素、Tabウィジェットのtext要素などで自動的に読み上げラベルが設定されます。
ただ、時により表示する文字列と読み上げ内容を変えたいことがあると思います。そのような場合は、ウィジェットに用意されている読み上げ用ラベルのオプションを使ったり、Semanticsウィジェットを使うことができます。
const Text(
'月', // 画面上は"月"と表示する
semanticsLabel: '月曜日', // 読み上げでは"月曜日"と読む
),
上記の例では、Textウィジェットに予め用意されているsemanticsLabelオプションを使います。semanticsLabelを指定しない場合画面に表示する文字列を発声するため(OSによる場合がありますが)「つき」と読み上げられてしまい、何のことかわからない可能性があります。
そこで、semanticsLabelに省略形ではない「月曜日」を当てることで、より確実に意味を伝えることができます。
Semantics(
label: 'ボタンの振る舞いなど',
child: IconButton(
icon: NoTextIcon(), // 任意の文字がないウィジェット
onPressed: () {
// タップ時の処理
},
),
),
上記の例では、IconButtonのiconに文字列がないイラスト等のアイコンがセットされています。
子要素にTextウィジェット等読み上げ機能に対応するウィジェットがあれば読み上げが自動的に実装可能ですが、そうでない場合はSemanticsウィジェットを使ってラベルをつけてあげる必要があります。
Semanticsウィジェットには多数のプロパティがあり、対象のウィジェットがどのような役割を持つのかを説明したり、追加の振る舞いを与えることができます。以下は一部例です。
なお、Semanticsの子ウィジェットによっては、以下のような属性も自動的に付く場合があり、テストで想定通りの属性が付いているか(付き過ぎていないか)確認する必要があります。
button
trueにすると、「ボタンである」とマークされ、読み上げ時に各OSごとの表現でタップ操作が可能であることが案内されます。checked
bool値を与えることで、チェック済みかチェックされていないかが案内されます。チェックボックス等で使います。toggled
bool値を与えることで、オンかオフかが案内されます。オンオフ切り替えスイッチ等で使います。
その他にも、さまざまなアクションに対してコールバックを設定することもできます。
コールバックで追加の音声読み上げ等を実行しても良いかもしれません。
参考:
Sufficient contrast
背景色と前景色のコントラストが十分であるかどうかという観点です。 ボタンやテキストフィールドのヒントテキストなど、特に何も指定しなければアプリのテーマカラーを使って十分なコントラストを確保して描画するウィジェットが多数あります。
とはいえ、必ずしもテーマのみに沿ったデザインができない場合もあり、注意が必要です。
コントラストが確保できているかのチェックは後述します。
Flutterのドキュメントにも書いてある通りですが、フォントの大きさに応じてW3Cが推奨するコントラスト比があります。
原則、この推奨値に沿ったデザインをすることが望ましいです。
At least 4.5:1 for small text (below 18 point regular or 14 point bold)
At least 3.0:1 for large text (18 point and above regular or 14 point and above bold)
テーマを使って推奨値を保って効率よく色指定を行う方法もあり、後編で紹介します。
アクセシビリティ検証ツールの紹介
Android、iOSそれぞれにアクセシビリティ対応ができているか確認するツールがあります。
いずれもFlutterドキュメントにも記載があるため、参照ください。
以後記事に沿ってハンズオンを行う方は、リンク先に沿ってツールのセットアップを行ってください。
サンプルアプリで実装
ここからは、私の作成したサンプルアプリを実際に動かしながら、アクセシビリティにおけるどのような問題があるのかを確認していきます。
環境のある方はコードをコピーしてお手元で動かしてみてください。
サンプルアプリ(初期状態)
サンプルアプリは以下のようなものを用意しました。
気持ち悪いUIもあると思いますが、後述する内容の仕込みもありますので一旦スルーしてください。
※ 基本的にUIの実装しか行っていません。
// 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),
ForgotPasswordText(),
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',
hintStyle: TextStyle(color: Colors.grey),
),
);
}
}
class PasswordTextField extends StatelessWidget {
const PasswordTextField();
@override
Widget build(BuildContext context) {
return const TextField(
decoration: InputDecoration(
hintText: 'Password',
hintStyle: TextStyle(color: Colors.grey),
),
obscureText: true,
);
}
}
class ForgotPasswordText extends StatelessWidget {
const ForgotPasswordText();
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return RichText(
text: 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: const SizedBox(
width: 40,
child: Text(
'here',
style: TextStyle(color: Colors.grey),
),
),
);
}
}
class LoginButton extends StatelessWidget {
const LoginButton();
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
// ListPageを表示
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ListPage(),
),
);
},
child: Container(
height: 40,
width: 365,
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 GestureDetector(
onTap: () {},
child: Container(
height: 40,
width: 365,
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 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 GestureDetector(
onTap: () {},
child: Container(
height: 38,
width: 38,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary,
),
child: Icon(
Icons.add,
color: colorScheme.onPrimary,
),
),
);
}
}
アクセシビリティ検証ツールの実行
Android
まずAndroidで上記のアプリを実行して、検証ツール(ユーザー補助検証ツール)を実行してみます。
すると、以下のように検証ツールからの提案が表示されました。
(ご自身の端末で確認する場合は、オレンジ枠内をタップすると詳細が表示されます)
ツールからの提案の要約は画像の下に列挙します。
ログイン画面
メールアドレスボックス、パスワードボックス
背景色とヒントテキストのコントラストが小さいパスワードリセットボタン
タップ範囲が縦に狭すぎるLoginボタン
タップ範囲が縦に狭すぎる
「Login」というラベルが他ウィジェット(AppBarのタイトルの"Login")と被っているSign upボタン
タップ範囲が縦に狭すぎる
リスト画面
リストタイル
複数のリストタイルのラベルが同じ
-> これは全てのタイルのタイトルが"List Title"であるためですが、実用的なアプリの場合このような タイトルは現実的ではないので、当記事ではスルーします。プラスボタン
ラベルが設定されていない
タップ範囲が縦横に狭すぎる
iOS
iOSでも同じアプリを実行して、検証ツール(Accessibility Inspector)を実行してみます。
iOSでは、Androidと指摘内容が異なりました。
ログイン画面
指摘なしリスト画面
スクロールビュー
ラベルが設定されていないプラスボタン
ラベルが設定されていない
OSによる違い
AndroidとiOSでは指摘内容に差が出ました。 違いは以下の通りです。
違いはありますが、Flutterアプリですので今回は原則全ての指摘に対応します。(ラベル被りは対応しません。)
コントラスト
Androidではメール、パスワード入力ボックスのヒントテキストのコントラストが不足している
iOSでは指摘されない
ボタン
Androidではボタンの縦横がそれぞれ48dp以上でないと小さすぎると指摘される
iOSでは指摘されない(20dp未満になるとiOSでも指摘される)
->この点は後述するFlutterのWidgetテストで確認する方法ではiOSは44dpが境目になるため、やや不思議な挙動です。
また、Apple公式のUIデザインガイドでも44 * 44が推奨されています。
スクロールビューへのラベル
Androidでは無くても何も指摘されない
iOSでは無いと指摘される
複数ウィジェットのラベル被り
Androidでは指摘される
iOSでは指摘されない
Widget Testでの確認
アクセシビリティ対応状況を機械的に確認する方法として、Widgetテストを書くことも可能です。
検査したいWidgetに対してWidgetテストを書くことで、以下の観点を確認できます。
ボタンのタップ範囲の大きさが一定以上か(AndroidとiOSそれぞれの基準で)
色のコントラストが十分に確保されているか
Semanticsラベルが設定されているか
つまり、ここまで説明してきたアクセシビリティ実装のための観点のうち、OS設定により変化するフォントサイズ(Large fonts)以外の観点は基本的にWidgetテストで確認可能です。
Widgetテストの書き方
基本的な書き方についても、Flutterのドキュメントに記載されています。
テスト作成
では、今回作成したサンプルアプリに対して、テストを作成してみます。
数ケースエラーになりますが、想定通りです。
// login_page_test.dart
import 'package:accessibility/login_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('コントラスト比', () {
testWidgets('✅Eメールアドレスフィールド', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_scaffold(const EMailTextField()));
// Androidのアクセシビリティ検証ツールでは、コントラスト比が不足していると指摘されるが、テストは通ってしまう
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
testWidgets('✅パスワードフィールド', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_scaffold(const PasswordTextField()));
// Androidのアクセシビリティ検証ツールでは、コントラスト比が不足していると指摘されるが、テストは通ってしまう
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
// エラーになる
testWidgets('✅パスワードリセットボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_scaffold(const PasswordResetButton()));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
testWidgets('✅ログインボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_material(const LoginButton()));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
testWidgets('✅サインアップボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_material(const SignUpButton()));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
});
group('タップ範囲', () {
// エラーになる
testWidgets('✅パスワードリセットボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_center(const PasswordResetButton()));
await expectLater(
tester,
meetsGuideline(androidTapTargetGuideline),
);
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
handle.dispose();
});
// エラーになる
testWidgets('✅ログインボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_center(const LoginButton()));
await expectLater(
tester,
meetsGuideline(androidTapTargetGuideline),
);
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
handle.dispose();
});
// エラーになる
testWidgets('✅サインアップボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_center(const SignUpButton()));
await expectLater(
tester,
meetsGuideline(androidTapTargetGuideline),
);
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
handle.dispose();
});
});
group('ラベル設定', () {
testWidgets('✅Eメールアドレスフィールド', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_scaffold(const EMailTextField()));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
handle.dispose();
});
testWidgets('✅パスワードフィールド', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_scaffold(const PasswordTextField()));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
handle.dispose();
});
testWidgets('✅ログインボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_material(const LoginButton()));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
handle.dispose();
});
testWidgets('✅サインアップボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_material(const SignUpButton()));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
handle.dispose();
});
testWidgets('✅パスワードリセットボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_material(const PasswordResetButton()));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
handle.dispose();
});
});
}
MaterialApp _material(Widget widget) => MaterialApp(
home: widget,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
);
MaterialApp _scaffold(Widget widget) => _material(Scaffold(body: widget));
MaterialApp _center(Widget widget) => _material(Center(child: widget));
// list_page_test.dart
import 'package:accessibility/list_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail_image_network/mocktail_image_network.dart';
void main() {
group('コントラスト比', () {
testWidgets('✅リスト', (tester) async {
final handle = tester.ensureSemantics();
// 画像をWebから取得しているため、mocktail_image_networkを使用してHTTPクライアントをモックしています。
// アクセシビリティ対応とは直接関係ありません。
await mockNetworkImages(() async {
await tester.pumpWidget(_scaffold(const ItemList()));
await expectLater(tester, meetsGuideline(textContrastGuideline));
});
handle.dispose();
});
testWidgets('✅タイル', (tester) async {
final handle = tester.ensureSemantics();
await mockNetworkImages(() async {
await tester.pumpWidget(_scaffold(const TileComponent()));
await expectLater(tester, meetsGuideline(textContrastGuideline));
});
handle.dispose();
});
testWidgets('✅追加ボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_scaffold(const AddButton()));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
});
group('タップ範囲', () {
testWidgets('✅タイル', (tester) async {
final handle = tester.ensureSemantics();
await mockNetworkImages(() async {
await tester.pumpWidget(_scaffold(const TileComponent()));
await expectLater(
tester,
meetsGuideline(androidTapTargetGuideline),
);
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
});
handle.dispose();
});
// エラーになる
testWidgets('✅追加ボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_center(const AddButton()));
await expectLater(
tester,
meetsGuideline(androidTapTargetGuideline),
);
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
handle.dispose();
});
});
group('ラベル', () {
testWidgets('✅リスト', (tester) async {
final handle = tester.ensureSemantics();
await mockNetworkImages(() async {
await tester.pumpWidget(_scaffold(const ItemList()));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
});
handle.dispose();
});
// エラーになる
testWidgets('✅追加ボタン', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_center(const AddButton()));
await expectLater(
tester,
meetsGuideline(labeledTapTargetGuideline),
);
handle.dispose();
});
});
}
MaterialApp _material(Widget widget) => MaterialApp(
home: widget,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
);
MaterialApp _scaffold(Widget widget) => _material(Scaffold(body: widget));
MaterialApp _center(Widget widget) => _material(Center(child: widget));
ご覧の通りですが、テストを書いても必ずしもアクセシビリティ検証ツールと同じ結果になるわけではありませんでした。
ウィジェット実装やテストを修正すれば検出できる可能性はありますが、現状実現できていません。
似たような事象はGithubにもissueが上がっています。
ともあれ、ある程度のテスト実装は完了しましたので、後編ではこのテスト結果も使いながらサンプルアプリを修正していきます。
後編に向けて
前編ではサンプルアプリに対してアクセシビリティ実装ができているかどうか確認してきました。
結果として数件の不備が見つかりましたので、後編ではこれらを修正して基本的なアクセシビリティ実装ができているアプリにしていきますので、ぜひご覧ください。
\もっと身近にもっとリアルに!DAAE 公式 X/
お問合せはお気軽に
SHIFTについて(コーポレートサイト)
SHIFTのサービスについて(サービスサイト)
SHIFTの導入事例
お役立ち資料はこちら
SHIFTの採用情報はこちら
PHOTO:UnsplashのAlex Presa