SwiftUIでButtonのlabelにImageを含む場合のVoiceOver読み上げコントロール

こんにちは、モバイルチームの榎原(@el_metal_)です。

iOSアプリでは一覧→詳細のビュー構造は頻出パターンです。 この時、SwiftUIではListNavigationLinkを利用したいところです。NavigationLinkではリストのセルはボタンになります。 また、Listはまだまだ細かいグラフィックの制御ができないため、LazyVStackを利用したり、グリッドにするためにLazyVGridを利用したりすることはよくあります。
この時各要素ではButtonを利用することでタップ時のフィードバックを簡単につけられるため、こちらもボタンを選択することはあるでしょう。

VoiceOverではButtonlabelは全体を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つあります。

  1. Textinit(_ image: Image)を使ってTextとして扱う
  2. accessibilityRemoveTraits(.isImage)を付与してVoiceOverからImageとして扱わない
  3. accessibilityRepresentation(representation:)を付与してTextとして扱う

Textinit(_ 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以下をサポートするには別の手段を取る必要があります。

おわりに

この記事では、ButtonlabelImageを含む場合に読み上げ対象にならない問題を紹介し、その対処法を3通り紹介しました。 SwiftUIでは自動的にアクセシビリティ対応がされますが、逆に意図通りにならないこともあるため今回のような対応が必要な場合があります。 また、このような特別対応が必要ない画面を作ることも重要です。

サイボウズ Office 新着通知アプリ開発チームは一緒に働くiOSエンジニアを募集しています。 ご興味のある方はぜひ詳細をご覧ください。

変更履歴

2022/04/08 accessibilityRepresentation(representation:) の手法を追記