ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
SwiftUIのアクセシビリティ: 基礎を超えて
基礎を超えたレベルに進んで、卓越したアクセシビリティ体験を提供しましょう。Xcodeの新しいSwiftUIプレビューを使用して、最新のアクセシビリティAPIを試してみて、あらゆるユーザが利用できる素晴らしいAppを作成する方法を確認しましょう。SwiftUIに組み込まれた自動アクセシビリティをカスタマイズして、独自のカスタムコントロールを利用可能にする方法を紹介します。ベストプラクティスを検証し、グループピングとフォーカスを使用してAppのナビゲーション体験の改善箇所を明らかにします。また、ローターを追加することで、VoiceOverユーザのためのナビゲーションを強化します。
リソース
関連ビデオ
WWDC23
WWDC21
WWDC20
-
ダウンロード
♪ (SwiftUIのアクセシビリティ: 基礎を超えて) こんにちは WWDCへようこそ! 私はNathanといいます Accessibilityチームのエンジニアをしています 今日は 基本を超えて優れたアクセシブルな SwiftUI Appを提供する方法について学びます 今年は SwiftUIの アクセシビリティが大きく飛躍した年です Appleではアクセシビリティは コアバリューの1つです すべてのプラットフォームに搭載 されているAppleの支援技術は 身体的 視覚的 聴覚的 運動的な 障害の有無にかかわらず 誰もがAppを利用できるようにします 私と私のチームはAppのほとんどが デフォルトでアクセス可能である ように取り組んでいますが 経験をより豊かにするために できることは常にあります この体験を簡単に豊かにする SwiftUIの新しいツールとAPIをご紹介します まずは Xcodeにおける ツールの改善から始めましょう SwiftUIのプレビューは私達の Appの開発方法を変えました Appを実行しなくても複数の環境で ビューを反復する能力が 劇的に向上します 最も重要なアクセシビリティ修飾子を クリックするだけで 表示できるように アクセシビリティエディタの 厳選されたリストを追加しました これで全ての人が常に 自分のビューにアクセスできる ようになるといいですね しかし それだけではありません アクセシビリティ修飾子はプレビューでは 視覚的な変更がないため Xcodeを離れることなくビューのアクセシビリティを 検査できる新しいツールが開発されました エディターパネルから 新しいアクセシビリティパネルに 切り替えてみましょう Xcode 13で出荷されるSwiftUIプレビューには 「アクセシビリティプレビュー」が搭載されます これにより プレビューのアクセシビリティ要素を リアルタイムで検査することができます これはゲームチェンジャーです アクセシビリティプレビューは各支援技術を 深く理解していなくても 優れたアクセシビリティAppを 作成するのに役立ちます アクセシビリティプレビューにどのように 反映されるのかさらに詳しく見てみましょう 先ほどの景色を簡略化したものです プレビューを実行した後VStackを選択すると アクセシビリティプレビューが更新され 要素がソートされた順番に表示されます 各要素には ラベルや特性などの 基本的なプロパティが 常に表示されることに注意してください 例えば 「テキスト」ビューでは 文字列をラベルとする アクセシビリティ要素が作成されます また .isStaticTextトレイトも得られます isHeaderトレイトの追加などアクセシビリティに 変更を加えた場合はプレビューが更新されます また SF Symbolsラベルの自動化など 舞台裏で行われている 自動アクセシビリティの一部を 確認することができます 例えば checkmark.seal.fillシンボルは デフォルトでは「検証済み」と表示されています シンボルのデフォルトラベルに依存 している場合 それが インターフェイスを正確に表現しているかを 確認することが重要です 引き続きアクセシビリティプレビューを使用して Appの全てのユーザーに優れた体験を提供するために 重要な5つの分野について 説明します まず カスタムコントロールを アクセシブルにするための 最良の方法を検討します 次に子供でもアクセスできるビューを 作る方法について説明します 一般的なナビゲーションの問題を Appで監査する方法と アクセシビリティプレビューが どのように役立つかをご紹介します 次に VoiceOverの ローターを使ってAppのナビゲーションを 強化する方法をご紹介します そして最後に フォーカスと それが支援技術とどのように 関連しているかを見ていきます 私は 「Wallet Pal」という新しい金融Appの プロトタイプを作っています まだ初期段階ですが 今のところ初期の設計にはかなり満足しています ベータテスターにも気に入って もらえたようで喜んでいます さて UIに磨きをかけたところで 今度はアクセシビリティ インターフェイスを磨くことにも 時間をかける必要があります アクセシビリティインターフェイスは 誰もが使いやすいように 視覚的な体験を補完するものです VoiceOverをお使いの方にWallet Palのテストを お願いしたところ Appの操作が 難しく 完全にアクセスできない という意見をもらいました VoiceOverに対応していない部分は 他の支援技術にも 対応していないことになります 誰もがWallet Palを 使えるようにすることが重要なので どこを改善できるか 調査してみましょう まずは ユーザーからの Wallet Palの重要な機能である 予算の編集ができないという報告から始めます これは予算プランナーのビューで ユーザーは食費 娯楽費 貯蓄の 予算を編集できます Wallet Palの設計に合わせて カスタムスライダを作成する必要がありました そのため シェイプで予算スライダーを作成し ドラッグジェスチャーで操作することにしました SwiftUIのシェイプを使用すると 魅力的でユニークなビューを 簡単につくることができますが デフォルトではアクセスできないため 予算スライダーもアクセスできません これが一部のユーザーが 予算を編集できない理由でしょう SwiftUIプレビューを実行しSliderShapeを 選択することでアクセス できないことを確認できます アクセシビリティプレビューを 見ると要素がないので このコントロールにはアクセスできません 理想的には 変更可能な値を持つラベル付きの アクセシビリティ要素が 1つあればよいのです 一方 標準的なコントロールは デフォルトでアクセス可能です つまり 優れた体験を提供するための 余分な努力は殆どありません SwiftUIはアクセシビリティ要素のラベル 値 特性 アクションを自動的に導き出すために ビュータイプと初期化パラメータを使用します では カスタム予算スライダーを アクセス可能にするには どうすれば一番よいでしょうか? 標準のスライダーはデフォルトで アクセス可能なので あるビューのアクセシビリティを別のビューで 表現できるようなAPIがあればいいなと 私たちのチームは考えました これがaccessibilityRepresentation(representation:)を 作るきっかけとなりました これは あるビューの アクセシビリティを別のビューで 定義することができるAPIです これを支援技術によって スライダーとして認識させたいので アクセシビリティの表現にはこれを使います accessibilityRepresentation(representation:)を使って この予算スライダーを アクセス可能にすることができました スライダーの操作性を向上させる ために必要な変更点は 金額をドルで表記することだけです これで予算スライダーに完全に アクセスできるようになりました macOSではVoiceOverを使っているユーザーには コントロールの種類が音声で通知されます つまり BudgetSliderの表示に スライダービューを使うことで スライダーとしてアナウンスされるのです カスタムコントロールを アクセス可能にするためには可能な限り accessibilityRepresentation(representation:)を 使用することを推奨します その柔軟性により より多くの クリエイティブな使い方が可能です それでは このAppを使って 他のアクセシビリティのバグを 修正できるかどうかを見てみましょう 予算を編集する機能は修正されましたが 1部のユーザーからは 予算プランナービューへの 移動方法が分からないという報告がありました Wallet Palを設計する際全てのボタンに SF Symbolsを使うことにしました SF Symbolsはデフォルトアクセシビリティラベル としては優れていますが 意図した 使用例に適さない場合もあります 問題がボタンのラベルの不備なのか 調べてみましょう NavigationBarViewには 「予算の編集」ボタンがあります たくさんのボタンにSF Symbolを使用しており SymbolButtonStyleという カスタムbuttonStyleを作りました しかし「予算の編集」というラベルで ボタンを初期化したにも関わらずラベルは slider.vertical.3になっているようです つまり アクセシビリティラベルは SF Symbolから派生しているのです SymbolButtonStyleを詳しく見てみましょう SymbolButtonStyle の makeBody(configuration:) メソッドは イメージビューを返します ボタンを初期化した「予算の編集」の文字列では 構成のラベルとしてテキストビューが生成されます しかし このスタイルでは 構成のラベルを完全に無視します ボタンに「予算の編集」のラベルが 付いていないのはこれが理由です SF Symbolを表示したいのですが ボタンのアクセシビリティは 構成のアクセシビリティで 表したいのです というわけで これは accessibilityRepresentation(representation:)の 良いユースケースだと思われます accessibilityRepresentation(representation:)を使うと イメージビューのアクセシビリティ を別のビュー この場合は 構成のラベルで代用することができます これで ボタンを作成する際に 使用したラベルを保持することができます accessibilityRepresentation(representation:)は カスタムコントロールを アクセス可能にするための理想的で 推奨される方法であるだけでなく ビューをアクセシブルにするための 新しい創造的な可能性を 開くものでもあります 次に 子とアクセシビリティコンテナの 関係についてお話しましょう 思い出すかもしれませんがアクセシビリティ要素は アクセシビリティコンテナの子 としてグループにまとめられます これにはaccessibilityElement(children:)修飾子と contain: ChildBehaviorが使われます よく分からない方のために説明すると contain: ChildBehaviorを使用すると 既存のアクセシビリティ要素を 子としてラップするアクセシビリティコンテナを 作成することができます しかし アクセシビリティ要素があって その子を設定したい場合は どうすればよいのでしょうか 予算履歴グラフをアクセスシブルにしようとすると このようなケースに遭遇します グラフに全くアクセスできない というユーザーの報告がありました つまり 支援技術が 予算履歴のヘッダーに注目して 次の要素に移動しようとすると Alertsのヘッダーにたどり 着いてしまうということです VoiceOverユーザーはこの グラフの存在すら知らないでしょう グラフの作成には 新しいビュー 「Canvas」を使用しました Canvasを使うと 図形の 集まりを簡単に描くことができます 詳細についてはどうやってCanvasでAppに豊富な グラフを追加するかについての Jacobのプレゼンをご覧ください アクセシビリティの観点から最も重要なことは Canvasが図形の集まりを描画するということです BudgetSliderのように図形は デフォルトではアクセスできません すべてのユーザが自分の 予算の履歴を見る必要があるため 誰でもアクセスできるようにしましょう まず基本的ですがCanvasにラベルを付けましょう これにより Canvas用の 新しいアクセシビリティ要素が 自動的に作成されそのラベルが割り当てられます 次に グラフの各バーをそれぞれの アクセシビリティ要素で表したいと思います これは アクセシビリティ要素が あって その子を提供したい場合の 使用例です そのためには 新しい accessibilityChildren(children:)修飾子を使います これで アクセシビリティ要素が アクセシビリティコンテナに 変換されラベルなどの他の アクセシビリティプロパティが維持されます この修飾子は ViewBuilderを受け取り アクセシビリティコンテナの子として 新しいビューを設定することができます 予算履歴グラフは 水平方向の棒グラフを描いているので HStackを使用して各予算のビューを返します 各アクセシビリティ要素にRectangleでフレーム 利用可能な全ての垂直方向の スペースを埋めるようにします これで 各アクセシビリティ要素の フレームは視覚的に 表示されるものよりも大きく なりますが 問題ありません 大きくても一貫性のあるフレームがあれば iOSでVoiceOverユーザーが 画面上で指をドラッグしてスキャンすると アクセシビリティ要素を探すことができます accessibilityChildren(children:)修飾子内で HStackを選択すると アクセシビリティプレビューでは グラフの各バーに要素が 作成されていることを確認できます これらはすべてCanvasアクセシビリティコンテナの 子としてアクセス可能になります これらの変更により 予算履歴グラフは完全にアクセス可能になり 支援技術によってグラフ内の 各バーをナビゲートできるようになりました 各要素のフレームが 同じであることに注目してください これは理想的です より複雑なグラフについては アクセシビリティを確保する別の方法があります グラフへのアクセシビリティの導入に関する Prestonのプレゼンテーションをご覧ください accessibilityChildrenを使えば ビューの アクセシビリティ要素は 視覚的に 表示されるものとは異なるため 素晴らしい体験を仕立てることができます しかし accessibilityChildrenは combineの助けを借りて アクセシビリティを構成するためにも使用できます 簡単に説明すると子のcombineは 複数のアクセシビリティ要素のプロパティを 新規または既存の アクセシビリティ要素に結合します しかし accessibilityChildren API が追加されたことで 一般的な方法でアクセシビリティを構成するためにも 使用できるようになりました accessibilityRepresentation(representation:)を使って 元のアクセシビリティが完全に置き換えられます これは構成を行うことができないことを意味します 一方 accessibilityChildrenは加算的です これは 後で子を結合してそれらのプロパティを 元の要素にマージすることができます これは accessibilityChildren(children:) 修飾子のより高度な使用例ですが 私が強調したい機能です このプレゼンテーションの アクセシビリティカタログ サンプルプロジェクトでは その例が紹介されています このような構成で何ができるのかを 多くの方々に見ていただきたいと思います accessibilityChildren(children:)はコンテナの子を 制御することができます また Canvasで描いた複雑なグラフも 使い慣れた修飾子やビューを使って アクセスできるようになります 子を結合する動作を使えば 1つのビューのアクセシビリティを 多くのビューで構成することができます Appの個々のコンポーネントを アクセス可能にする方法を 学んだので 今度はナビゲーション 体験を洗練するために いろいろなものを結合してみましょう VoiceOverを使ったWallet Palの操作は 混乱して難しいと聞いています 素晴らしくてアクセシブルなAppを提供するためには まだやるべきことがあります 一番上にある「友達」のカルーセルを見てみましょう この機能は まだ作っていませんが Wallet Palに何らかの ゲーミフィケーションを追加する予定です そこで 各友達ビューの左上に チャレンジボタンを追加しました すでにSymbolButtonStyleを修正したので チャレンジボタンは正しく 表示されるようになりました しかし ユーザーからは 「ナビゲーションが分かりにくい」 という報告を受けていますが 他にも何か問題はありますか? この答えとしては まず支援技術が Wallet Palをどのように ナビゲートするかを理解する必要があります デフォルトではアクセシビリティ要素は 他の要素 との関係における幾何学的な位置に基づいて 左上から右下へとソートされます つまり コンテンツを区別する アクセシビリティコンテナが なければVoiceOverは それぞれのチャレンジボタン 次に画像と友達追加ボタン 最後に ユーザー名が書かれたテキストを ナビゲートすることになります アクセシビリティプレビューの大きな特徴は アクセシビリティ要素が ソート された状態で表示されることです これにより Xcode上で 支援技術がどのように動作するかを 簡単に視覚化することができます 予想通り ソートされた順序は 以前に見たものと一致しています この順番は 間違いなくナビゲート の際に混乱を招くでしょう さて アクセシビリティ要素の ソート順を修正する方法はいくつかあります 1つは アクセシビリティコンテナの導入です accessibilityElement修飾子を 包含動作で追加してみます これで 各FriendCellViewのアクセシビリティ要素が アクセシビリティコンテナに包まれます これにより アクセシビリティコンテナの子が次の アクセシビリティ要素に移動する前に ナビゲートされるため ナビゲーション順序が修正されます この変更によりVoiceOverは アクセシビリティコンテナの子を ナビゲートしてから 次の アクセシビリティ要素に移動します このようにして より望ましい ナビゲーション順序を実現します しかし この体験向上のために より良い方法はないのでしょうか? 問題の1つは チャレンジボタンが ユーザーが誰であるかを知る前に ナビゲートされることです VoiceOverのユーザーはチャレンジを送信する前に ユーザーの名前を知りたいと思うでしょう そのため このボタンは最後にソートされるべきです この問題を解決するためにアクセシビリティの accessibilitySortPriority(_:)修飾子を 使用することができます accessibilitySortPriority(_:)は アクセシビリティコンテナ内の 要素の順序を変更するために使用できます 優先度が高い要素は最初にソートされ 優先度が低い要素は最後にソートされます 優先順位が同じ要素は 幾何学的な位置関係に基づいてソートされます ナビゲーションの順序改善のために チャレンジボタンに アクセシビリティの SortPriority(_:)修飾子を追加します デフォルトは0なので 優先度を -1にして チャレンジボタンの ソート順を強制的に最後にするようにします これで チャレンジボタンは 最後に表示されるようになります これは VoiceOverユーザーが 誰にチャレンジを送っているのか 混乱する可能性が減るので これは良い改善ですが それでも素晴らしい改善とは言えません 各FriendCellViewをアクセシビリティコンテナで ラッピングする代わりに 子供たちを1つの要素にまとめることができます 結合は このプロパティを既存のまたは 新しいアクセシビリティ要素にマージします 結合動作は どのプロパティが マージされるかを調整して デフォルトの最良の結果を得ることもできます 例えば チャレンジボタンは 「チャレンジを送信」という 名前のアクションになりました これも ナビゲーションの順序を修正し アクセシビリティ要素の数を減らしています これで ユーザーごとに1つの 要素が用意され 各要素には 「チャレンジを送る」というアクションがあります ForEachで表現されている ビューのアクセシビリティ要素を 結合することは 多くの場合理想的です もうお分かりかと思いますが 結合は非常に便利な子の動作です 子は個別にナビゲートできるのではなく そのプロパティを単一の ナビゲート可能な要素に統合します 単一の要素が必要だが子からプロパティを 継承させたくない場合は 「動作を無視」を使用します 最後にcontain child動作は子を アクセシビリティコンテナにラップ して関連するビューのグループを 表すために使用します これにより デフォルトの ソート順が改善されるだけでなく 支援技術の面でもメリットがあります 新しいアクセシビリティプレビューを使えば このようなナビゲーションの問題を 簡単に発見することができます また いくつかの小さな変更を加えるだけで エクスペリエンスを劇的に 向上させることができます しかし VoiceOverユーザーに真に優れた ナビゲーション体験を提供するには ローターを考慮する必要があります アクセシビリティに慣れていない方は 「ローターって何だろう?」 とお思いかもしれません 簡単に言うと ローターは強力な ナビゲーションツールです ユーザーが素早くナビゲートできる ブックマークのようなものだと考えてください 見出しやコンテナローターなどの システムローターは この強力なナビゲーションの基礎となります 例えば ユーザーは見出し ローターを使ってセクションを 素早くナビゲートすることができます これは セクションビューが自動的 にisHeaderトレイトを ヘッダービューに追加するからです セクションビューを使用していない場合は accessibilityAddTraitsで isHeaderトレイトを ビューに追加することができます 同様に アクセシビリティコンテナ はコンテナのローターに 追加されますがこれは先程見たように accessibilityElement修飾子で作成されます ご覧の通り 見出しローターのサポートは非常に簡単で コンテナローターはcontain child動作を 使ってアクセシビリティ要素を グループ化する利点を更に高めています Wallet Palの基本的なナビゲーション体験が 改善されてきたので ローターを使ってさらに一歩進めてみましょう Wallet Palでは予算が上限に近づいたり 上限を超えたりしたときに 動機付けとなるメッセージや警告を 出してユーザーの支出傾向を 確認するアラート機能があります それぞれのアラートの種類には SF Symbolを採用しています これにより 視覚的なユーザーは リストに素早く目を通すことができます しかしこのアイコンはVoiceOver ユーザーの役に立ちません 警告が出ているかどうかを知るために すべての警告を確認しなければならないのです 目の見える人と同じような体験をするためには VoiceOverユーザーが 警告だけをナビゲートできるように して 瞬時に次の警告に移動 できるようにしなければなりません そのために ローターを使用することができます カスタムローターのその他の例や 効率化に重要な理由については 2020年に行われたプレゼンテーション 「カスタムローターのVoiceOver効率」 をご覧ください 警告ローターをAertsViewに追加するために まず最初にすることは ローターをアクセシビリティ コンテナに追加することの確認です SwiftUIの幾つかのビューは デフォルトでアクセシビリティ コンテナです 例えばリストやLazyVStackです でもVStackやHStackは違います ontain child動作を持つ accessibilityElement(children:)修飾子を追加します 次に accessibilityRotorr(_:entries:)修飾子で ローターを作成し 名前を「Warning」とします そして最後に どのアラートを 警告ローターに含めたいかを宣言します ここではすべての警告アラートを対象とします これで完成です こんなに簡単にAppの ナビゲーションを強化できるのです このような単純なユースケースで ローターが極めて簡単になる 理由の1つは SwiftUIがIDに基づいて ローターのエントリーを アクセシビリティ要素に自動的に マッチさせることができるからです これは ローターのエントリーの IDが ForEachによって AlertCellViewに与えられたIDと一致するからです アクセシビリティローターのために ビューのアイデンティティを 理解することは重要です そのため ビューのアイデンティティが不明瞭な場合 または再確認したい場合は「SwiftUIの解明」の プレゼンテーションをご覧いただく ことをお勧めします 「ちょっと待って 私のビューはこんなに単純じゃない ForEachの中にないビューは どうするの」と思うかもしれません 心配なさらないでください accessibilityRotor APIは シンプルなビューから 複雑なビューまで拡張できます この単純なケースではAlertCellViewには 結合による単一のアクセシビリティ要素があるため エレガントに動作します そしてAlertCellViewは アラートのIDで識別されます もし すべてのアラートに対して アクションビューがあれば? この場合 VStackはForEachのルートビューとなり アラートのアイデンティティが与えられるのは VStackとなります AlertCellViewを警告ローターに含めるためには ローターのエントリーとして 明示的にマークする必要があります それには accessibilityRotorEntry修飾子を使います この修飾子は 名前空間とIDを 必要としますが これは AccessibilityRotorEntryを 作成するために使われたIDと 名前空間に一致する限り何でも良いのです 最後に 各ローターエントリーに この名前空間を含める必要があります はっきりとした名前空間を参照する機能により accessibilityRotor APIは単純なユースケースから 複雑なユースケースまで拡張でき 複数のビューにまたがる アクセシビリティ要素を 同じローターに含めることができます アクセシビリティローターを追加し テキストナビを強化もできます そのためには accessibilityRotor修飾子の異なるバリエーション を使用して テキスト範囲の配列を 提供することができます この修飾子は Eメール リンク電話番号など TextEditor内の特定の文字列にVoiceOver ユーザーが素早く簡単にアクセス できるようにするのに適しています アクセシビリティローターはVoiceOverユーザーに とって複雑なナビゲーションを簡単にします 新しいSwiftUIのローターAPIを使えば この優れた ナビゲーション体験を提供すること がこれまで以上に簡単になります 今日の最後のテーマは「フォーカス」です フォーカスの要素という概念は 皆さんも既にご存じと思いますが 多くの支援技術が 独自のフォーカス状態を持っていることは ご存じないかもしれません 私達はこれを「アクセシビリティ フォーカス」と呼んでいます これは 支援技術のナビゲーション について言及した時に 変化が見られたフォーカスされたビューです 支援技術のカーソルの位置は ユーザーエクスペリエンスにとって重要です VoiceOverでフォーカスが変更されると カーソルは 要素の説明をするだけではなく フォーカスされた要素のパスに合わせて移動します では どのような場合に フォーカスが変わるのでしょうか? フォーカスは 3つのイベントの いずれかが発生した時に 変更されますが常に変更されるとは限りません 1つ目で最も一般的なケースはユーザーが 別のアクセシビリティ要素に移動する際に フォーカスを促した場合です 2つ目のケースは UIが変更され 前にフォーカスされていたビューが 画面に表示されなくなったり モーダルビューで覆われたりした時です このような場合 最初にソート されたアクセシビリティ要素に フォーカスがリセットされることが多くあります これは 新しく提示されたビューの 最初のアクセシビリティ要素に 移動するなど多くのユースケースを 自動的にカバーします しかし それがAppにおける 最良の動作ではないかもしれません そのため フォーカスをプログラムによる要求で 変更することも可能です ただし VoiceOVerユーザーのフォーカスを 移動させることは 非常に混乱を 招く可能性があるので 慎重に扱う必要があります それではダジャレではなく 最後のケースに注目しましょう 今年 私達には新しいAPIがありそれは支援技術が 現在どこにフォーカスしているかを 読み取ることに加えて 支援技術にフォーカスを移行する ように要求することができます 通知を追跡し通知が存在する場合は カスタムのNotificationBannerを オーバーレイする単純なビューを用意しました このビューをWallet Palで使用して Appが前面にあるときに受信した プッシュ通知のアラートを表示したいと思います NotificationBannerが追加されても 支援技術は自動的にそれにフォーカスしません しかし 新しいAccessibilityFocusState を使ってフォーカスするように 要求することができます AccessibilityFocusStateは 支援技術がどこにフォーカスして いるかを読み取る方法と プログラムによるフォーカスの 変更を要求する方法の両方を 提供するプロパティラッパーです このプロパティをビューに追加し accessibilityFocus(_:)修飾子をつけ NotificationBannerにバインドしてみます 次に onChange(of:perform:)修飾子を使用して 新しい通知が受信された時に追跡します 優先度の高い通知を受信した時だけ アクセシビリティフォーカスの変更を要求します 特に注意しなければならないのは プログラム的にフォーカスを 移動させることは ユーザーの インタラクションが発生 しなかった場合 非常に混乱を招く 可能性があるということです ユーザーが 現在フォーカス されているコンテキストから 外されてしまいます そのため 慎重に取り扱わなければなりません 従って優先度が低い通知については VoiceOver用のアクセシビリティ通知を 投稿してお知らせいたします こうすることで VoiceOverユーザーは 新しい通知が表示されたことを知り 必要に応じてその通知に ナビゲートすることができます では NotificationBanner ビューの中に入ってみましょう 通知が表示されるとタイマーが開始します タイマーが完了すると通知はnilに設定されます 先に述べましたように通知がnilになると NotificationBannerは非表示になります これは VoiceOverユーザーが通知バナーに フォーカスしていてタイマーが切れると ビューが削除されるため フォーカスがリセットされます これは優れたユーザー エクスペリエンスにはなりません この問題を解決するには 通知バナーが支援技術によって フォーカスされているかどうかを読み取り フォーカスされている場合は 通知の解除を遅らせます これが理想的な解決策です フォーカスされている間は ビューが削除されないので もうVoiceOverユーザーの フォーカスはリセットされません さらに 支援技術ユーザーがコンテンツを理解し 必要に応じて対話するための時間を 無制限に与えています この2つは 支援技術ユーザーがそれを行う場合 かなりの時間がかかる可能性があります AccessibilityFocusStateは 今年以降 卓越していて アクセス可能なSwiftUI Appを提供するために必要な 最後の部分です これを使えば 支援技術の焦点を読み取って指示し ビュー間のスムーズな移行を 実現することができます SwiftUIのアクセシビリティ についてたくさんカバーしました SwiftUIのアクセシビリティ を開発・デバッグする方法を 強化する新しいアクセシビリティプレビュー を紹介し カスタムコントロールや複雑なグラフを アクセス可能にする方法を説明しました また グループ化 ローターフォーカスを使って ナビゲーションを改善する方法に ついても時間をかけて学びました すべてをまとめると 今年はアクセシビリティが大きく飛躍した年でした アクセシビリティAPIの単純 または複雑な例については 「Accessibility Catalog」Sample Project をご覧ください 本日ご紹介しきれなかった例や推奨される ベストプラクティスなどが掲載されています ご参加くださりありがとうございました 皆様がAppをどうやって誰もが アクセスできるようにするのか 拝見するのを楽しみにしています ♪
-
-
2:00 - Welcome to the Accessibility Preview
struct ContentView: View { var body: some View { VStack { Text("WWDC 2021") .accessibilityAddTraits(.isHeader) Text("SwiftUI Accessibility") Text("Beyond the Basics") Image(systemName: "checkmark.seal.fill") } } }
-
4:30 - BudgetSlider
struct BudgetSlider: View { @Binding var value: Double var label: String var body: some View { VStack(alignment: .leading) { HStack { Text(label) Text(value.toDollars()).bold() } SliderShape(value: value) .gesture(DragGesture().onChanged(handle)) } } } struct SliderShape: View { var value: Double private struct BackgroundTrack: View { var cornerRadius: CGFloat var body: some View { RoundedRectangle( cornerRadius: cornerRadius, style: .continuous ) .foregroundColor(Color(white: 0.2)) } } private struct OverlayTrack: View { var cornerRadius: CGFloat var body: some View { RoundedRectangle( cornerRadius: cornerRadius, style: .continuous ) .foregroundColor(Color(white: 0.95)) } } private struct Knob: View { var cornerRadius: CGFloat var body: some View { RoundedRectangle( cornerRadius: cornerRadius, style: .continuous ) .strokeBorder(Color(white: 0.7), lineWidth: 1) .shadow(radius: 3) } } var body: some View { GeometryReader { geometry in ZStack(alignment: .leading) { BackgroundTrack(cornerRadius: geometry.size.height / 2) OverlayTrack(cornerRadius: geometry.size.height / 2) .frame( width: max(geometry.size.height, geometry.size.width * CGFloat(value) + geometry.size.height / 2), height: geometry.size.height) Knob(cornerRadius: geometry.size.height / 2) .frame( width: geometry.size.height, height: geometry.size.height) .offset(x: max(0, geometry.size.width * CGFloat(value) - geometry.size.height / 2), y: 0) } } } } extension Double { func toDollars() -> String { return "$\(Int(self))" } }
-
5:15 - Slider
struct StandardSlider: View { @Binding var value: Double var label: String var body: some View { Slider(value: $value, in: 0...1) { Text(label) } } }
-
5:50 - Accessible BudgetSlider
struct BudgetSlider: View { @Binding var value: Double var label: String var body: some View { VStack(alignment: .leading) { HStack { Text(label) Text(value.toDollars()).bold() } SliderShape(value: value) .gesture(DragGesture().onChanged(handle)) .accessibilityRepresentation { Slider(value: $value, in: 0...1) { Text(label) } .accessibilityValue(value.toDollars()) } } } } struct SliderShape: View { var value: Double private struct BackgroundTrack: View { var cornerRadius: CGFloat var body: some View { RoundedRectangle( cornerRadius: cornerRadius, style: .continuous ) .foregroundColor(Color(white: 0.2)) } } private struct OverlayTrack: View { var cornerRadius: CGFloat var body: some View { RoundedRectangle( cornerRadius: cornerRadius, style: .continuous ) .foregroundColor(Color(white: 0.95)) } } private struct Knob: View { var cornerRadius: CGFloat var body: some View { RoundedRectangle( cornerRadius: cornerRadius, style: .continuous ) .strokeBorder(Color(white: 0.7), lineWidth: 1) .shadow(radius: 3) } } var body: some View { GeometryReader { geometry in ZStack(alignment: .leading) { BackgroundTrack(cornerRadius: geometry.size.height / 2) OverlayTrack(cornerRadius: geometry.size.height / 2) .frame( width: max(geometry.size.height, geometry.size.width * CGFloat(value) + geometry.size.height / 2), height: geometry.size.height) Knob(cornerRadius: geometry.size.height / 2) .frame( width: geometry.size.height, height: geometry.size.height) .offset(x: max(0, geometry.size.width * CGFloat(value) - geometry.size.height / 2), y: 0) } } } } extension Double { func toDollars() -> String { return "$\(Int(self))" } }
-
7:05 - NavigationBarView
struct NavigationBarView: View { var body: some View { HStack { Text("Wallet Pal") .font(.largeTitle) .bold() Spacer() Button("Edit Budgets", action: { ... }) .buttonStyle( SymbolButtonStyle( systemName: "slider.vertical.3")) } } } struct SymbolButtonStyle: ButtonStyle { let systemName: String func makeBody(configuration: Configuration) -> some View { Image(systemName: systemName) .accessibilityRepresentation { configuration.label } } }
-
9:40 - BudgetHistoryGraph
struct Budget: Identifiable { var month: String var amount: Double var id: String { month } } struct BudgetHistoryGraph: View { var budgets: [Budget] var body: some View { GeometryReader { proxy in VStack { Canvas { ctx, size in let inset: CGFloat = 25 let insetSize = CGSize(width: size.width, height: size.height - inset * 2) let width = insetSize.width / CGFloat(budgets.count) let max = budgets.map(\.amount).max() ?? 0 for n in budgets.indices { let x = width * CGFloat(n) let height = (CGFloat(budgets[n].amount) / CGFloat(max)) * insetSize.height let y = insetSize.height - height let p = Path( roundedRect: CGRect( x: x + 2.5, y: y + inset, width: width - 5, height: height), cornerRadius: 4) ctx.fill(p, with: .color(Color.green)) ctx.draw(Text(budgets[n].amount.toDollars()), at: CGPoint(x: x + width / 2, y: y + inset / 2)) ctx.draw(Text(budgets[n].month), at: CGPoint(x: x + width / 2, y: y + height + 1.5*inset)) } } .accessibilityLabel("Budget History Graph") .accessibilityChildren { HStack { ForEach(budgets) { budget in Rectangle() .accessibilityLabel(budget.month) .accessibilityValue(budget.amount.toDollars()) } } } } } .padding() .background( RoundedRectangle(cornerRadius: 16) .foregroundColor(Color(white: 0.9))) .padding(.horizontal) } }
-
12:30 - Composition
// See CompositionExample.swift in the referenced sample project
-
13:50 - FriendCellView
struct User: Identifiable { var id: Int var name: String var photo: String } struct FriendCellView: View { var user: User var body: some View { ZStack(alignment: .topLeading) { VStack(alignment: .center) { Image(user.photo) Text(user.name) } Button("Send Challenge", action: { /* ... */ }) .buttonStyle( SymbolButtonStyle( systemName: "gamecontroller.fill")) } } } struct SymbolButtonStyle: ButtonStyle { let systemName: String func makeBody(configuration: Configuration) -> some View { Image(systemName: systemName) .accessibilityRepresentation { configuration.label } } }
-
14:50 - FriendsView
struct User: Identifiable { var id: Int var name: String var photo: String } struct FriendCellView: View { var user: User var body: some View { ZStack(alignment: .topLeading) { VStack(alignment: .center) { Image(user.photo) Text(user.name) } Button("Send Challenge", action: { /* ... */ }) .buttonStyle( SymbolButtonStyle( systemName: "gamecontroller.fill")) } } } struct FriendsView: View { var users: [User] var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(users) { user in FriendCellView(user: user) .onTapGesture { /* ... */ } } AddFriendButton() Spacer() } } } } struct AddFriendButton: View { var body: some View { Button(action: { /* ... */ }) { Circle() .foregroundColor(Color(white: 0.9)) .frame(width: 50, height: 50) .overlay( Image(systemName: "plus") .resizable() .foregroundColor(Color(white: 0.5)) .padding(15) ) } .buttonStyle(PlainButtonStyle()) } } struct SymbolButtonStyle: ButtonStyle { let systemName: String func makeBody(configuration: Configuration) -> some View { Image(systemName: systemName) .accessibilityRepresentation { configuration.label } } }
-
15:10 - FriendsView with Containers
struct User: Identifiable { var id: Int var name: String var photo: String } struct FriendCellView: View { var user: User var body: some View { ZStack(alignment: .topLeading) { VStack(alignment: .center) { Image(user.photo) Text(user.name) } Button("Send Challenge", action: { /* ... */ }) .buttonStyle( SymbolButtonStyle( systemName: "gamecontroller.fill")) } } } struct FriendsView: View { var users: [User] var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(users) { user in FriendCellView(user: user) .accessibilityElement(children: .contain) .onTapGesture { /* ... */ } } AddFriendButton() Spacer() } } } } struct AddFriendButton: View { var body: some View { Button(action: { /* ... */ }) { Circle() .foregroundColor(Color(white: 0.9)) .frame(width: 50, height: 50) .overlay( Image(systemName: "plus") .resizable() .foregroundColor(Color(white: 0.5)) .padding(15) ) } .buttonStyle(PlainButtonStyle()) } } struct SymbolButtonStyle: ButtonStyle { let systemName: String func makeBody(configuration: Configuration) -> some View { Image(systemName: systemName) .accessibilityRepresentation { configuration.label } } }
-
16:20 - FriendCellView Sort Priority
struct User: Identifiable { var id: Int var name: String var photo: String } struct FriendCellView: View { var user: User var body: some View { ZStack(alignment: .topLeading) { VStack(alignment: .center) { Image(user.photo) Text(user.name) } Button("Send Challenge", action: { /* ... */ }) .buttonStyle( SymbolButtonStyle( systemName: "gamecontroller.fill")) .accessibilitySortPriority(-1) } } }
-
16:55 - FriendsView with .combine
struct User: Identifiable { var id: Int var name: String var photo: String } struct FriendCellView: View { var user: User var body: some View { ZStack(alignment: .topLeading) { VStack(alignment: .center) { Image(user.photo) Text(user.name) } Button("Send Challenge", action: { /* ... */ }) .buttonStyle( SymbolButtonStyle( systemName: "gamecontroller.fill")) } } } struct FriendsView: View { var users: [User] var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(users) { user in FriendCellView(user: user) .accessibilityElement(children: .combine) .onTapGesture { /* ... */ } } AddFriendButton() Spacer() } } } } struct AddFriendButton: View { var body: some View { Button(action: { /* ... */ }) { Circle() .foregroundColor(Color(white: 0.9)) .frame(width: 50, height: 50) .overlay( Image(systemName: "plus") .resizable() .foregroundColor(Color(white: 0.5)) .padding(15) ) } .buttonStyle(PlainButtonStyle()) } } struct SymbolButtonStyle: ButtonStyle { let systemName: String func makeBody(configuration: Configuration) -> some View { Image(systemName: systemName) .accessibilityRepresentation { configuration.label } } }
-
20:30 - AlertsView Implicit Rotor
struct Alert: Identifiable { var id: Int var isUnread: Bool var isFlagged: Bool var subject: String var content: String } struct AlertsView: View { var alerts: [Alert] var body: some View { VStack { ForEach(alerts) { alert in AlertCellView(alert: alert) .accessibilityElement(children: .combine) } } .accessibilityElement(children: .contain) .accessibilityRotor("Warnings") { ForEach(alerts) { alert in if alert.isWarning { AccessibilityRotorEntry(alert.title, id: alert.id) } } } } } struct AlertCell: View { var alert: Alert var body: some View { VStack(alignment: .leading) { HStack { if alert.isUnread { Circle() .foregroundColor(.blue) .frame(width: 10, height: 10) } if alert.isFlagged { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) .frame(width: 10, height: 10) } Text(alert.subject) .font(.headline) .fontWeight(.semibold) Spacer() Text("04/30/21") .font(.subheadline) .foregroundColor(.secondary) } Text(alert.content) .lineLimit(3) } .padding(10) .background( RoundedRectangle(cornerRadius: 8) .foregroundColor(Color(white: 0.9)) ) } }
-
21:50 - AlertsView Explicit Rotor
struct Alert: Identifiable { var id: Int var isUnread: Bool var isFlagged: Bool var subject: String var content: String } struct AlertsView: View { var alerts: [Alert] @Namespace var namespace var body: some View { VStack { ForEach(alerts) { alert in VStack { AlertCellView(alert: alert) .accessibilityElement(children: .combine) .accessibilityRotorEntry(id: alert.id, in: namespace) AlertActionsView(alert: alert) } } } .accessibilityElement(children: .contain) .accessibilityRotor("Warnings") { ForEach(alerts) { alert in if alert.isWarning { AccessibilityRotorEntry(alert.title, id: alert.id, in: namespace) } } } } } struct AlertCell: View { var alert: Alert var body: some View { VStack(alignment: .leading) { HStack { if alert.isUnread { Circle() .foregroundColor(.blue) .frame(width: 10, height: 10) } if alert.isFlagged { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) .frame(width: 10, height: 10) } Text(alert.subject) .font(.headline) .fontWeight(.semibold) Spacer() Text("04/30/21") .font(.subheadline) .foregroundColor(.secondary) } Text(alert.content) .lineLimit(3) } .padding(10) .background( RoundedRectangle(cornerRadius: 8) .foregroundColor(Color(white: 0.9)) ) } }
-
22:20 - TextEditor Rotors
struct ContentView: View { @State var note: Note var body: some View { TextEditor($text.content) .accessibilityRotor("Email Addresses", textRanges: note.addressRanges) .accessibilityRotor("Links", textRanges: note.linkRanges) .accessibilityRotor("Phone Numbers", textRanges: note.phoneNumberRanges) } }
-
24:45 - AlertNotificationView
struct Notification: Equatable { enum Priority { case low, high } var content: String var priority: Priority } struct AlertNotificationView<Content: View>: View { @ViewBuilder var content: Content @Binding var notification: Notification? @AccessibilityFocusState var isNotificationFocused: Bool var body: some View { ZStack(alignment: .top) { content if let notification = $notification { NotificationBanner(notification: notification) .accessibilityFocused($isNotificationFocused) } } .onChange(of: notification) { notification in if notification?.priority == .high { isNotificationFocused = true } else { postAccessibilityNotification() } } } func postAccessibilityNotification() { guard let announcement = notification?.content else { return } #if os(macOS) NSAccessibility.post( element: NSApp.accessibilityWindow(), notification: .announcementRequested, userInfo: [.announcement: announcement]) #else UIAccessibility.post(notification: .announcement, argument: announcement) #endif } } struct NotificationBanner: View { @Binding var notification: Notification? @State var timer: Timer? @AccessibilityFocusState var isNotificationFocused: Bool var body: some View { if let notification = notification { Text(notification.content) .accessibilityFocused($isNotificationFocused) .onAppear { startTimer() } .onDisappear { stopTimer() } } else { EmptyView() } } func startTimer() { timer = Timer.scheduledTimer( withTimeInterval: 3, repeats: true) { _ in if !isNotificationFocused { notification = nil } } } func stopTimer() { timer?.invalidate() } }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。