📚

React Native アクセシビリティ対応をわかるための記事: フォーカス編

2021/12/03に公開

前回「React Native のアクセシビリティ対応について知るための入門記事」の続きです。

フォーカス可能なコンポーネントについて知る

前回の記事では、スクリーンリーダーを特定のコンポーネントにフォーカスさせて、情報を読み上げたり、操作したり、色々やりました。
この「支援技術がフォーカス可能」な状態になるためには、ちょっとしたルールがあります。
対応次第では逆にフォーカスできなくなってしまうので、ルールを知っておく必要があります。

accessible

まず、「デフォルトでフォーカス不可能」なコンポーネントにフォーカスする場合は accessible: boolean という Props が必要となります。
「デフォルトでフォーカス不可能」なコンポーネントは、ViewImage などです。

<Image accessible accessibilityLabel="何らかの風景" source={source} />

では、逆に「デフォルトでフォーカス可能」なコンポーネントとは何でしょうか?
答えは、内部で accessible={true} が指定されているコンポーネントです。
例えば Text, Button, Touchable, Pressable, TextInput などがそれにあたります。
これらのコンポーネントは、最初からラベル化されたり操作されることを前提としているので、支援技術もフォーカスできるようになっています。
この仕様は後々重要になるので、「デフォルトでフォーカス可能」ではなく「デフォルトで accessible={true} が設定されている」と置き換えて覚えておく方がいいです。

accessible={true} という状態は一体なんなのでしょうか。
そもそも accessible は、本来 iOS のためにあるようなものです。
iOS では、支援技術がフォーカス可能かを isAccessibilityElement で判断しています。accessible の値は、そのプロパティに割り当てられるわけです。
しかし Android の場合、支援技術がフォーカスするかはラベルなどの有無で判定されるので、accessible はなくても良かったりします。

Android では accessible を検出した時、 View コンポーネントの focusable={true} とほぼ同じ処理を行います。iOS での accessible とは違い、自分自身または子にラベルがなければ、フォーカスされない場合があります。
逆に iOS では accessible={true} だと、ラベルがなくてもフォーカスされる場合があります。
次のサンプルコードは、その挙動の差異を確認するものです。

{/* TalkBack では読み上げる */}
<Text accessible={false}>accessible が false</Text>

{/* VoiceOver では props.text が空でもフォーカスする */}
<Text>{props.text}</Text>

この差異をなくしてみましょう。
まず、accessible={false} のコンポーネントを Android で隠すには、 importantForAccessibility="no-hide-descendants" が使用できます。
importantForAccessibility は Android 専用の Props です。no-hide-descendants という値は、「自身と子孫を含めてフォーカス不可能にする」ことを意味します。子孫を引き続きフォーカス可能にしたい場合は "no" を指定します。

<Text
  accessible={false}
  importantForAccessibility="no-hide-descendants"
>
  accessible が false
</Text>

次に、空テキストのコンポーネントを iOS で隠します。
これは簡単で、文字列が空の時に accessible={false} になるよう設定するだけです。

<Text accessible={Boolean(props.text)}>{props.text}</Text>

accessible の挙動を確認するための Expo Snack を用意しました。実機のスクリーンリーダーで動かして、挙動を確認してみてください。

accessible を使ったネスト、グループ化

accessible={true} なコンポーネントの子に、テキストまたは accessibilityLabel で指定されたラベルがある場合、支援技術がフォーカスした時、子のテキストまたはラベルをまとめて拾い上げます。
これを、コンポーネントのグループ化と呼ぶことにします。

{/* 「こんにちは」「さようなら」をそれぞれフォーカスすると読み上げる */}
<View>
  <Text>こんにちは</Text>
  <Text>さようなら</Text>
</View>

{/* 「こんにちは、さようなら」をまとめて読み上げる */}
<View accessible>
  <Text>こんにちは</Text>
  <Text>さようなら</Text>
</View>

{/* 「こんにちは、さようなら」をまとめて読み上げる */}
<View accessible>
  <Text>こんにちは</Text>
  <Image accessibilityLabel="さようなら" source={source} />
</View>

ここで、TextPressable は、デフォルトで accessible={true} であることを思い出してみます。つまり、それらのコンポーネントにある子の要素はグループ化され、まとめて読み上げられるようになるのです。

{/* Text はデフォルトで accessible={true} なので「親テキスト、子テキスト」がグループ化される */}
<Text>
  親テキスト
  <Text>子テキスト</Text>
</Text>

グループ化の利点は、それぞれ単体でフォーカスしてもよくわからないコンテンツをまとめて認識させられる点です。
例えばスクリーンリーダーで、カード型のコンテンツだったり、説明付きのチェックボタンを読み上げさせる場合にうまく使えることがあります。

グループ化を確認するための Expo Snack を用意しました。実機のスクリーンリーダーで動かして、挙動を確認してみてください。

グループ化された子にフォーカスできなくなるケース(iOS)

iOS では、accessible={true} なコンポーネントの子に、Button などの accessible={true} なコンポーネントがあっても、それらにはフォーカスできません。また、子が持つラベルは全て親に巻き上げられますが、子が持つロールやステートは無視されます。
完全にアクセスできないのではなく、親要素からダブルタップすれば、ボタンとして操作可能なことがあります。しかし、ボタンとしてのロールやステートが一切なくなるので、前後のテキストによっては「これがボタンであるか」の判別が難しくなります。
また、子に複数のフォーカス可能なコンポーネントがあると、最初のコンポーネントにしかアクセスできなくなります。

// 「こんにちは、さようなら、またあした」しか読み上げられない。ボタンはないのかも?
<View style={styles.outline} accessible>
  <Text>こんにちは。</Text>
  <Text>さようなら。</Text>
  <Button
    title="またあした。"
    onPress={() => Alert.alert('ボタンが押されました')}
  />
</View>

accessible={true} なコンポーネントをネストするケースはあまりないと思いますが、意外と忘れてしまいがちな罠を 1 つ紹介します。
「画面外をタップした時に、ソフトウェアキーボードを閉じる」という処理を作るため、TouchableWithoutFeedback コンポーネントで画面全体をラップするプラクティスです。

<View style={styles.section}>
  <TouchableWithoutFeedback>
    <View>
      <TextInput value={val} />
      <Button
        title="ログイン"
        onPress={handlePress}
      />
    </View>
  </TouchableWithoutFeedback>
</View>

この TouchableWithoutFeedbackaccessible={true} なコンポーネントです。子の TextInputButton がグループ化された状態なので、iOS では支援技術でフォーカス不可能となります。
逆に言えば、グループ化されていない状態であればアクセス可能です。
そこで、TouchableWithoutFeedbackaccessible={false} にしてやると解決します。

<View style={styles.section}>
  <TouchableWithoutFeedback accessible={false}>
    <View>
      <TextInput value={val} />
      <Button
        title="ログイン"
        onPress={handlePress}
      />
    </View>
  </TouchableWithoutFeedback>
</View>

もう 1 つありそうなのが、Text にリンクを置く例です。iOS ではリンクにフォーカスできず、ロールも無視されます。
これは、親自体がラベルであり、 accessible={false} を指定するわけにもいかないので、兄弟として分けるしかありません…。

<Text>
  こんにちは。
  <Text onPress={() => {}} accessibilityRole="link">
    続きはこちら
  </Text>
</Text>

iOS では AccessibilityContainer によって、子のコンポーネントもアクセス可能にできるのですが、現状の React Native ではそれに対応できません。
そのため accessible={true} なコンポーネントに、ボタンなどの操作可能なコンポーネントをネストするのは避けるべきとされています。


まとめ

今回は、accessible のなかなか複雑なルールについて取り上げました。

  • accessible={true} なコンポーネントは iOS でフォーカス可能
  • accessible={false} でも、Android ならラベルがあればフォーカス可能
  • Text, Button, Touchable, Pressable, TextInput などのコンポーネントは accessible={true}
  • accessible={true} の子コンポーネントはまとめて読み上げられる。グループ化と呼ぶ
  • グループ化されたコンポーネントは、iOS でフォーカス不可能。ロール、ステートも無視される
  • グループ化されたコンポーネントにフォーカス可能な子が複数あると、iOS では全てを操作できない

あまりにも複雑なのでまともに覚えず、「accessible={true} のコンポーネントはネストしない」ことを心がけるだけで、フォーカスできなくなる事故が防げると思います。

Discussion