WWDC Quick Look 💓 By SwiftGGTeam
Build accessible apps with SwiftUI and UIKit

Build accessible apps with SwiftUI and UIKit

元の動画を見る

ハイライト

Apple は SwiftUI と UIKit に AccessibilityNotification のアナウンス優先度、Toggle trait、Zoom Action、Direct Touch、UIKit プロパティの自動同期などのアクセシビリティ API を追加しました。VoiceOver の操作がより正確になり、アナウンスが互いに干渉しなくなり、開発者が手動でアクセシビリティ状態を維持するコード量が減ります。


主要内容

課題: カスタムコントロールは VoiceOver ユーザーにとって不透明

写真編集 App にフィルター切り替えボタンがあると想像してください。視力のあるユーザーはボタン背景が薄緑から濃緑に変わるのを見て、フィルターのオン/オフが一目で分かります。VoiceOver ユーザーには「ボタン」としか聞こえず、このボタンにオン/オフ状態があることは全く分かりません。

以前は開発者が手動で accessibility hint を書いていました:「ダブルタップでフィルター状態を切り替え」。しかし問題があります — 開発者ごとに文言が異なり、App ごとに違う説明を聞く。さらに悪いことに、システムは toggle だと認識しない — VoiceOver は現在の状態を自動報告しません。

Apple のソリューション: 6 つのアクセシビリティ強化

WWDC 2023 は 6 つの重要な改善をもたらしました — ボタン状態の宣言、アナウンス制御、ジェスチャーインタラクション、視覚アクセシビリティ、プロパティ自動同期をカバーします。


詳細

1. Toggle Trait: ボタン状態の自動読み上げ

isToggle trait はカスタムトグルボタンのアクセシビリティ記述問題を解決します。この 1 行を追加すると VoiceOver が自動で「トグルボタン、オン」または「オフ」と言います — 手動 hint は不要。

SwiftUI01:54):

import SwiftUI

struct FilterButton: View {
    @State var filter: Bool = false

    var body: some View {
        Button(action: { filter.toggle() }) {
            Text("Filter")
        }
        .background(filter ? darkGreen : lightGreen)
        .accessibilityAddTraits(.isToggle)
    }
}

キーポイント:

  • .accessibilityAddTraits(.isToggle) でシステムにこのボタンが toggle タイプだと伝える
  • VoiceOver が現在の Bool 状態を自動読み取り — 手動で accessibilityValue を設定不要
  • システムが「ダブルタップで設定を切り替え」ヒントをすべての App で一貫して提供

UIKit02:31):

import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let filterButton = UIButton(type: .custom)

        setupButtonView()

        filterButton.accessibilityTraits = [.toggleButton]

        view.addSubview(filterButton)
    }
}

キーポイント:

  • UIKit では accessibilityTraits.toggleButton を使用
  • SwiftUI の .isToggle と完全に同じ効果
  • accessibilityHint 文字列の設定は不要

2. AccessibilityNotification: クロスプラットフォーム統一通知 API

以前、SwiftUI でアクセシビリティアナウンスを送るには UIKit の UIAccessibility.post(notification:argument:) を呼ぶ必要がありました。SwiftUI には独自の通知 API がありませんでした。WWDC 2023 で AccessibilityNotification が導入 — Swift ネイティブのクロスプラットフォームソリューションです。

アナウンスの送信03:43):

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            PhotoFilterView
                .toolbar {
                    Button(action: {
                        AccessibilityNotification.Announcement("Loading Photos View")
                            .post()
                    }) {
                        Text("Photos")
                    }
                }
        }
    }
}

キーポイント:

  • AccessibilityNotification.Announcement("メッセージ") でアナウンスオブジェクトを作成
  • .post() で送信 — VoiceOver が括弧内の文字を読み上げ
  • SwiftUI、UIKit、AppKit すべてで使用可能
  • 4 種類の通知タイプ: Announcement、LayoutChanged、ScreenChanged、PageScrolled

アナウンス優先度05:13):

今回のアップデートで最も重要な改善です。以前は複数のアナウンスが同時に発火すると互いに割り込んだり重なったりし、混乱した音声になりました。アナウンスは 3 段階の優先度をサポート:

import SwiftUI

struct ZoomingImageView: View {

    var defaultPriorityAnnouncement = AttributedString("Opening Camera")

    var lowPriorityAnnouncement: AttributedString {
        var lowPriorityString = AttributedString("Camera Loading")
        lowPriorityString.accessibilitySpeechAnnouncementPriority = .low
        return lowPriorityString
    }

    var highPriorityAnnouncement: AttributedString {
        var highPriorityString = AttributedString("Camera Active")
        highPriorityString.accessibilitySpeechAnnouncementPriority = .high
        return highPriorityString
    }

    // ...
}

キーポイント:

  • 優先度は AttributedStringaccessibilitySpeechAnnouncementPriority で設定
  • 3 段階: .high(即座に現在の音声を割り込み、自身は割り込まれない)、.default(通常キュー)、.low(すべての default の後)
  • 複数アナウンスを同時送信するとき、.high で重要情報を優先配信

実際の使用シーン05:46):

import SwiftUI

struct CameraButton: View {

    // ...

    var body: some View {
        Button(action: {
            // Open Camera Code
            AccessibilityNotification.Announcement(defaultPriorityAnnouncement).post()
            // Camera Loading Code
            AccessibilityNotification.Announcement(lowPriorityAnnouncement).post()
            // Camera Loaded Code
            AccessibilityNotification.Announcement(highPriorityAnnouncement).post()
        }) {
            Image("Camera")
           }
        }
    }
}

キーポイント:

  • カメラを開くとき 3 つのアナウンスを連続送信 — 優先度で「Camera Active」が先に聞こえる
  • .low 優先度の「Camera Loading」は他の重要情報を割り込まない
  • .default 優先度のアナウンスは通常順にキューイング

UIKit 対応の書き方06:15):

class ViewController: UIViewController {
    let defaultAnnouncement = NSAttributedString(string: "Opening Camera", attributes:
        [NSAttributedString.Key.UIAccessibilitySpeechAttributeAnnouncementPriority:
        UIAccessibilityPriority.default]
    )

    let lowPriorityAnnouncement = NSAttributedString(string: "Camera Loading", attributes:
        [NSAttributedString.Key.UIAccessibilitySpeechAttributeAnnouncementPriority:
        UIAccessibilityPriority.low]
    )

    let highPriorityAnnouncement = NSAttributedString(string: "Camera Active", attributes:
        [NSAttributedString.Key.UIAccessibilitySpeechAttributeAnnouncementPriority:
        UIAccessibilityPriority.high]
    )

    // ...
}

キーポイント:

  • UIKit では NSAttributedStringUIAccessibilitySpeechAttributeAnnouncementPriority を使用
  • 対応定数: UIAccessibilityPriority.high.default.low

3. Zoom Action: VoiceOver ユーザーも画像をズームできる

画像ビューアのピンチズームは VoiceOver ユーザーには使えません — VoiceOver ジェスチャーがタッチイベントを横取りするからです。新しい accessibilityZoomAction で VoiceOver ユーザーがアクセシビリティジェスチャーでズームできます。

SwiftUI06:56):

struct ZoomingImageView: View {
    @State private var zoomValue = 1.0
    @State var imageName: String?

    var body: some View {
        Image(imageName ?? "")
            .scaleEffect(zoomValue)
            .accessibilityZoomAction { action in
                let zoomQuantity = "\(Int(zoomValue)) x zoom"
                switch action.direction {
                case .zoomIn:
                    zoomValue += 1.0
                    AccessibilityNotification.Announcement(zoomQuantity).post()
                case .zoomOut:
                    zoomValue -= 1.0
                    AccessibilityNotification.Announcement(zoomQuantity).post()
                }
            }
    }
}

キーポイント:

  • .accessibilityZoomAction はクロージャを受け取り、パラメータに action.direction を提供
  • action.direction.zoomIn.zoomOut の 2 値
  • ズーム後に AccessibilityNotification.Announcement で現在の倍率を報告 — ユーザーがどの程度ズームしたか分かる

UIKit07:18 + 07:43):

import UIKit

class ZoomingImageView: UIScrollView {
    override func accessibilityZoomIn(at point: CGPoint) -> Bool {
        zoomScale += 1.0

        let zoomQuantity = "\(Int(zoomScale)) x zoom"
        UIAccessibility.post(notification: .announcement, argument: zoomQuantity)
        return true
    }

    override func accessibilityZoomOut(at point: CGPoint) -> Bool {
        zoomScale -= 1.0

        let zoomQuantity = "\(Int(zoomScale)) x zoom"
        UIAccessibility.post(notification: .announcement, argument: zoomQuantity)
        return true
    }
}

キーポイント:

  • UIKit では UIScrollViewaccessibilityZoomIn(at:)accessibilityZoomOut(at:) をオーバーライド
  • 先にビューの accessibilityTraits[.image, .supportsZoom] に設定
  • true を返すとズーム操作が処理されたことを示す

4. Direct Touch: VoiceOver ジェスチャーを App に透過

VoiceOver の読み上げが操作を妨げるシーンがあります — ピアノキーボード App でユーザーが素早く連続でキーを押す必要があるとき、毎回 VoiceOver が読み上げるとノイズになります。

Direct Touch は特定のビュー領域を指定し、ユーザーのジェスチャーを VoiceOver の横取りをスキップして App に直接渡せます。

SwiftUI10:10):

import SwiftUI

struct KeyboardKeyView: View {
    var soundFile: String
    var body: some View {
        Rectangle()
            .fill(.white)
            .frame(width: 35, height: 80)
            .onTapGesture(count: 1) {
                playSound(sound: soundFile, type: "mp3")
            }
            .accessibilityDirectTouch(options: .silentOnTouch)
    }
}

キーポイント:

  • .accessibilityDirectTouch(options: .silentOnTouch) で直接タッチを有効化し、タッチ時の読み上げを抑制
  • VoiceOver の探索(explore)モードでは要素を読み上げ、ユーザーが位置を把握できる
  • 直接タッチがアクティブになると、ユーザーが領域を出るまですべての後続ジェスチャーが App に直接渡される

UIKit10:46):

import UIKit

class ViewController: UIViewController {
    let waveformButton = UIButton(type: .custom)

    override func viewDidLoad() {
        super.viewDidLoad()

        waveformButton.accessibilityTraits = .allowsDirectInteraction
        waveformButton.accessibilityDirectTouchOptions = .silentOnTouch
        waveformButton.addTarget(self, action: #selector(playTone), for: .touchUpInside)

        view.addSubview(waveformButton)
    }
}

キーポイント:

  • UIKit では accessibilityTraits = .allowsDirectInteraction で直接インタラクションを許可
  • accessibilityDirectTouchOptions = .silentOnTouch はオプションで、タッチ時の音声フィードバックをオフに

5. 視覚アクセシビリティの強化

Content Shape のカスタマイズ12:21):

SwiftUI では VoiceOver フォーカス時の枠線はデフォルトで矩形ですが、円形要素に矩形枠はユーザーを混乱させます。新しい .contentShape API でアクセシビリティフォーカス用のカスタム形状を設定できます:

import SwiftUI

struct ImageView: View {
    var body: some View {
        Image("circle-red")
            .resizable()
            .frame(width: 200, height: 200)
            .accessibilityLabel("Red")
            .contentShape(.accessibility, Circle())
    }
}

キーポイント:

  • .contentShape(.accessibility, Circle()) で VoiceOver フォーカスハイライトを円形に
  • 第 1 引数 .accessibility はこの形状がアクセシビリティ専用で、タップ領域には影響しないことを指定
  • 任意の SwiftUI Shape タイプをサポート

6. UIKit プロパティ自動同期: 手動更新にさよなら

UIKit では UI 状態が変化するとき(例: フィルター切り替え)、開発者は accessibilityValue の更新を忘れずに行う必要がありました。1 回でも漏れると VoiceOver ユーザーは誤った状態を聞きます。

WWDC 2023 は block ベースのプロパティセッターを導入し、アクセシビリティ属性値を UI 状態から自動派生させます:

import UIKit

class ViewController: UIViewController {
    var isFiltered = false

    override func viewDidLoad() {
        super.viewDidLoad()
        // Set up views
        zoomView.accessibilityValueBlock = { [weak self] in
            guard let self else { return nil }
            return isFiltered ? "Filtered" : "Not Filtered"
        }
    }
}

キーポイント:

  • accessibilityValueBlock はクロージャを受け取り、VoiceOver が値を読むたびに呼ばれる
  • クロージャは [weak self] で循環参照を回避
  • isFiltered が変われば、次回 VoiceOver が読むときに最新値を自動取得
  • 状態変更のたびに手動で accessibilityValue を設定する必要がない

重要ポイント

1. すべてのカスタムトグルボタンを改造

App 内のすべてのカスタム toggle ボタンを確認 — トグル動作を実装した UIButton や SwiftUI Button。.isToggle trait(SwiftUI)または .toggleButton(UIKit)を追加。1 行のコードで VoiceOver 体験が即座に向上します。

2. 重要な操作に優先度付きアナウンスを追加

ユーザーが「すぐに知る必要がある」状態変化を洗い出す — 支払い成功、ファイルアップロード完了、ナビゲーション到着など。無音の状態更新を .high 優先度の AccessibilityNotification.Announcement に置き換え。進捗系情報(「さらに読み込み中」など)は .low 優先度で重要情報の割り込みを避ける。

3. 画像ビューアにアクセシビリティズームを追加

画像閲覧や地図表示機能がある App には accessibilityZoomAction を追加。VoiceOver ユーザーは rotor ジェスチャーで拡大縮小でき、物理的なピンチは不要。ズーム後に現在の倍率をアナウンスすること。

4. Direct Touch シーンを特定

素早い連続操作が必要な要素を見つける — キーボード、画板、ゲームコントローラー、音楽パッド。これらの領域で Direct Touch を有効化し、VoiceOver ユーザーのインタラクション遅延を最小化。探索モードでユーザーがここが何か分かるよう、明確な accessibilityLabel も確保。

5. UIKit アクセシビリティプロパティを Block モードに移行

手動で accessibilityValueaccessibilityHint を設定しているコードをすべて検索。accessibilityValueBlock など block ベースのプロパティに移行。状態同期バグを排除 — 最も一般的なアクセシビリティ問題の 1 つです。


関連セッション

コメント

GitHub Issues · utterances