Xamarin.Formsにおけるスクリーンリーダー対応まとめ

私はモバイルアプリにおけるアクセシビリティ対応の記事をいくつか投稿しています。

- [モバイルアプリでアクセシビリティ対応をする前に知るべきこと](https://qiita.com/uhooi/items/f566dbcfec9fdb69e993)
- Xamarin.Formsにおけるスクリーンリーダー対応まとめ __←今ココ__

「モバイルアプリでアクセシビリティ対応をする前に知るべきこと」を読んでから本記事を読むことをオススメします。

## はじめに

Xamarin.Formsにおけるスクリーンリーダー（iOS: VoiceOver、Android: TalkBack）対応について紹介します。

## 注意

Xamarin.Formsでアクセシビリティ対応するのは __非常に難しい__ です。
そもそもアクセシビリティ対応まで考えて設計されたフレームワークではなく、2022/03/07現在の最新バージョンでもできないことや不安定なことが多いです。

Xamarin.Formsの後継であるMAUIは、最初からアクセシビリティが考慮されています。
アクセシビリティ対応が必要なアプリの場合、MAUIが正式リリースされたら早めに移行するのがいいと思います。

## 環境

- Xamarin.Forms：5.0.0.2291
- Xamarin.CommunityToolkit：2.0.0

## Xamarin.Formsにおけるスクリーンリーダー対応

Xamarin.Formsにおけるスクリーンリーダー対応を順番に紹介します。

### 要素にアクセスできるようにする

`AutomationProperties.IsInAccessibleTree` 属性に `True` を指定することで、スクリーンリーダーで読み上げられ、遷移されるようになります。
指定しなくてもアクセスできる要素もありますが、明示的に指定したほうが確実です。

```diff_xml:FooPage.xaml
<Label
+ AutomationProperties.IsInAccessibleTree="True"
  Text="{x:Static resources:AppResources.FooText}" />
```

装飾用の画像など、逆にスクリーンリーダーでスキップしたい要素には `False` を指定します。
非表示の要素にも `False` を指定しないと遷移されることがあるので注意です。

### 要素の読み上げを設定する

デフォルト（ラベルなら `Text` ）以外の読み上げを設定したい場合、 `AutomationProperties.Name` 属性に文字列を指定します。

```diff_xml:FooPage.xaml
<Label
  AutomationProperties.IsInAccessibleTree="True"
+ AutomationProperties.Name="テスト"
  Text="{x:Static resources:AppResources.FooText}" />
```

`AutomationId` 属性が指定されているとなぜかそれが読み上げられるので、その場合は指定が必須です。

Androidのラベルでは `Text` と `AutomationProperties.Name` の両方が読み上げられます。
実装を工夫してiOSのみ `AutomationProperties.Name` を指定することはできますが、Androidでは読み上げを変えられないので、根本的な解決にはなりません。

```diff_xml:FooPage.xaml
<Label
  AutomationProperties.IsInAccessibleTree="True"
  Text="{x:Static resources:AppResources.FooText}">
+ <AutomationProperties.Name>
+   <OnPlatform x:TypeArguments="x:String">
+     <On Platform="iOS" Value="Foo" />
+   </OnPlatform>
+ </AutomationProperties.Name>
</Label>
```

このあたりの読み上げについて詳しい方がいたら教えていただけると嬉しいです。

### 要素の遷移順を決める

遷移順を明示的に指定しないと、きれいに左上から右下に向かって遷移しなかったり、要素間でループして他の要素へ遷移しなかったりします。
遷移したい全要素に `TabIndex` を指定することで、要素の遷移順を確定できます。

```diff_xml:FooPage.xaml
<Label
  AutomationProperties.IsInAccessibleTree="True"
+ TabIndex="1"
  Text="{x:Static resources:AppResources.FooText}" />
<Label
  AutomationProperties.IsInAccessibleTree="True"
+ TabIndex="2"
  Text="{x:Static resources:AppResources.BarText}" />
```

Androidだと指定通りに遷移しないことがあるので注意です。

`TabIndex` は以下の要素に付けても効きません。

- `StackLayout`
- `CollectionView`

`TabIndex` を指定することで、iOSで最初と最後の要素がお互いに遷移しなくなります。
スクリーンリーダーのユーザーは、無限ループすると最初と最後の要素がわからなくなって困るので、ありがたい副作用です。

### 要素にフォーカスを当てる

[Xamarin.CommunityToolkitの1.3.2](https://github.com/xamarin/XamarinCommunityToolkit/releases/tag/1.3.2) で `SetSemanticFocus()` メソッドが追加されました。
このメソッドを呼び出すと、引数で渡した `VisualElement` にフォーカスを当てられます。
基本的には呼び出さないようにしたいですが、どうしてもフォーカスが当たらない要素に対しては有用です。

例えば `MasterDetailPage` を使ってメニューを実現する場合、iOSではメニューから次のページへ遷移してもフォーカスがメニューに残ってしまいます。
遷移先の `ContentPage` の `OnAppearing()` 内で `SetSemanticFocus(this)` を呼び出すことで、フォーカスを次のページに当てられます。

```diff_c_sharp:FooPage.xaml.cs
+ using Xamarin.CommunityToolkit.Extensions;

public partial class FooPage : ContentPage
{
    protected override void OnAppearing()
    {
        base.OnAppearing();

+       Device.BeginInvokeOnMainThread(() =>
+       {
+           SemanticExtensions.SetSemanticFocus(this);
+       });
    }
}
```

[Xamarin.Forms.Device.BeginInvokeOnMainThread(Action)](https://docs.microsoft.com/en-us/dotnet/api/xamarin.forms.device.begininvokeonmainthread?view=xamarin-forms) で括らないと、Androidで `System.NullReferenceException: Can't access view` が発生するので注意です。
Androidでは `OnAppearing()` を抜けたあとに実行される必要があるようです。

これでもフォーカスがメニューに残ってしまうページがあるので、もっといい回避策や根本的な解決策があれば知りたいです。

ちなみに `SetSemanticFocus()` は公式ドキュメントがありません。
MAUIの公式ドキュメントにはあるので、挙動は違う可能性がありますが、参考になります。

https://docs.microsoft.com/en-us/dotnet/maui/fundamentals/accessibility#semantic-focus

### ListViewの小技

`ListView` に関するアクセシビリティ対応を紹介します。

#### 読み上げを調整する

iOSではリストへの遷移時に「カラノリスト（おそらく「空のリスト」）」と読み上げられてしまうことがあります。

`ListView` の `AutomationProperties.Name` に文字列（ここでは「リスト」）を指定することで回避できます。

```diff_xml:FooPage.xaml
<ListView
+ AutomationProperties.IsInAccessibleTree="True"
+ AutomationProperties.Name="{x:Static resources:AppResources.List}">
</ListView>
```

```diff_xml:AppResources.ja.resx
+ <data name="List" xml:space="preserve">
+   <value>リスト</value>
+   <comment>リスト</comment>
+ </data>
```

Androidで読み上げがおかしくなるなどの副作用があるので、もっといい回避策や根本的な解決策があれば知りたいです。

#### フッターと空の要素の両方を表示しない

iOS 14以下では、リストにフッターがないと空の要素が一番下の要素の下に表示されます。
`Fotter=""` を付けると回避できますが、リストの一番下の見えないフッターに遷移できてしまいます。
この問題を解消するために、遷移できない空のフッターを追加します。

```diff_xml:FooPage.xaml
<ListView>
+ <ListView.Footer>
+   <StackLayout AutomationProperties.IsInAccessibleTree="False" />
+ </ListView.Footer>
</ListView>
```

もっといい回避策や根本的な解決策があれば知りたいです。

## おまけ：属性の順番

XAMLでタグ内の属性について、私は以下の順番で書いています。

- `x:Name`
- `Grid.Row`
- `Grid.Column`
- `AutomationProperties.IsInAccessibleTree`
- `AutomationProperties.Name`
- `TabIndex`
- その他

他にオススメの順番があれば教えてほしいです。

## おわりに

これでXamarin.Forms製のアプリのアクセシビリティを向上できました！
ただ本記事を読んでわかる通り、まだ難しいことがたくさんあるので、もっと向上できるよう頑張ります。

## 参考リンク

- [Automation Properties - Xamarin | Microsoft Docs](https://docs.microsoft.com/en-us/xamarin/xamarin-forms/app-fundamentals/accessibility/automation-properties)
- [Element.AutomationId Property (Xamarin.Forms) | Microsoft Docs](https://docs.microsoft.com/en-us/dotnet/api/xamarin.forms.element.automationid?view=xamarin-forms)
- [Implement SetSemanticFocus and Announce APIs by rachelkang · Pull Request #1727 · xamarin/XamarinCommunityToolkit](https://github.com/xamarin/XamarinCommunityToolkit/pull/1727)