FlutterのAccessibility release checklistに適応してみる 前編

# はじめに
こんばんは。OREOです。今回はFlutterの公式ドキュメント上で定義されているAccessibility release checklistに基づいてサンプルアプリを修正してみたいと思います。

# アクセシビリティとは
アクセシビリティとは、視覚や聴覚、運動、認知などの機能に制限があるユーザーがアプリケーションを完全に操作できるようにするための機能や設計のことを指します。
Flutterにおいては以下のようなアクセシビリティ機能が提供されています。

1. スクリーンリーダー：視覚障害のあるユーザーがアプリケーションを使用できるように、画面上の要素を音声で読み上げる機能

2. ラージテキスト：ユーザーがシステム設定でテキストの大きさを変更できるようにする機能

3. 高コントラスト：視覚障害のあるユーザーがアプリケーションを使用しやすくするために、高コントラストモードをサポート

https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility

# Accessibility release checklistとは
Flutterの公式ドキュメントにはリリースに向けたアプリの準備における考慮事項のチェックリストが記載されています。
##### アクティブなインタラクション：全てのアクティブなインタラクションが何かを実行するようにします。押すことができるボタンは押された時に何かを実行するべきです。例えば、onPressedイベントにno-opコールバックがある場合, どのコントロールを押したかをスクリーン上のSnackBarで表示するように変更します。

##### コンテキスト切り替え：情報を入力中にユーザーのコンテキストを自動的に変更するものはありません。一般的に, ウィジェットは確認アクションなしにユーザーのコンテキストを変更することを避けるべきです。

##### タップ可能なターゲット：全てのタップ可能なターゲットは少なくとも48x48ピクセルであるべきです。

##### エラー：重要なアクションは元に戻すことができるべきです。エラーを表示するフィールドでは, 可能であれば修正を提案します。

##### 色覚異常テスト：コントロールは色盲モードとグレースケールモードでも使用可能で読み取り可能であるべきです。

##### スクリーンリーダーテスト：スクリーンリーダーはタップしたときにページ上の全てのコントロールを説明でき, 説明内容は理解可能であるべきです。**TalkBack（Android）とVoiceOver（iOS）でアプリをテスト**します。

##### コントラスト比：コントロールやテキストと背景の間には, 無効なコンポーネントを除いて, 少なくとも4.5:1のコントラスト比を持つことを推奨します。画像も十分なコントラストがあるか評価します。

# 実装してみる
Accesibility release checklistを確認しながらサンプルアプリを修正してみたいと思います。

まずこちらはサンプルアプリのソースコードです。なおコメントは削除しております。

``` main.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: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

```

## アクティブなインタラクション
> アクティブなインタラクション：全てのアクティブなインタラクションが何かを実行するようにします。押すことができるボタンは押された時に何かを実行するべきです。例えば、onPressedイベントにno-opコールバックがある場合, どのコントロールを押したかをスクリーン上のSnackBarで表示するように変更します。

こちらの記載に従って， 「＋」押下時にSnacBarを出すように修正したいと思います。

```main.dart
/// 修正部のみ抜粋
            FloatingActionButton(
              onPressed: () {
                _incrementCounter();
                // Accessibility release checklist No.1
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(
                    content: Text('数を増やしました!'),
                  ),
                );
              },
              tooltip: 'Increment',
              child: const Icon(
                Icons.add,
              ),
            ),
```

|修正前|修正後|
|-|-|
|![画面収録-2024-07-30-21.56.07.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550392/17b4b4be-27ee-35f8-814c-7c76cda98caf.gif)|![画面収録-2024-07-30-21.55.06.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550392/545458cc-bc34-46d6-d0af-cace89ae1d08.gif)|

修正自体はこれでOKかなと思います。

## コンテキスト切り替え
> コンテキスト切り替え：情報を入力中にユーザーのコンテキストを自動的に変更するものはありません。一般的に, ウィジェットは確認アクションなしにユーザーのコンテキストを変更することを避けるべきです。

本記事ではできるだけシンプルに修正を行いたいのでまずは「カウンターリセット機能」を追加しようと思います。

```main.dart
class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  void _resetCounter() {
    setState(() {
      _counter = 0;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            onPressed: () {
              _incrementCounter();
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(
                  content: Text('数を増やしました!'),
                ),
              );
            },
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ),
          const SizedBox(width: 20),
          FloatingActionButton(
            onPressed: () {
              _resetCounter();
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(
                  content: Text('数をリセットしました!'),
                ),
              );
            },
            tooltip: 'Reset',
            child: const Text('Reset'),
          ),
        ],
      ),
    );
  }
}

```

次に
> 確認アクションなしにユーザーのコンテキストを変更することを避けるべきです。

に従ってリセットボタンタップ時にアラートダイアログ表示機能を追加します。

```main.dart
        　　　// Resetボタンのみ抜粋
          FloatingActionButton(
            onPressed: () {
              showDialog(
                context: context,
                builder: (BuildContext context) {
                  return AlertDialog(
                    title: const Text('確認'),
                    content: const Text('カウンターをリセットしますか？'),
                    actions: <Widget>[
                      TextButton(
                        onPressed: () {
                          Navigator.of(context).pop();
                        },
                        child: const Text('いいえ'),
                      ),
                      TextButton(
                        onPressed: () {
                          _resetCounter();
                          Navigator.of(context).pop();
                          ScaffoldMessenger.of(context).showSnackBar(
                            const SnackBar(
                              content: Text('数をリセットしました!'),
                            ),
                          );
                        },
                        child: const Text('はい'),
                      ),
                    ],
                  );
                },
              );
            },
            tooltip: 'Reset',
            child: const Text('Reset'),
          ),
```

実際の動作はこんな感じです。
![画面収録-2024-07-30-22.20.54.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550392/935933bd-4431-f3b4-2b79-e70d961fdb60.gif)

## タップ可能なターゲット
> タップ可能なターゲット：全てのタップ可能なターゲットは少なくとも48x48ピクセルであるべきです。

サンプルアプリにおけるタップ可能なターゲットはFloatingActionButtonのみであり, Widget Inspectorにてボタンサイズが56x56であり，48x48以上であることを確認できたので対応不要となります。
![スクリーンショット 2024-07-30 22.31.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550392/9d41d074-da53-2718-2456-74f303eb89f4.png)

## エラー
> エラー：重要なアクションは元に戻すことができるべきです。エラーを表示するフィールドでは, 可能であれば修正を提案します。

本項目のチェックリストを満たすようにするために, カウンターへの加算が一定の確率で失敗する機能を追加したいと思います。
_incrementCounter内で一定の確率でエラーを返すようにします。また実行成功時にはSnackBarを表示するようにします。

```main.dart
  void _incrementCounter(BuildContext context) {
    final random = Random();
    if (random.nextInt(3) == 0) {
      throw Exception('Increment failed');
    }
    setState(() {
      _counter++;
    });
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('数を増やしました!'),
      ),
    );
  }
```

本実装に合わせてFloatingActionButtonも修正します。

```main.dart
          FloatingActionButton(
            onPressed: () {
            // try-catch文に変更
              try {
                _incrementCounter(context);
            // 実行失敗時には次のアクションを促す
              } catch (e) {
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(
                    content: Text('失敗しました。再実行してください。'),
                  ),
                );
              }
            },
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ),
```

実際の動作はこんな感じです。
![画面収録-2024-07-30-22.45.17.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550392/1698e3d9-8647-8a58-c2bb-c5ceace9ceed.gif)

# さいごに
一部無駄な機能を追加しながらでしたがAccessibility release checklistに適応したサンプルアプリを作ることができました。アプリ内の色彩やスクリーンリーダー機能については後編で記載してみようと思います。