ハイライト
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 は不要。
SwiftUI(01: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 で一貫して提供
UIKit(02: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
}
// ...
}
キーポイント:
- 優先度は
AttributedStringのaccessibilitySpeechAnnouncementPriorityで設定 - 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 では
NSAttributedStringのUIAccessibilitySpeechAttributeAnnouncementPriorityを使用 - 対応定数:
UIAccessibilityPriority.high、.default、.low
3. Zoom Action: VoiceOver ユーザーも画像をズームできる
画像ビューアのピンチズームは VoiceOver ユーザーには使えません — VoiceOver ジェスチャーがタッチイベントを横取りするからです。新しい accessibilityZoomAction で VoiceOver ユーザーがアクセシビリティジェスチャーでズームできます。
SwiftUI(06: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で現在の倍率を報告 — ユーザーがどの程度ズームしたか分かる
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 では
UIScrollViewのaccessibilityZoomIn(at:)とaccessibilityZoomOut(at:)をオーバーライド - 先にビューの
accessibilityTraitsを[.image, .supportsZoom]に設定 trueを返すとズーム操作が処理されたことを示す
4. Direct Touch: VoiceOver ジェスチャーを App に透過
VoiceOver の読み上げが操作を妨げるシーンがあります — ピアノキーボード App でユーザーが素早く連続でキーを押す必要があるとき、毎回 VoiceOver が読み上げるとノイズになります。
Direct Touch は特定のビュー領域を指定し、ユーザーのジェスチャーを VoiceOver の横取りをスキップして App に直接渡せます。
SwiftUI(10: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 に直接渡される
UIKit(10: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 モードに移行
手動で accessibilityValue、accessibilityHint を設定しているコードをすべて検索。accessibilityValueBlock など block ベースのプロパティに移行。状態同期バグを排除 — 最も一般的なアクセシビリティ問題の 1 つです。
関連セッション
- Explore media formats for the web — WWDC23 Keynote で言及されたアクセシビリティ改善の概要
- Design considerations for vision and motion — 視覚と動きのデザインがアクセシビリティ体験に与える影響
- Extend Speech Synthesis with personal and custom voices — アクセシビリティシーンでの音声合成の活用
- Meet Assistive Access — 認知障害ユーザー向けに設計された Assistive Access モード
- Explore enhancements to RoomPlan — RoomPlan のアクセシビリティ強化
コメント
GitHub Issues · utterances