WWDC Quick Look 💓 By SwiftGGTeam
Catch up on accessibility in SwiftUI

Catch up on accessibility in SwiftUI

元の動画を見る

ハイライト

iOS 18 では SwiftUI に条件付きアクセシビリティ修飾子、動的ラベルビルダー、App Intent アクションが追加され、支援技術が Widget やドラッグ&ドロップ操作を直接扱えるようになりました。

主要内容

多くの開発者は、SwiftUI の組み込みコントロールには最初からアクセシビリティ対応が備わっており、追加の対応は不要だと考えがちです。しかし VoiceOver を一度走らせると、装飾用グラフィックにラベルがない、コメント一覧に散在するボタンの所属が分からない、hover オーバーレイがキーボードユーザーにとって手順が煩雑、といった問題が見えてきます。問題は「対応があること」と「体験が良いこと」の間のギャップにあります。

SwiftUI のアクセシビリティ体系は accessibility element を中心に構成されています。各 element は label、trait、action の 3 種類の情報を持ち、VoiceOver は element を通じてのみアプリとやり取りします。組み込みコントロールは自動的に element を生成しますが、カスタムビュー、Shape、動的なインタラクションを使うと、element の情報が欠落したり不完全になったりします。

今年 iOS 18 ではいくつかの重要な欠落が補われました。isEnabled パラメータにより修飾子を条件付きで適用でき、SwiftUI のデフォルトラベルを上書きしません。accessibilityLabel が view ビルダーを受け取り、既存ラベルを保持したまま動的コンテンツを追加できます。accessibilityDropPoint は複数ドロップ地点のドラッグ&ドロップに意味的な説明を提供します。accessibilityAction(named:intent:) により Widget 内のカスタムアクションを VoiceOver から直接トリガーできます。これら API の共通の考え方は、視覚レイアウトを変えずに支援技術に必要な情報を補うことです。

詳細

自動生成される element とスタイルシステムの連携

SwiftUI は body で宣言したビューを、画面上の視覚コンテンツと accessibility element の 2 つの出力として同時にレンダリングします。Toggle を例にすると、SwiftUI は「Comments」テキストとスイッチ状態を 1 つの element に自動的にまとめ、label は「Comments」、trait には isToggle と isSelected、action は press です。VoiceOver がフォーカスすると「Comments, switch button off」と読み上げ、ダブルタップで切り替えられます(03:16)。

キーポイント: toggleStyle で視覚スタイルを変更しても、element の label と trait は失われません。SwiftUI のスタイルシステムは、視覚的なカスタマイズ性を保ちながらアクセシビリティ属性を壊しません(04:04)。

Shape やラベルなしビューへの label 追加

Shape ビュー(Circle など)はデフォルトでは element を生成しません。.accessibilityLabel() を追加すると、SwiftUI は element を作成してラベルを設定します。ビューの opacity が 0 になると、element も支援技術から自動的に非表示になります(07:27)。

struct UnreadIndicatorView: View {
    var isUnread: Bool

    var body: some View {
        Circle()
            .foregroundStyle(.blue)
            .accessibilityLabel("Unread")
            .opacity(isUnread ? 1 : 0)
    }
}
  • Circle() は純粋な装飾 Shape で、デフォルトでは element を生成しない
  • .accessibilityLabel("Unread") は element の作成とラベル設定の 2 つを同時に行う
  • .opacity(isUnread ? 1 : 0) で表示を制御し、opacity が 0 のとき element は自動的に非表示になる

combine でナビゲーション手順を削減

コメント一覧の各コメントには未読マーク、本文、お気に入りボタン、返信ボタンがあります。デフォルトでは VoiceOver がこれらを 1 つずつ読み上げ、手順が多くなります。.accessibilityElement(children: .combine) を使うと、HStack 内のすべての子ビューを 1 つの element にまとめられます(08:02)。

var body: some View {
    HStack {
        UnreadIndicatorView(isUnread: message.isUnread)
        MessageContentsView(message: message)
        Spacer()
        Button(action: favorite) { favoriteLabel }
        Button(action: reply) { replyLabel }
    }
    .accessibilityElement(children: .combine)
}
  • HStack 内の 4 つの子ビューの label が 1 つの完全なラベルに連結される
  • Button の action は自動的に custom action になり、VoiceOver では上スワイプで選択できる
  • Spacer は element 生成に参加せず、視覚レイアウトにのみ影響する

条件付き修飾子: isEnabled

お気に入りボタンは star.fill アイコン、スーパーお気に入りは sparkles アイコンを使います。Sparkles のデフォルトラベルは「Super Favorite」ではなく「Sparkle」であり、直接 .accessibilityLabel("Super Favorite") を追加すると、通常のお気に入り時の star の正しいラベルも上書きしてしまいます。iOS 18 の isEnabled パラメータがこの問題を解決します(10:37)。

var body: some View {
    Button(action: favorite) {
        Image(systemName: isSuperFavorite ? "sparkles" : "star.fill")
    }
    .accessibilityLabel("Super Favorite", isEnabled: isSuperFavorite)
}
  • isEnabled: isSuperFavorite が true のときのみこのラベルが適用される
  • false のとき SwiftUI は star.fill のデフォルトラベルにフォールバックする
  • 手動で if isSuperFavorite { ... } else { ... } と分岐する必要がない

hover オーバーレイを custom action に変換

macOS では hover で添付ファイルを表示します。マウス操作は速いですが、VoiceOver では 4 ステップ必要です。メインビューにフォーカス、hover をトリガー、オーバーレイの子ビューへ移動、操作後にメインビューへ戻る。.accessibilityActions を使うと、オーバーレイのコントロールを custom action として抽出し、メインビューに直接紐づけられます(13:38)。

var body: some View {
    TripView(trip: trip)
        .onHover { showAttachments = $0 }
        .overlay {
            MessageAttachments(attachments: trip.attachments)
                .opacity(showAttachments ? 1 : 0)
        }
        .accessibilityActions {
             MessageAttachments(attachments: trip.attachments)
        }
}
  • .accessibilityActions は overlay と同じビューを受け取り、中のコントロールを custom action に変換する
  • overlay は視覚表示、accessibilityActions は支援技術とのやり取りを担当し、同じビューロジックを共有する
  • VoiceOver は hover をトリガーする必要がなく、action メニューから直接添付ファイルを操作できる

動的ラベルビルダー

評価はある場合とない場合があり、ハードコードできません。新しい accessibilityLabel ビルダーはクロージャを受け取り、クロージャのパラメータは現在の element の既存 label です。前後にコンテンツを追加できます(15:16)。

var body: some View {
  TripView(trip: trip)
      .accessibilityLabel { label in
         if let rating = trip.rating {
            Text(rating)
         }
         label
      }
}
  • クロージャパラメータ label は SwiftUI が既に持っているラベルコンテンツ
  • Text(rating) が前、label が後。VoiceOver は評価を先に、本文を後に読み上げる
  • rating が nil のときは何も追加せず、元のラベルのみ残る

ドラッグ&ドロップのアクセシビリティ対応

カスタム drop delegate が複数のドロップ地点をサポートする場合、VoiceOver はどこに置けるか分かりません。.accessibilityDropPoint は各ドロップ地点に説明を提供します(17:42)。

var body: some View {
    CommentAlertView(contact: contact)
        .onDrop(of: [.audio], delegate: delegate)
        .accessibilityDropPoint(.leading, description: "Set Sound 1")
        .accessibilityDropPoint(.center, description: "Set Sound 2")
        .accessibilityDropPoint(.trailing, description: "Set Sound 3")
}
  • 3 つの .accessibilityDropPoint が各ドロップ地点の位置と意味を説明する
  • VoiceOver のドラッグ&ドロップ中、「Set Sound 1」「Set Sound 2」「Set Sound 3」と順に読み上げられる
  • この修飾子がない場合、VoiceOver は「drop ready」としか分からず、各地点の意味は分からない

Widget 内の App Intent アクション

Widget ビューはリアルタイム更新されないため、accessibilityAction のクロージャ形式は使えません。iOS 18 では App Intent を受け取るオーバーロードが追加され、VoiceOver がアクションをトリガーすると intent が直接実行されます(19:45)。

var body: some View {
    ForEach(beaches) { beach in
        BeachView(beach)
            .accessibilityAction(
                named: "Favorite",
                intent: ToggleRatingIntent(beach: beach, rating: .fullStar))
            .accessibilityAction(
                .magicTap,
                intent: ComposeIntent(type: .photo))
    }
}
  • named: "Favorite" はカスタムアクションを定義し、VoiceOver の action メニューに「Favorite」と表示される
  • intent: ToggleRatingIntent(...) はダブルタップ時に実行する App Intent を指定し、Widget は自動的に更新される
  • .magicTap は VoiceOver の 2 本指ダブルタップジェスチャで、写真撮影の ComposeIntent にマッピングされる

重要ポイント

  • 何をするか: カスタム Shape や装飾アイコンに .accessibilityLabel() を追加する。なぜ価値があるか: Shape と純粋な装飾 Image はデフォルトで element を生成せず、VoiceOver ユーザーはこの情報をまったく認識できない。始め方: Circle()RoundedRectangle()Image(systemName:) をグローバル検索し、ラベルが必要か 1 つずつ確認する。

  • 何をするか: .accessibilityElement(children: .combine) でリスト行を統合する。なぜ価値があるか: 統合後は VoiceOver ユーザーが 1 回で完全な情報を聞け、ボタンは自動的に custom action になり、ナビゲーション効率が倍増する。始め方: リスト項目の HStack/VStack を見つけ、最外層に .accessibilityElement(children: .combine) を追加し、VoiceOver で確認する。

  • 何をするか: hover オーバーレイと動的コンテンツを .accessibilityActions と動的 accessibilityLabel で支援技術に公開する。なぜ価値があるか: hover インタラクションはマウスユーザーには快適だが、キーボードと VoiceOver ユーザーには 4〜5 ステップ必要。オーバーレイ内容を action として抽出するかラベルに追加すれば 1 ステップで完了する。始め方: すべての .onHover と条件表示 overlay を見つけ、対応する accessibility 修飾子を追加する。

  • 何をするか: Widget に accessibilityAction(named:intent:) を追加する。なぜ価値があるか: Widget はクロージャアクションをサポートしないが、App Intent アクションにより VoiceOver ユーザーが主要操作を直接実行して UI を更新できる。始め方: Widget の ForEach ループで主要操作に .accessibilityAction(named:intent:) を追加し、.magicTap を最もよく使う操作に紐づけることも検討する。

関連セッション

コメント

GitHub Issues · utterances