ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
フォーカスを使ったSwiftUIレシピ
SwiftUIチームがフォーカス制御のための強力なツールと共に、昨年に続きコーディング「キッチン」に戻ってきました。フォーカス重視の動作をサポートする主な機能について学びましょう。カスタムビューのためのフォーカス・インタラクションや、キーボード入力のためのキープレスハンドラについて学び、移動とヒエラルキーにおけるフォーカスセクションの活用方法を学びましょう。また、よく使われるフォーカスパターンの「おすすめレシピ」も紹介します。
関連する章
- 1:44 - What is focus
- 3:18 - Ingredients
- 3:35 - Ingredients: Focusable views
- 6:04 - Ingredients: Focus state
- 7:03 - Ingredients: Focused values
- 8:54 - Ingredients: Focused sections
- 10:45 - Recipes
- 11:21 - Recipes: Controlling focus
- 14:36 - Recipes: Custom focusable control
- 18:04 - Recipes: Grid view
リソース
関連ビデオ
WWDC23
-
ダウンロード
♪ ♪
こんにちは「フォーカスを使った SwiftUIレシピ」へようこそ 私はCodyです この動画では SwiftUIのfocus APIを使って 素晴らしいユーザーエクスペリエンスを 作るための方法をいくつか説明します この動画では 3品のコース料理を提供します APIの詳細に関する固定メニューと 一連の素晴らしいコード例を 紹介するコースです 前菜として フォーカスの基本をおさらいします それはどのようなもので どのような役割があるのか? 最初のコースは フォーカスを実装させるための 材料を見て 味覚を刺激しましょう これらの材料が揃ったら 本格的に料理を始めます メインコースでは フォーカスの外観を制御したり 動きを観察したり カスタムコントロールを使って キーボード入力に応答してみましょう フォーカスとは何でしょうか? フォーカスは 操作にどう反応するかを決めるツールで 誰かがキーボードのキーを押したり Apple TVのリモコンをスワイプしたり 腕時計のデジタルクラウンを 回したときの反応です これらの入力方法には ある重要な共通点があります その動作だけでは 入力が画面上のどこを 操作しているのかを識別する 十分な情報がありません マウスやトラックパッド タッチスクリーンなどと比べてみてください マウスやトラックパッドを使うとき 画面上のカーソルはあなたのクリックと システムがインタラクションの ターゲットを見つけるために使う 画面座標を紐づけます フォーカスはシステムがポインター無しで 入力指示するための追加情報を提供します ビューにフォーカスがある時 システムはそれを起点として キーボードやリモコンや デジタルクラウンからの入力に応答します
フォーカスは 単なる実装の詳細ではありません アプリユーザーにとっても同様に重要であり だからこそフォーカスされたビューは 特別に強調されて表示されます macOSではフォーカスされたビューの周囲に 境界線が自動的に追加され キーボード入力を受け取ることを示します watchOSではコントロールの周囲に 緑色の境界線が表示され デジタルクラウンを回して コントロールの値を変更できる事を示します また tvOSではフォーカスされたビューは ホバー効果を受け 他のコントロールの上に持ち上がります フォーカスビューを強調することは いくつかの点でユーザーを助けます キーボードをタイプしたり リモコンをスワイプしたりするときに 入力がどこに向かうかを予測できます また 複雑で詳細なレイアウトでは アプリのどの部分と インタラクトしているのか一目瞭然です フォーカスは 特殊なカーソルのようなものです マウスカーソルのように 画面上の点を追跡するのではなく UIのどの部分がフォーカス入力の ターゲットなのかを追跡します このような理由から 私はフォーカスを ユーザーの注意を引くカーソルと考えます これで フォーカスとは何か そして アプリ上での表示について少し理解したので 最初のコースとして すべてのアプリのフォーカス体験に必要な 基本的な要素を紹介します focusableビュー…フォーカスステート… フォーカス値…フォーカスセクションです フォーカスを使った料理で 考慮すべき主な材料は フォーカスされたビューそのものです これは フォーカスの入力に応答する際 システムが出発点として使用するビューです 異なるコントロールは 異なる状況 異なる理由で フォーカス可能です macOSとiPadOSのテキストフィールドと ボタンを比べてみましょう テキストフィールドは タップしても Tabキーでフォーカスを移動させても 常にフォーカス可能です この種のコントロールは 編集のためのフォーカスをサポートします 継続的なフォーカスの入力を キャプチャする役割があるからです
それとは違って ボタンはクリックとタップを処理します macOSとiPadOSは タップしても ボタンにフォーカスを与えず Tabキーでボタンに到達する唯一の方法は システム全体で キーボードナビゲーションを オンにすることです この設定をご存じない方は macOSシステム設定の キーボードにあります Keyboard navigationと 書かれたスイッチです このスイッチを入れると Tabキーを押してボタンにフォーカスを当て スペースバーを押して ボタンをアクティブにできます
ボタンはアクティベーションのための フォーカスをサポートします これらのコントロールは 作動にフォーカスは不要ですが システムが許可すればフォーカスをします クリックやタップに代わる フォーカス駆動をサポートするためです iOS 17とmacOS Sonomaでは 新しいAPIを使えばカスタムコントロールを フォーカスシステムに加えられます focusableのView modifierを適用すると あなたのコントロールが対応する フォーカスインタラクションを 指定することで 結果の動作の微調整も可能になります 時間をかけてstate更新するフォーカスを 使用するコントロールには edit interactionsを指定します ポインタのアクティブ化の代わりに フォーカスを使うコントロールには activate interactionsを指定します
引数をまったく指定しない場合は システムはすべてのインタラクションで コントロールにフォーカスを与えます macOS Sonoma以前では focusable modifierがサポートしたのは activationセマンティックのみでした すでにmacOSのコードで focusable modifier使用の場合は 新しい動作が使用ケースに合っているか 確認しましょう interactions引数を追加して コード更新が必要かもしれません 次の材料は 瞬間ごとの フォーカスシステムの状態についてです この材料は FocusStateと名付けられました このシステムは どのビューに フォーカスが当たっているかを記録し アプリはその情報をロジックに使用して 入力の処理方法や ビューのスタイルを決定できます システムの状態を観察するには 特定のビューに フォーカスが当たっていることを示す値を 関連付けるバインディングを作成します ビューはこれらを読み取ることで ビューにフォーカスが当たった時や フォーカスが外れた時に 通知を受けることができます ブール値を持つFocusStateプロパティは 単一のビューがフォーカスされているかを 示します より複雑なケースでは カスタムデータ型の使用もできます 後ほど この例について説明し FocusStateをプログラム的に 変更する方法を紹介します
次はFocused Values APIです Focused Values APIが解決するのは ユーザーインターフェースの 離れた部分をリンクするデータ依存関係の 構築方法です このAPIを使って アクティブなシーンの状況に合わせて アプリのコマンドを更新しましょう フォーカスされた値はこれら異なる要素間の データフローを可能にします カスタム値を定義してメインメニューの コンテンツを構築してみます フォーカス値の作成と使用は カスタム環境キーや オブジェクトの作成と使用に似ています FocusedValueKeyプロトコルを使用して 新しいキーを定義し FocusedValuesは 新しいキーを使用して 値を取得および設定する Computed プロパティで拡張します 使用するデータは シーンのビューから来るもので 値 バインディング または observableオブジェクトなどです いずれにしても ビュー階層の その部分にフォーカスがあるように データを関連付けるために 一連のView modifierを使います 環境値と同様に ダイナミックプロパティを宣言して フォーカスされた値にアクセスします この例では フォーカス値はバインディングなので @FocusedBindingプロパティラッパーを 使用し カスタムKeyパスを指定します @FocusedBindingは フォーカスされたビューとその祖先を調べ 現在キーに関連付けられたバインディングが あるか確認します プロパティラッパーは自動的に バインディングをアンラップするので バインドされた値を直接扱えます 他に必要なのは ビューのボディで 新しいプロパティを使うことだけです 時間の経過とともに 異なるコントロール間でフォーカスが移動し 異なるウィンドウがアクティブになると システムは新コンテキストで見つけた値を 反映するようにビューを更新します 最後の材料は フォーカスセクションAPIです フォーカスセクションは 誰かがApple TV Remoteをスワイプしたり キーボードのTabキーを押したときの フォーカスの動きに影響を与えます デフォルトでは フォーカスは画面の最先端に最も近い 一番上のコントロールから始まります そこからTabキーを押すと 現在のロケールのレイアウト順に従って コントロールからコントロールへと フォーカスが移動します 画面上の最後のコントロールに到達したら もう一度Tabキーを押すと シーケンスが再開されます Apple TVリモコンでのフォーカス移動は 方向性があります 上下左右にスワイプして コントロール間でフォーカスを移動できます 方向移動は 隣接するターゲット間でのみ機能します この例ではクレームブリュレボタンから 右にスワイプして 他のデザートの1つに移動できますが 材料を食料品リストに追加するために 下にスワイプはできません ボタンがクレームブリュレの真下にないので ジェスチャーが失敗するのです これらのフォーカスターゲットを 一直線に並べるには 一番下のボタンのコンテナを フォーカスセクションとしてマークします フォーカスセクションは 移動ジェスチャーのターゲットになりますが フォーカス可能にはなりません 最も近いフォーカス可能コンテンツに 代わりにフォーカスを誘導します 効果的にするためにフォーカスセクションは コンテンツより大きくします この場合 ボタンの前後に スペーサーを追加して スタックを画面の幅に合わせて大きくします より大きなフォーカスターゲットを 配置したので どこからでも下にスワイプして 一番下のボタンに到達できます クレームブリュレの味が想像できますね!
今説明した定番の材料を組み合わせた レシピをいくつか紹介します カスタム・コントロールの ルック&フィールに磨きをかけ 一般的なタスクの摩擦をなくしましょう 最近 私はシェフ仲間のCurtが作った 料理本アプリを使っています 彼のWWDC22の動画で 見覚えがあるかもしれません このセクションでは最近私が開発している フォーカスの動作に 注視すると良い新機能をいくつか紹介します 例えば私は次回スーパーに行く時 買うものを覚えておくのに役立つ 食料品リストをアプリ内に追加しました この最初のレシピの プログラム的なフォーカスの動作で 買い物リスト作りが楽しくなるでしょう Listシートが表示されると その最後には常に空の項目があります 空の項目をタップすると キーボードが表示されるので 買う必要があるものを入力できます 食料品を追加するのは頻繁な作業なので タップの手間を省くために リストが表示されるたびに自動的に 空のアイテムにフォーカスを当てます 先ほど Focus State APIを使って どのビューにフォーカスがあるか観察し 更新する方法を紹介しました ここでも同じAPIを使います 前の例ではフラグを使って1つのビューに フォーカスがあることをシグナルしました 注視すべきテキストフィールドは 私の食料品リストの場合いくつでもあります FocusStateの値はこのような場合のために 任意のHashable型にすることができます この画面に追加する食材には それぞれ固有のIDがあり フォーカスされたテキストフィールドに 関連付けられたIDを保存することで フォーカスを追跡することができます focused(_:equals:) modifierを使って 各テキストフィールドと 材料の間のリンクを作ることにします このmodifierに 2つの引数を与える必要があります focusedItemプロパティへの バインディングと そのテキストフィールドに フォーカスが当たったときに バインディングが更新されるべきIDです これでアプリを実行すると 食料品リストをタップしたときに focusItemプロパティが異なるID値で 更新されることを確認できます
FocusStateバインディングが完了したので 食料品リストの初期表示時に フォーカスをテキストフィールドに プログラムで移動させる準備ができました これを行うには defaultFocus(_:_:)View modifierを リストに追加します これはiOS 17も対応済みです システムは この画面で初めてフォーカスを評価するとき バインディングを最後のリストの アイテムIDで更新しようとします
この変更により 食料品リストへの追加は 2ステップで完了します ツールバーのボタンをタップして シートを表示し それから入力を始めます ステップ3はありません 買い物リストが増えるにつれて ツールバーのAddボタンをタップすると 新しい空のリスト項目が作成されますが フォーカスが元の場所に残っています フォーカスを与えるには 空の項目をタップしなければなりません 新しい項目が表示されたら すぐに入力を開始できるように これもプログラム的にフォーカスを 移動させたい一つの例ですね 違うのは 変更のタイミングを 自分でコントロールしたいことです 幸いなことに defaultFocus設定用と同じの FocusStateバインディングを 使用することができます 私のGroceryListViewには addEmptyItemメソッドがあり モデルに新しい項目を追加できます そしてすでに新しい項目のTextFieldを currentItemIDプロパティに紐付けたので ツールバーボタンのアクションの一部として 新しいIDでプロパティを更新するだけです
Voilà! これで食料品リストを開始 または更新したいときに 必要なところにフォーカスを当てるために タップする必要はありません ただ入力し始めればいいのです
次はさらにいくつかの材料を使って 作成したカスタムコントロールの フォーカスインタラクションを改善します この時点で たくさんのレシピを カタログ化しました ひとつひとつ試していく上で どのレシピがうまくいって 再考が必要か覚えておきたいですよね 塩分を足すだけだったとしてもです このために 私は絵文字を使った カスタムピッカーコントロールを作り 料理の良し悪しを 記録できるようにしました 絵文字をタップすることで 各レシピの評価ができますが キーボードナビゲーションを好む私としては Tabキーでコントロールにフォーカスして 矢印キーで選択を変更したいので それを実現しましょう 私の絵文字ピッカーの基本構造は まずコントロールに フォーカスできるようにします 引数なしで focusable modifierを追加します これでTabキーを押したときに コントロールがフォーカスされますが 追加の動作があることに気づきました 他のボタンや類似したコントロールでは 見られない動きです 例えば私のコントロールは クリック時にフォーカスを得ますが ボタンやセグメント化されたコントロールは フォーカスを得ません これらのコントロールはフォーカスのために キーボードナビゲーションが必要です 私のもそうであるべきです この動作を得るために 私のコントロールを 起動時に フォーカスされるように指定します activate用のfocusableコントロールは クリックではフォーカスを得られず キーボードでフォーカスを受け取るには キーボードナビゲーションを オンにする必要があります 次に気が付いたのは macOSでは 私のコントロールのフォーカスリングが 長方形だということです より洗練された見た目にするためには フォーカスリングをカプセル型の 背景のパスに沿わせてみましょう フォーカスリングは常に ビューのコンテンツ形状に従います ここではデフォルト値の長方形です contentShape modifierを使って ビューの視覚的クリップと同様の カプセルシェイプを渡します これでコントロールにフォーカスできるので 次はキー入力を処理できるようにします 左右の矢印キーを使って 選択したレーティングを 変更できるようにしたいですね onMoveCommand modifierを使うと 移動コマンドに反応して プラットフォームに適した アクションを指定できます Macのキーボードで 矢印キーが押されたときや Apple TVのリモートで 方向キーがタップされたときなどです システムは移動方向に アクションを呼び出すので それに基づいてRatingセレクションを 左右に移動させます アラビア語やヘブライ語のような 右から左の言語のために コントロールコンテンツは 水平に反転するはずです 移動コマンドのアクションが環境値の layoutDirectionを使っていることを 確認してください Focusビヘイビアを実装する 魅力のひとつは Apple Watchでも同じコントロールができ 素晴らしい結果が得られることです watchOSのフォーカス入力処理のために onMoveCommand modifierの代わりに digitalCrownRotation modifierを使います そしてisFocused環境値を使って コントロールにフォーカスがあるときに おなじみの緑色ボーダーを表示させます
たったこれだけのmodifierで シンプルなコントロールに キーボードとデジタルクラウンのサポートを 追加できました 最後のレシピはフォーカス可能な グリッドビューのためのもので 完成した結果を撮影した写真を 展示するために作っています これは遅延グリッドとして作っていて すでにいくつかの選択動作を実装しています 画像をクリックすると選択され ダブルクリックすると レシピの詳細ビューに移動します さてフォーカスのインタラクションを どのように扱うか考えましょう 具体的には Tabキーを押したときに グリッドがフォーカスされるようにします フォーカスが当たったとき 矢印キーで選択範囲を更新し Returnキーで選択した レシピの詳細に移動させます 先ほど紹介したいくつかの材料に加え キーを押したときの処理と フォーカスが当たったときの グリッドの表示方法を カスタマイズするための材料を使います 前の例と同じように まずは グリッドをフォーカス可能にします この場合 インタラクション指定は不要です デフォルトではグリッドのクリック時と キーボードでタブ操作したときに グリッドにフォーカスが当たります これはキーボードナビゲーションとは 無関係です まさに私が望んでいることです グリッドにフォーカスを 当てられるようにしたことで システムは自動的に フォーカスリングを表示します 選択可能なコンテンツのコンテナでは この効果は冗長です 選択されたレシピの周りに追加した 色付きボーダーは既に グリッドのフォーカスの有無を表しています focusEffectDisabled modifierで 自動フォーカスリングをオフにできます SelectionShapeStyleのボーダーや インジケータで選択中の装飾にします 選択したアクセントカラーに自動的に適応し 祖先ビューのどれもフォーカスがないときは グレーになります 例えばグリッドからサイドバーに フォーカスが移動したときです 次は 選択されたレシピを メインメニューコマンドで お気に入りとしてマークします これにはFocused Values APIを使用し 必要に応じたメニューコマンド更新用に 選択範囲へのバインディングを渡します 矢印キーによる選択のサポート用に onMoveCommand modifierを使います そして システムが呼び出した時に 移動方向を指定して グリッドの選択されたレシピを更新します 最後に欲しいのは Returnキーが押されたときに 選択されたレシピに移動する方法です これはonKeyPress modifierで可能です macOS SonomaとiOS 17の新機能です このmodifierは接続された ハードウェアキーボードで 特定のキーや文字のセットとアクションが 押されたときに実行されます アクション操作が処理されなかった場合は ignoredを返し ディスパッチは ビュー階層の上まで続けられます ボーナス機能として onKeyPressを使って Type Selectionを実装し レシピ名の最初の文字を入力することで レシピを素早く スクロールして選択できるようにします
さて macOSのグリッド用の 素晴らしいキーボード体験が構築できたので tvOSのグリッドも見ていきましょう tvOSではグリッドの各セルが フォーカス可能で リモコンでフォーカスを さまざまな方向に移動させると その方向のセルがフォーカスになり 視覚的に他のセルよりも上に持ち上がります ボタンとNavigationLinksでは リフトホバー効果がデフォルトです この効果はテキストやテキストと画像を 組み合わせたビューに適しています しかしこれらのレシピ写真には 別のエフェクトが必要です tvOS 17の新機能でfocus可能ビューに highlight hoverEffectを適用できます このエフェクトはリモコンのスワイプ時 perspectiveのシフトとspecular shineを フォーカスアイテムに追加し 私のレシピサムネイルのような アートや写真との相性も抜群です 私のtvOSアプリの仕上げとして フォーカスセクションを追加します グリッドはボタンのリストの隣にあり 私はしばしばこの2つのグループを 行き来することがあります アプリを使ってみると おなじみの問題に気が付きました グリッドの下の行に フォーカスが当たっているとき 左にスワイプして カテゴリーボタンを移動できないのです フォーカスターゲットが 横並びにないからです レイアウトの高さいっぱいに広がる フォーカスセクションに カテゴリーリストを配置します これでクレームブリュレから 左にスワイプすると 期待通りに カテゴリーにフォーカスが移動します グリッドの完成です
Bellisimo! このビデオで多くのことを学びましたね 今こそあなたもフォーカスの材料を集めて どんなものが作れるか試してみましょう キーボードナビゲーションを有効にして macOSとiPadOSアプリをテストしましょう デフォルトのフォーカスを 最も便利な場所に配置しましょう 不規則なレイアウトでの動きを ガイドするために フォーカスセクションで コントロールを整理してみましょう ありがとう そして bon appétit!
-
-
5:05 - Focusable views
// Focusable views struct RecipeGrid: View { var body: some View { LazyVGrid(columns: [GridItem(), GridItem()]) { ForEach(0..<4) { _ in Capsule() } } .focusable(interactions: .edit) } } struct RatingPicker: View { var body: some View { HStack { Capsule() ; Capsule() } .focusable(interactions: .activate) } }
-
6:12 - Focus state
// Focus state struct GroceryListView: View { @FocusState private var isItemFocused @State private var itemName = "" var body: some View { TextField("Item Name", text: $itemName) .focused($isItemFocused) Button("Done") { isItemFocused = false } .disabled(!isItemFocused) } }
-
7:32 - Focused values
// Focused values struct SelectedRecipeKey: FocusedValueKey { typealias Value = Binding<Recipe> } extension FocusedValues { var selectedRecipe: Binding<Recipe>? { get { self[SelectedRecipeKey.self] } set { self[SelectedRecipeKey.self] = newValue } } } struct RecipeView: View { @Binding var recipe: Recipe var body: some View { VStack { Text(recipe.title) } .focusedSceneValue(\.selectedRecipe, $recipe) } } struct RecipeCommands: Commands { @FocusedBinding(\.selectedRecipe) private var selectedRecipe: Recipe? var body: some Commands { CommandMenu("Recipe") { Button("Add to Grocery List") { if let selectedRecipe { addRecipe(selectedRecipe) } } .disabled(selectedRecipe == nil) } } private func addRecipe(_ recipe: Recipe) { /* ... */ } } struct Recipe: Hashable, Identifiable { let id = UUID() var title = "" var isFavorite = false }
-
10:03 - Focus sections
// Focus sections struct ContentView: View { @State private var favorites = Recipe.examples @State private var selection = Recipe.examples.first! var body: some View { VStack { HStack { ForEach(favorites) { recipe in Button(recipe.name) { selection = recipe } } } Image(selection.imageName) HStack { Spacer() Button("Add to Grocery List") { addIngredients(selection) } Spacer() } .focusSection() } } private func addIngredients(_ recipe: Recipe) { /* ... */ } } struct Recipe: Hashable, Identifiable { static let examples: [Recipe] = [ Recipe(name: "Apple Pie"), Recipe(name: "Baklava"), Recipe(name: "Crème Brûlée") ] let id = UUID() var name = "" var imageName = "" }
-
11:29 - Controlling focus
struct GroceryListView: View { @State private var list = GroceryList.examples @FocusState private var focusedItem: GroceryList.Item.ID? var body: some View { NavigationStack { List($list.items) { $item in HStack { Toggle("Obtained", isOn: $item.isObtained) TextField("Item Name", text: $item.name) .onSubmit { addEmptyItem() } .focused($focusedItem, equals: item.id) } } .defaultFocus($focusedItem, list.items.last?.id) .toggleStyle(.checklist) } .toolbar { Button(action: addEmptyItem) { Label("New Item", systemImage: "plus") } } } private func addEmptyItem() { let newItem = list.addItem() focusedItem = newItem.id } } struct GroceryList: Codable { static let examples = GroceryList(items: [ GroceryList.Item(name: "Apples"), GroceryList.Item(name: "Lasagna"), GroceryList.Item(name: "") ]) struct Item: Codable, Hashable, Identifiable { var id = UUID() var name: String var isObtained: Bool = false } var items: [Item] = [] mutating func addItem() -> Item { let item = GroceryList.Item(name: "") items.append(item) return item } } struct ChecklistToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { Button { configuration.isOn.toggle() } label: { Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle.dashed") .foregroundStyle(configuration.isOn ? .green : .gray) .font(.system(size: 20)) .contentTransition(.symbolEffect) .animation(.linear, value: configuration.isOn) } .buttonStyle(.plain) .contentShape(.circle) } } extension ToggleStyle where Self == ChecklistToggleStyle { static var checklist: ChecklistToggleStyle { .init() } }
-
15:25 - Custom focusable control
struct RatingPicker: View { @Environment(\.layoutDirection) private var layoutDirection @Binding var rating: Rating? #if os(watchOS) @State private var digitalCrownRotation = 0.0 #endif var body: some View { EmojiContainer { ratingOptions } .contentShape(.capsule) .focusable(interactions: .activate) #if os(macOS) .onMoveCommand { direction in selectRating(direction, layoutDirection: layoutDirection) } #endif #if os(watchOS) .digitalCrownRotation($digitalCrownRotation, from: 0, through: Double(Rating.allCases.count - 1), by: 1, sensitivity: .low) .onChange(of: digitalCrownRotation) { oldValue, newValue in if let rating = Rating(rawValue: Int(round(digitalCrownRotation))) { self.rating = rating } } #endif } private var ratingOptions: some View { ForEach(Rating.allCases) { rating in EmojiView(rating: rating, isSelected: self.rating == rating) { self.rating = rating } } } #if os(macOS) private func selectRating( _ direction: MoveCommandDirection, layoutDirection: LayoutDirection ) { var direction = direction if layoutDirection == .rightToLeft { switch direction { case .left: direction = .right case .right: direction = .left default: break } } if let rating { switch direction { case .left: guard let previousRating = rating.previous else { return } self.rating = previousRating case .right: guard let nextRating = rating.next else { return } self.rating = nextRating default: break } } } #endif } private struct EmojiContainer<Content: View>: View { @Environment(\.isFocused) private var isFocused private var content: Content #if os(watchOS) private var strokeColor: Color { isFocused ? .green : .clear } #endif init(@ViewBuilder content: @escaping () -> Content) { self.content = content() } var body: some View { HStack(spacing: 2) { content } .frame(height: 32) .font(.system(size: 24)) .padding(.horizontal, 8) .padding(.vertical, 6) .background(.quaternary) .clipShape(.capsule) #if os(watchOS) .overlay( Capsule() .strokeBorder(strokeColor, lineWidth: 1.5) ) #endif } } private struct EmojiView: View { var rating: Rating var isSelected: Bool var action: () -> Void var body: some View { ZStack { Circle() .fill(isSelected ? Color.accentColor : Color.clear) Text(verbatim: rating.emoji) .onTapGesture { action() } .accessibilityLabel(rating.localizedName) } } } enum Rating: Int, CaseIterable, Identifiable { case meh case yummy case delicious var id: RawValue { rawValue } var emoji: String { switch self { case .meh: return "😕" case .yummy: return "🙂" case .delicious: return "🥰" } } var localizedName: LocalizedStringKey { switch self { case .meh: return "Meh" case .yummy: return "Yummy" case .delicious: return "Delicious" } } var previous: Rating? { let ratings = Rating.allCases let index = ratings.firstIndex(of: self)! guard index != ratings.startIndex else { return nil } let previousIndex = ratings.index(before: index) return ratings[previousIndex] } var next: Rating? { let ratings = Rating.allCases let index = ratings.firstIndex(of: self)! let nextIndex = ratings.index(after: index) guard nextIndex != ratings.endIndex else { return nil } return ratings[nextIndex] } }
-
18:50 - Grid view
struct ContentView: View { @State private var recipes = Recipe.examples @State private var selection: Recipe.ID = Recipe.examples.first!.id @Environment(\.layoutDirection) private var layoutDirection var body: some View { LazyVGrid(columns: columns) { ForEach(recipes) { recipe in RecipeTile(recipe: recipe, isSelected: recipe.id == selection) .id(recipe.id) #if os(macOS) .onTapGesture { selection = recipe.id } .simultaneousGesture(TapGesture(count: 2).onEnded { navigateToRecipe(id: recipe.id) }) #else .onTapGesture { navigateToRecipe(id: recipe.id) } #endif } } .focusable() .focusEffectDisabled() .focusedValue(\.selectedRecipe, $selection) .onMoveCommand { direction in selectRecipe(direction, layoutDirection: layoutDirection) } .onKeyPress(.return) { navigateToRecipe(id: selection) return .handled } .onKeyPress(characters: .alphanumerics, phases: .down) { keyPress in selectRecipe(matching: keyPress.characters) } } private var columns: [GridItem] { [ GridItem(.adaptive(minimum: RecipeTile.size), spacing: 0) ] } private func navigateToRecipe(id: Recipe.ID) { // ... } private func selectRecipe( _ direction: MoveCommandDirection, layoutDirection: LayoutDirection ) { // ... } private func selectRecipe(matching characters: String) -> KeyPress.Result { // ... return .handled } } struct RecipeTile: View { static let size = 240.0 static let selectionStrokeWidth = 4.0 var recipe: Recipe var isSelected: Bool private var strokeStyle: AnyShapeStyle { isSelected ? AnyShapeStyle(.selection) : AnyShapeStyle(.clear) } var body: some View { VStack { RoundedRectangle(cornerRadius: 20) .fill(.background) .strokeBorder( strokeStyle, lineWidth: Self.selectionStrokeWidth) .frame(width: Self.size, height: Self.size) Text(recipe.name) } } } struct SelectedRecipeKey: FocusedValueKey { typealias Value = Binding<Recipe.ID> } extension FocusedValues { var selectedRecipe: Binding<Recipe.ID>? { get { self[SelectedRecipeKey.self] } set { self[SelectedRecipeKey.self] = newValue } } } struct RecipeCommands: Commands { @FocusedBinding(\.selectedRecipe) private var selectedRecipe: Recipe.ID? var body: some Commands { CommandMenu("Recipe") { Button("Add to Grocery List") { if let selectedRecipe { addRecipe(selectedRecipe) } } .disabled(selectedRecipe == nil) } } private func addRecipe(_ recipe: Recipe.ID) { /* ... */ } } struct Recipe: Hashable, Identifiable { static let examples: [Recipe] = [ Recipe(name: "Apple Pie"), Recipe(name: "Baklava"), Recipe(name: "Crème Brûlée") ] let id = UUID() var name = "" var imageName = "" }
-
21:28 - Focusable grid on tvOS
struct ContentView: View { var body: some View { HStack { VStack { List(["Dessert", "Pancake", "Salad", "Sandwich"], id: \.self) { NavigationLink($0, destination: Color.gray) } Spacer() } .focusSection() ScrollView { LazyVGrid(columns: [GridItem(), GridItem()]) { RoundedRectangle(cornerRadius: 5.0) .focusable() } } .focusSection() } } }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。