社内でアクセシビリティを考慮したアプリに関わる可能性があるので先行で調査することにしました。iOSでの記事も執筆しているのでOS間での違いも併せてみていただければと思います。

アクセシビリティ機能(TalkBack)とは

視覚、色覚、または聴覚に障がいのある方、細かい作業に支障のある方、認知障がいのある方など、障がいのある多くの方々が Android デバイスを使って、普段の生活でさまざまな操作を行っています。ユーザー補助を念頭に置いてアプリを開発するには、特に上記やその他の補助が必要なユーザー向けに、アプリの便宜性を高めてください。

https://developer.android.com/guide/topics/ui/accessibility/apps

AndroidでもiOSと同様に障がいのある方でも便利に使えるようにアクセシビリティ機能に力を入れているようで、2022年5月にはPlayStore上にあるAccessible(アクセシビリティ機能を備えた)アプリについてAllyタグをつけることを発表しています。タグの詳細についてはリンクを参照ください。

https://support.google.com/googleplay/thread/164221330/accessibility-tags-make-it-easy-to-find-accessible-apps-in-the-play-store-app?hl=en

様々なアクセシビリティ機能がある中で、視覚に障がいのある方に有効なTalkbackの実装について今回は調べました。

TalkBackを有効/無効にする方法

  • 音量ボタンの大小両方を3秒ほど長押し(押すたびにオンオフが切り替わる)
  • Googleアシスタントに「TalkBack をオン(オフ)にして」と言う
  • デバイスの設定アプリで 設定 > ユーザー補助 > Talkbackを選択し、「TalkBackの使用」をオン(オフ)にする

この3パターンがあります。

注意点としてTalkBackはデフォルトでパスワードを読み上げるようです。対策として TalkBackの設定>詳細設定>パスワードの読み上げ をオフ にすることでイヤフォンを利用している時のみ読み上げられます。

また、Androidの中でも独自にカスタマイズされているOSの場合、上の設定項目の名称や順番などは変わっている時があるので、お手元にあるAndroidで一度項目を探してみると良いかもしれません。この際に使ってみてください。

私の手元にあるXperiaⅢではGoogleアシスタントに言っても反応はするが機能はしませんでした。原因はわからず、音量ボタン大小同時押しで切り替えています。

基本的な操作方法について

使う際の基本的な操作方法は以下のようになっています。

動作実際の操作
フォーカスの移動1本指で左右スワイプ動作
要素の選択1本指でタップ動作
要素の決定1本指でダブルタップ動作

読み上げ

読み上げの対象

OSから提供されているUI要素はImageViewImageButtonなどを除いてデフォルトで読み上げられます。

デフォルトで読み上げられないものは情報が画像として伝えられており、コンテンツ ラベルが設定しない限り存在していないことから読み上げられないので、Viewのandroid:contentDescription属性を利用して、設定する必要があります。

ちなみにAndroidStudioでImageViewに対してcontentDescription属性を設定していない場合、このような警告が表示されます。

Suppress:Add tools:ignore="ContentDescription"attributeを実行すれば警告は消えますができる限り対応した方がAccessibleなアプリと言えるでしょう。

検証用に作成したアプリが以下の動画になります。画面中段のswitchBottomと言うラベルの左にあるアイコン画像だけcontentDescriptionの設定をしていません。

画面録画の技術的な問題でTalkbackの音声入りの動画が撮れなかったので、画面下部のトースト表示で話している内容を表示しています。

これはTalkBackの設定 > 詳細設定 > デベロッパー向けの設定 > 音声出力の表示をオンに設定すると表示できます。

descriptionを設定していないImageViewがTalkBackの対象に選ばれずに無視されていることがわかりますでしょうか。このようにdescription設定のないImageViewはデフォルトでは読み上げる内容がないので選ばれません。

コンテンツラベルを設定する属性

android:contentDescription

先に説明したように情報を画像で伝えるviewはこのandroid:contentDescription属性を使用してコンテンツラベルを設定できます。後で紹介するLinearLayoutなどの親クラスであるViewGroupを使う場合このViewGroupに対してこの属性を設定することもあります。

Label等にcontentDescriptionを設定しなかった場合Labelが持つtext属性を読みますが、設定した場合はcontentDescriptionの内容を反映します。

例として以下の画像の緑の枠を読み上げることを考えます。レイアウトXMLファイルの当該箇所では、textが読み上げcontentDescription読み上げサンプルに設定されています。実際にTalkBackが話す内容は読み上げサンプルになっています。

このようにcontentDescriptionを優先して読み上げます。

<Switch
        android:id="@+id/linearFocusableSwitch"
        style="@style/defaultSwitchStyle"
        android:contentDescription="読み上げサンプル"
        android:text="読み上げ"
        app:layout_constraintBottom_toTopOf="@id/LinearLayoutTop"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/seekBar" />

android:hint

EditTextまたはTextViewではandroid:contentDescriptionをコンテンツラベルとして使用できません。代わりにandroid:hintを利用して目的を設定します。

例として以下の画像上部の緑の枠のテキストボックスを読み上げます。レイアウトXMLファイル内で指定した通り今日の天気を入力と発声していることがわかります。

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/textInputLayout"
        style="@style/defaultSwitchStyle"
        app:layout_constraintBottom_toTopOf="@id/button"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/textView">

        <com.google.android.material.textfield.TextInputEditText
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="今日の天気を入力" />
    </com.google.android.material.textfield.TextInputLayout>

どのように読み上げられるか

iOSでは規定の順番がありましたが、Androidではユーザーが選択できるようになっています。

要素は状態名前種類の三種類あります。順序を並べ替えるにはTalkBackの設定 > 詳細設定 > 説明する要素の順序 で並び替えることができます。

基本的にこのデフォルトである状態名前種類で問題ないと思われます。先のcontentDescriptionの項でも紹介した右写真の緑の四角で囲まれた要素を読み上げてみようと思います。

各設定で読み上げてみた結果を下に示します。

状態、名前、種類

OFF、読み上げサンプル、スイッチ

種類、名前、状態

スイッチ、読み上げサンプル、OFF

名前、種類、状態

読み上げサンプル、スイッチ、OFF

デフォルトでは先に状態が分かり、次に名前、最後にスイッチとわかりますが他の2つでは状態を最後まで聞かないと先に行けません。いくつも要素を辿っていくユーザーからすると端的に物の名前、状態が分かれば良いのでデフォルトが望ましいかと思われます。

フォーカス遷移の順番

基本は左から右、上から下に遷移します。画面の設計上縦に並んでいる2要素をまとめて読んでほしい場合があるかと思います。その場合は、LinearLayoutなどViewGroupで要素を包んであげることでまとめて1要素と見做すことができます。以下に例を示します。

<LinearLayout
        android:id="@+id/LinearLayoutLeft"
        style="@style/verticalLinearStyle"
        android:contentDescription="@string/start_Description"
        app:layout_constraintEnd_toStartOf="@+id/LinearLayoutRight"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/LinearLayoutBottom">

        <ImageView
            android:id="@+id/imageViewLeft"
            style="@style/defaultImageViewStyle"
            android:src="@android:drawable/sym_def_app_icon"
            tools:srcCompat="@android:mipmap/sym_def_app_icon" />

        <Switch
            android:id="@+id/switchLeft"
            style="@style/linearSwitchStyle"
            android:text="switchLeft" />
    </LinearLayout>

このような構成のviewを用意した際にLinear Layoutにフォーカスすることを示すには

val layout = findViewById<LinearLayout>(R.id.LinearLayoutLeft)
val image = findViewById<ImageView>(R.id.imageViewLeft)
val switch = findViewById<Switch>(R.id.switchLeft)
switch.isFocusable = false
image.isFocusable = false
layout.isFocusable = true

と書きます。同様に、レイアウトを構成しているxml上でもfocasableが指定可能なので、対象Viewのフォーカス可否が確定している場合はxml上で指定してしまう方が良いでしょう。

今回の例のようにSwitchのようなクリック動作がある場合は

val switch = findViewById<Switch>(R.id.switchLeft)
val layout = findViewById<LinearLayout>(R.id.LinearLayoutLeft)
layout.setOnClickListener { switch.toggle() }

このようにsetOnClickListenerを設定すると、同様にフォーカス可能と認識されます。今回はSwitchにLinearLayoutのクリックイベントを流すように書いているので実質タップ領域が拡張されていますがTalkBackが有効かどうかをチェックして必要な場合にのみ拡張する、など必要に応じた対応をすると良いでしょう。

ところでこのLinearLayoutにフォーカスした状態から次の要素に移動してみると以下の動画のように、switchのisFocusablefalseにしたはずなのにフォーカスされます。

これをフォーカスさせないためにSwitchのandroid:importantForAccessibilitynoに設定します。こうすることでswitch側のアクセシビリティ情報は無視されますがLinearLayout側で説明することで問題なく利用できます。

 val switch = findViewById<Switch>(R.id.switchLeft)
 switch.isFocusable = false
 switch.importantForAccessibility = 2 
//ref. https://developer.android.com/reference/android/view/View.html#attr_android:importantForAccessibility

まとめ

事前に検討が必要な点

  • Talkbackが読み上げるべきviewと必要のない装飾viewを切り分ける
  • 要素が読み上げる適切なタイトルを検討する
  • UI設計上でまとめて読みたいviewを洗い出す

感想

iOSを先行して調査していましたが、同じ音声案内での補助機能とあってできる機能としてはそう変わらないと感じました。気になった点としては読み上げる順序がiOSとAndroidで少し異なっていて、OS間の差が共通規格などが制定されて吸収されると、実装側としても利用者側としても違和感なくどのデバイスでも使えるだろうなと思いました。

補足

読み上げる文章に漢字を含むワードを設定することについて

contentDescriptionに記事内では読み上げサンプルなどと設定していましたが、漢字を含むワードを読み上げるよう設定することの是非については賛否両論あるようです。

  • スクリーンリーダーなどの音声合成エンジンが漢字を誤読しないようにあえてひらがな表記にする
  • ひらがな表記にされると声の抑揚がなく聞き取りづらく、誤読は慣れているとそこまで問題にならない

アプリのユーザー層、用途など様々な要因で正確性を優先すべき場合もあるため、事前にしっかり誤読する可能性を含めた検討が行われる必要があるかと思います。

検証用アプリのレイアウトXML

展開
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textView"
        style="@style/defaultTextViewStyle"
        android:text="以下のtext boxに今日の天気を入力"
        app:layout_constraintBottom_toBottomOf="@+id/textInputLayout"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/textInputLayout"
        style="@style/defaultSwitchStyle"
        app:layout_constraintBottom_toTopOf="@id/button"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/textView">

        <com.google.android.material.textfield.TextInputEditText
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="今日の天気を入力" />
    </com.google.android.material.textfield.TextInputLayout>

    <Button
        android:id="@+id/button"
        style="@style/defaultSwitchStyle"
        android:text="Button"
        android:contentDescription="決定"
        app:layout_constraintBottom_toTopOf="@id/seekBar"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textInputLayout" />

    <SeekBar
        android:id="@+id/seekBar"
        style="@style/defaultSwitchStyle"
        android:contentDescription="画面輝度"
        app:layout_constraintBottom_toTopOf="@id/linearFocusableSwitch"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/button" />

    <Switch
        android:id="@+id/linearFocusableSwitch"
        style="@style/defaultSwitchStyle"
        android:contentDescription="読み上げサンプル"
        android:text="読み上げ"
        app:layout_constraintBottom_toTopOf="@id/LinearLayoutTop"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/seekBar" />

    <LinearLayout
        android:id="@+id/LinearLayoutTop"
        style="@style/horizontalLinearStyle"
        android:clickable="true"
        android:contentDescription="@string/top_description"
        android:focusable="true"
        app:layout_constraintBottom_toTopOf="@+id/LinearLayoutBottom"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/linearFocusableSwitch">

        <ImageView
            android:id="@+id/imageViewTop"
            style="@style/defaultImageViewStyle"
            android:src="@android:drawable/sym_def_app_icon"
            tools:srcCompat="@android:mipmap/sym_def_app_icon" />

        <Switch
            android:id="@+id/switchTop"
            style="@style/linearSwitchStyle"
            android:text="switchTop" />

    </LinearLayout>

    <LinearLayout
        android:id="@+id/LinearLayoutBottom"
        style="@style/horizontalLinearStyle"
        android:contentDescription="@string/bottom_Description"
        app:layout_constraintBottom_toTopOf="@id/LinearLayoutLeft"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/linearFocusableSwitch">

        <ImageView
            android:id="@+id/imageViewBottom"
            style="@style/defaultImageViewStyle"
            android:src="@android:drawable/sym_def_app_icon"
            tools:srcCompat="@android:mipmap/sym_def_app_icon" />

        <Switch
            android:id="@+id/switchBottom"
            style="@style/linearSwitchStyle"
            android:importantForAccessibility="no"
            android:text="switchBottom" />
    </LinearLayout>

    <LinearLayout
        android:id="@+id/LinearLayoutLeft"
        style="@style/verticalLinearStyle"
        android:contentDescription="@string/start_Description"
        app:layout_constraintEnd_toStartOf="@+id/LinearLayoutRight"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/LinearLayoutBottom">

        <ImageView
            android:id="@+id/imageViewLeft"
            style="@style/defaultImageViewStyle"
            android:focusable="false"
            android:src="@android:drawable/sym_def_app_icon"
            tools:srcCompat="@android:mipmap/sym_def_app_icon" />

        <Switch
            android:id="@+id/switchLeft"
            style="@style/linearSwitchStyle"
            android:text="switchLeft" />
    </LinearLayout>

    <LinearLayout
        android:id="@+id/LinearLayoutRight"
        style="@style/verticalLinearStyle"
        android:contentDescription="@string/end_Description"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/LinearLayoutLeft"
        app:layout_constraintTop_toBottomOf="@+id/LinearLayoutBottom">

        <ImageView
            android:id="@+id/imageViewRight"
            style="@style/defaultImageViewStyle"
            android:src="@android:drawable/sym_def_app_icon"
            tools:srcCompat="@android:mipmap/sym_def_app_icon" />

        <Switch
            android:id="@+id/switchRight"
            style="@style/linearSwitchStyle"
            android:text="switchRight" />
    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

参考文献

https://developer.android.com/guide/topics/ui/accessibility/principles?hl=ja

https://developer.android.com/reference/android/view/View.html#attr_android:importantForAccessibility

https://chuff-chuff.hatenablog.com/entry/2019/12/06/003051



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる