こんにちは、モバイルチームの榎原(@el_metal_)です。
iOSアプリでは一覧→詳細のビュー構造は頻出パターンです。
この時、SwiftUIではList
とNavigationLink
を利用したいところです。NavigationLink
ではリストのセルはボタンになります。
また、List
はまだまだ細かいグラフィックの制御ができないため、LazyVStack
を利用したり、グリッドにするためにLazyVGrid
を利用したりすることはよくあります。
この時各要素ではButton
を利用することでタップ時のフィードバックを簡単につけられるため、こちらもボタンを選択することはあるでしょう。
VoiceOverではButton
のlabel
は全体を1要素としてインタラクションします。
さらに、label
の中のText
は読みあげられますが、Image
は読み上げられません。
Image
に意味を持たせていた場合にインタラクションが破綻してしまいます。
サイボウズ Office 新着通知 では実際にこの挙動に遭遇し、対応しました。
この記事では、この問題に対処する方法をお伝えします。
問題の詳細
以下のような画面を構成することを考えます。
この例では、
- 予定時刻表示の頭に同じ時間に予定が入っていることを示すアイコン
- 予定タイトルの末尾に繰り返される予定であることを示すアイコン
が付与されています。
これはこのようなコードにするとこんな感じになります。
struct ContentView: View { let schedules: [Schedule] = [schedule1, schedule2, schedule3] var body: some View { NavigationView { List { ForEach(schedules, id: \.self) { schedule in NavigationLink(destination: ScheduleDetailView(schedule: schedule)) { HStack { VStack(alignment: .leading, spacing: 2) { ScheduleTimeView(overlapping: schedule.overlapping, start: schedule.start, end: schedule.end) HStack(spacing: 4) { ScheduleTagView(tag: schedule.tag) Text(schedule.title) if schedule.recurring { Image(systemName: "arrow.triangle.capsulepath") .font(.caption) .rotationEffect(.degrees(270)) .accessibilityLabel(Text("繰り返される予定")) // not working } } } } .padding(.vertical, 4) } } } .navigationTitle(Text("Schedules")) } } } struct ScheduleTimeView: View { let overlapping: Bool let start: String let end: String var body: some View { HStack(spacing: 0) { if overlapping { Image(systemName: "exclamationmark.triangle.fill") .renderingMode(.original) .font(.subheadline) .accessibilityLabel(Text("重複予定あり")) // not working } Text("\(start) - \(end)") .font(.subheadline) } } }
この場合、アイコンのImage
に.accessibilityLabel
を付与しても読み上げ対象にならず、VoiceOverではインタラクションできない画面要素になってしまいます。
対処法
この問題の対処法は3つあります。
Text
のinit(_ image: Image)
を使ってText
として扱うaccessibilityRemoveTraits(.isImage)
を付与してVoiceOverからImageとして扱わないaccessibilityRepresentation(representation:)
を付与してText
として扱う
Text
のinit(_ image: Image)
を利用する場合
例えば以下のようにします。
struct ScheduleTimeView: View { let overlapping: Bool let start: String let end: String var body: some View { if overlapping { (Text(Image(systemName: "exclamationmark.triangle.fill").renderingMode(.original)).accessibilityLabel(Text("重複予定あり")) + Text("\(start) - \(end)")) .font(.subheadline) } else { Text("\(start) - \(end)") .font(.subheadline) } } }
これでText
として扱われるため、読み上げ対象になります。
Image
のtraitsを削除する場合
例えば以下のようにします。
struct ContentView: View { let schedules: [Schedule] = [schedule1, schedule2, schedule3] var body: some View { NavigationView { List { ForEach(schedules, id: \.self) { schedule in NavigationLink(destination: ScheduleDetailView(schedule: schedule)) { HStack { VStack(alignment: .leading, spacing: 2) { ScheduleTimeView(overlapping: schedule.overlapping, start: schedule.start, end: schedule.end) HStack(spacing: 4) { ScheduleTagView(tag: schedule.tag) Text(schedule.title) if schedule.recurring { Image(systemName: "arrow.triangle.capsulepath") .font(.caption) .rotationEffect(.degrees(270)) .accessibilityRemoveTraits(.isImage) .accessibilityLabel(Text("繰り返される予定")) } } } } .padding(.vertical, 4) } } } .navigationTitle(Text("Schedules")) } } }
これでImage
として扱われなくなるため、読み上げ対象になります。
例ではSF Symbolsを使っていますが、自前の画像をAssetから取り込むときはImage(uiImage: myAsset)
を使うことになるため、Text
を利用するとコード上でのサイズ調整が困難になります。
その場合はこちらで対処することになるでしょう。
accessibilityRepresentation
を利用する場合
例えば以下のようにします。
struct ContentView: View { let schedules: [Schedule] = [schedule1, schedule2, schedule3] var body: some View { NavigationView { List { ForEach(schedules, id: \.self) { schedule in NavigationLink(destination: ScheduleDetailView(schedule: schedule)) { HStack { VStack(alignment: .leading, spacing: 2) { ScheduleTimeView(overlapping: schedule.overlapping, start: schedule.start, end: schedule.end) HStack(spacing: 4) { ScheduleTagView(tag: schedule.tag) Text(schedule.title) if schedule.recurring { Image(systemName: "arrow.triangle.capsulepath") .font(.caption) .rotationEffect(.degrees(270)) .accessibilityRepresentation { Text("繰り返される予定") } } } } } .padding(.vertical, 4) } } } .navigationTitle(Text("Schedules")) } } }
これで支援技術からText
として認識されるため、読み上げ対象になります。
API Availability が iOS15+ となっているため、iOS14以下をサポートするには別の手段を取る必要があります。
おわりに
この記事では、Button
のlabel
にImage
を含む場合に読み上げ対象にならない問題を紹介し、その対処法を3通り紹介しました。
SwiftUIでは自動的にアクセシビリティ対応がされますが、逆に意図通りにならないこともあるため今回のような対応が必要な場合があります。
また、このような特別対応が必要ない画面を作ることも重要です。
サイボウズ Office 新着通知アプリ開発チームは一緒に働くiOSエンジニアを募集しています。 ご興味のある方はぜひ詳細をご覧ください。
変更履歴
2022/04/08 accessibilityRepresentation(representation:)
の手法を追記