Quantcast
Channel: Cybozu Inside Out | サイボウズエンジニアのブログ
Viewing all articles
Browse latest Browse all 690

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

$
0
0

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

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

VoiceOverではButtonlabelは全体を1要素としてインタラクションします。 さらに、labelの中のTextは読みあげられますが、Imageは読み上げられません。 Imageに意味を持たせていた場合にインタラクションが破綻してしまいます。

サイボウズ Office 新着通知 では実際にこの挙動に遭遇し、対応しました。

この記事では、この問題に対処する方法をお伝えします。

問題の詳細

以下のような画面を構成することを考えます。

対応が必要な画面構成の例

この例では、

  • 予定時刻表示の頭に同じ時間に予定が入っていることを示すアイコン
  • 予定タイトルの末尾に繰り返される予定であることを示すアイコン

が付与されています。

これはこのようなコードにするとこんな感じになります。

structContentView:View {
    letschedules:[Schedule]= [schedule1, schedule2, schedule3]
    varbody: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"))
        }
    }
}
structScheduleTimeView:View {
    letoverlapping:Boolletstart:Stringletend:Stringvarbody: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ではインタラクションできない画面要素になってしまいます。

対処法

この問題の対処法は2つあります。

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

Textとして扱う場合

例えば以下のようにします。

structScheduleTimeView:View {
    letoverlapping:Boolletstart:Stringletend:Stringvarbody: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を削除する場合

例えば以下のようにします。

structContentView:View {
    letschedules:[Schedule]= [schedule1, schedule2, schedule3]
    varbody: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を利用するとコード上でのサイズ調整が困難になります。 その場合はこちらで対処することになるでしょう。

おわりに

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

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


Viewing all articles
Browse latest Browse all 690

Trending Articles