ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
SwiftUIのナビゲーション機能
優れたAppの秘訣は、明確で堅牢なナビゲーション構造にあります。SwiftUIチームより、すばらしいエクスペリエンスをあなたのAppにもたらす方法について解説しますので、是非ご覧ください。SwiftUIの新しいNavigationStackやNavigationSplitViewについて解説し、Appの特定領域にリンクする方法、ナビゲーションの状態をすばやく簡単に復元する方法を紹介します。
リソース
- Bringing robust navigation structure to your SwiftUI app
- List
- Migrating to new navigation types
- NavigationSplitView
- NavigationStack
関連ビデオ
Tech Talks
WWDC22
-
ダウンロード
♪メローなインストヒップホップ♪ ♪ 私はCurtです SwiftUIチームのエンジニアです SwiftUIにナビゲーション用の新しいAPIを導入します 新しいAPIでAppを作成するのは楽しいです 今日はその楽しさを共有したいと思います これらAPIで 基本的なスタックである Apple TV・iPhone・Apple Watchから 強力なマルチカラムAppまでスケールできます 新しいAPIはプログラムによるナビゲーションと ディープリンクを強力にサポート Appに最適な構造を構築する ことが可能です この講演ではSwiftUIのナビゲーションによる レシピApp作成の手順をいくつか紹介します またすでにSwiftUIを使用している場合は 新しいAPI向上に役立つことを願っています 材料から始めましょう 新しいデータ駆動型ナビAPIに組み込まれるもの 次にテイスティングメニューに移動 ナビゲーションのプログラム制御のための 簡単なレシピについてお話しします デザートには新APIによる Appのナビゲーション状態を 維持するためのヒントをいくつか紹介します 以前にSwiftUIでナビゲーションを 使用したことがある場合 新しいAPIがどう異なるのか気になるでしょう そこで既存のAPIをいくつか確認してみます 既存のAPIはスタックに表示されるビューを 送信するリンクに基づいています たとえばルートビューに ナビゲーションリンクのリストがあるとします このリンクをタップすると そのビューをスタックにプッシュします これは基本的なナビゲーションに最適で このパターンは引き続き使用できます ルートビューに戻りましょう 既存のナビAPIを使用してプログラムで リンクを表示するにはそこに バインディングを追加します たとえばitem.showDetailを trueに設定することで このリンクのビューを表示できます リンクごとに個別のバインディングが必要です 新しいAPIを使用するとNavigationStackという コンテナ全体でバインディングを処理できて ここでのパスはスタックにプッシュされた すべての値を表すコレクションです NavigationLinksはパスに値を追加します パスを変更することでディープリンクしたり 全アイテムを削除して ルートビューにポップすることもできます この講演では新しいナビAPIがデータ駆動型の プログラムでどう機能するかをご紹介します パワフルで使いやすいと思います 新しいナビゲーションAPIによるレシピの前に メニューの内容を共有すべきかもしれません 最近料理が大好きでレシピを管理する Appの開発にも取り組んでいます この情報を提示する 方法についてはたくさんのアイデアがあります たとえば3列表示のアプローチです 最初の列ではレシピのカテゴリを選択 カテゴリを選択すると 2番目の列にレシピが一覧表示されます レシピを選択すると 詳細領域にそのレシピの材料が表示されます 詳細には関連レシピの 選択リンクもあります 祖母はいつも“パイは 生地が命”と言っていました 今日それを調理してみることにします 新しいナビゲーションAPIで材料を表示 次にそれらを掘り下げて 特定のナビゲーションレシピを 組合せてみましょう 新しいナビゲーションAPIでは 新コンテナタイプが導入されたので Appの構造とNavigationLinkで その構造の中をナビゲート するための補佐をしてくれます 最初の新しいコンテナはNavigationStack NavigationStackはプッシュ ポップインターフェイスで Apple Watchの「探す」や iPhoneの設定App macOS Venturaの新しいシステム設定Appで 見られます 2つめの新しいコンテナタイプは NavigationSplitViewです NavigationSplitViewはMacやiPadのメールや メモなどの複数カラムのAppに最適です NavigationSplitViewは自動的に iPhoneの単一列スタックや iPadのSlide Overや Apple WatchやApple TVでも適合します NavigationSplitViewには 2セットの初期化子があります ここに示したセットは2列の エクスペリエンスを作成 イニシャライザの他のセットでは 3列のエクスペリエンスを作成します NavigationSplitViewには 列幅をカスタマイズできる 一連の構成オプションが用意されており サイドバーの表示や プログラムで列を表示または非表示にします ここでは構成オプションに ついては詳しく触れません 同僚のRajのトークをチェックしてください 「iPadのSwiftUI:インターフェイスをオーガナイズする」 NavigationSplitViewをAppに最適化する とても優れた内容となっています 以前はNavigationLinksに常に表示される タイトルとビューが含まれていました 今回もタイトルは含まれていますが ビューの代わりに提示されるのが value つまり値となっています たとえばこのリンクはアップルパイのレシピを 表示しています 後述しますが優秀なNavigationLinkです リンクの動作はNavigationStackまたは リストによって異なります これら新しいAPIがどう 連携するかを確認するため クックブックとAppでの使用例として レシピをいくつか見てみましょう 最初のレシピはApple Watchの「探す」 またはiPhoneの設定にあるような 基本的なビューのスタックで カテゴリごとにセクションがあります セクション内でレシピをタップして 詳細を表示し どのレシピでも関連レシピの1つをタップして スタックにプッシュできます 戻るボタンを使用して元のレシピに戻り カテゴリリストに戻れます このレシピはNavigationStackと NavigationLinkに 新しいnavigationDestination修飾子を 組み合わせたものです 方法を見てみましょう 基本的なNavigationStackから始めます 内部には全カテゴリを繰り返すリストと navigationTitleがあります リスト内には各カテゴリのセクション 次に各セクション内にカテゴリ内の各レシピの NavigationLinkを追加します ここではリンクにRecipeDetail ビューを表示させます これは既存の遷移先をビューとして指定した NavigationLinkを使用しています それはナビゲーションを体験するのに 十分な挙動です コードによって遷移先を変えるには? これを行うには このナビゲーションリンクの2つの部分 それが提示する値と その値に伴うビューを切り離します 方法を見てみましょう まず遷移先ビューを新しい navigationDestination修飾子に移します この修飾子は渡すデータタイプを宣言しますが ここではそれがレシピです 修飾子はレシピの値を受け取って スタックにプッシュするビューを 作成して返します 次に新しいNavigationLinkの いずれかに切り替えて レシピの値を渡します どのように機能させるか NavigationStackの中身を見てみましょう すべてのナビスタックは スタックが表示している すべてのデータを表すパスを追跡します スタックがルートビューを表示している場合 例示のとおりパスは空です 次にスタックはスタック内か プッシュされたビューの内部で宣言された すべてのナビゲーションの遷移先も追跡します 通常これはセットですがこの例では 遷移先は1つだけです プッシュされたビューも図に追加します パスが空なのでプッシュ されたビューのリストも空です それはミルクとクッキーのように 組み合わせると魔法が起こります 値を表示するリンクをタップすると その値がパスに追加されます 次にナビゲーションスタックは遷移先を スタックにプッシュする ビュー決定のパス値にマップ アップルパイのレシピから パイ生地をタップすると リンクもそれをパスに追加します NavigationStackはその魔法で 別のRecipeDetailビューをスタックにプッシュ パスに追加するすべての値について NavigationStackは別のビューをプッシュします 戻るボタンをタップすると NavigationStackはパスと プッシュされたビューから 最後のアイテムを削除します NavigationStackにはもう 1つのトリックがあります バインディングを使用して パスに接続できるのです コードに戻りましょう 先ほどここまで来ました パスをバインドするために まずStateを追加します このスタックにプッシュされる 値はレシピであるため パスとして一連のレシピを使用できます スタックにデータを表示する必要がある場合は 新しい消去型の NavigationPathをチェックしてください パスの状態がわかったらNavigationStackに 引数を追加しパスにバインディングを渡します それが整ったらスタックを働かせましょう たとえば特定のレシピにジャンプする メソッドを追加できます またはスタックのどこからでも パスをリセットするだけでルートに戻れます これが新しいSwiftUIのNavigationStackと 新しいNavigation Linksと navigation Destinationsによる プッシュ可能なスタックを準備する方法です Macを含む全プラットフォームで機能しますが iPhoneやApple TVにApple Watchで特に有効です NavigationStackの動作を確認するにはこちらを 「Apple Watch用の生産性Appを構築する」 次のレシピはMacやiPadのMailにあるような スタックのない複数列表示に関するものです iPadではサイドバーは最初は非表示 表示させてカテゴリーの選択が可能です 次に2番目の列でレシピを選択 3番目の列はレシピの詳細が表示されています このレシピはNavigationSplit Viewと新しい NavigationLinkにリンクを 組み合わせたものです このレシピはモダリティを 回避するのに有用なため より大きなデバイスに最適です ドリルインしなくてもすべての 情報を見られます 方法を見てみましょう 3列のNavigationSplit Viewから始めます コンテンツと詳細の仮ビューがあります 次にサイドバーにリストを追加して すべてのカテゴリとnavigationTitleを 繰り返します リストの中では カテゴリごとにNavigation Linkがあります 次にどのカテゴリが選択されているかを 追跡するためにいくつか状態を挿入します selectedCategoryを使用するために サイドバーのリストを微調整します 選択範囲にバインディングを 渡していることに着目 これによりリストとその内容で 選択内容を操作できます リスト内に値を提示するリンクを配置する場合 選択タイプが一致する カテゴリとのリンクは自動的に タップまたはクリックで 選択範囲が更新されます サイドバーでカテゴリを選択すると SwiftUIはselected Categoryを更新します 前述の「iPadのSwiftUI: インターフェイスをオーガナイズする」を チェックして 選択とリストに関する情報をご確認ください 次にコンテンツ列の仮ホルダーを 選択したカテゴリに対してレシピの リストにもnavigationTitleを 追加してレシピのリストと置き換えます 選択したカテゴリと同じ手法で コンテンツリストで選択した レシピを随時追跡できます selectedRecipeに状態を使用し コンテンツリストにその状態を適用 レシピごとに値を表示するリンクを表示します 最後に詳細列を更新して selectedRecipeの詳細を表示します これが整ったら 再びナビゲーションを完全に プログラムで制御できます たとえば目玉レシピに移動するには 選択状態を更新する必要があります これが新しいNavigation SplitViewによる 値を提示するNavigation Linksと SwiftUIで選択されたリストを組み合わせた 複数列のナビゲーション体験を 準備する方法です このようにリスト選択とNavigationSplitViewを 組み合わせることに関する大きな利点として SwiftUIは分割ビューをiPhoneの単一スタックや iPadのスライドオーバーで自動的に 適応させられるということが挙げられます 選択を変更するとiPhoneで適切なプッシュと ポップに自動的に変換されます もちろんこの複数列表示は Macでも適切に機能します またApple TVとApple Watchは複数の列を 表示しませんが これらのプラットフォームも 単一スタックに自動変換されます SwiftUIのNavigationSplitViewは 全プラットフォームで機能 次にこれらすべての材料をどのように 2列のナビゲーションによりiPadや MacのPhotosのように 構築する方法を見ていきます カテゴリを選択すると詳細領域に そのカテゴリのすべての レシピのグリッドが表示されます レシピをタップすると詳細領域のスタックに プッシュされます 関連するレシピをタップすると それもスタックにプッシュ レシピのグリッドに戻れます
このレシピはナビゲーション分割ビューや スタックやリンクに遷移先 リストなどを組み合わせた メインディッシュです すべての材料をどう組み 合せるか見てみましょう 2列のNavigationSplit Viewから始めます 最初の列は前のレシピとまったく同じです selectedCategoryを追跡するための状態と その状態へのバインディングに加え NavigationLinkの他に必要な navigationTitleのリストがあります このレシピの違いは詳細領域にあります 新しいナビゲーションAPIは構成を最大活用 NavigationSplitViewの 列内にリストを配置できるのと同様 NavigationStackを列の中に入れることも可能 このナビゲーションスタックのルートビューは RecipeGridです RecipeGridが NavigationStack内にあることに着目です スタック関連の修飾子を RecipeGrid内に配置できます RecipeGridにズームインして 中身を見てみましょう RecipeGridはビューですので パラメータというカテゴリ ここではカテゴリはオプション なのでif-letから始めます elseケースは空の選択を処理します ifの中にスクロールビューと レイジーグリッドを追加 レイジーグリッドレイアウトは一連のビュー ここではForEachでレシピを繰り返し処理 レシピごとに 値を提示するNavigation Linkがあります リンクはレシピ値を示します 末尾のクロージャにあるリンクのラベルは サムネイルとタイトルが付いたRecipeTileです このグリッドを完成させるための作業は? NavigationStackにレシピから詳細ビューに マップする方法の説明がまだでした 最初のレシピで示したように新しい NavigationStackでは NavigationDestination修飾子を使用して パス上の値からスタックに 表示されるビューにマップ navigationDestination修飾子を追加しましょう でもどこに付ける? リンクに直接添付したいのですが 間違っている理由が2つあります ListやTableまたはここではLazyVGridのような レイジーコンテナはビューを すぐにロードしません ここに修飾子を置くと 遷移先がロードされない可能性があるため 周囲のNavigationStackがそれを 認識しない可能性があります 次にここに修飾子を配置すると グリッド内の全アイテムに 対して繰り返されます そこで修飾子をScrollViewにアタッチします ScrollViewの外部に 修飾子をアタッチすることにより NavigationStackがこの navigationDestinationを スクロール位置に関係なく 認識できるようにします ここに修飾子を配置することについての利点は ターゲットとするリンクにとても近いことです ナビゲーションの遷移先は意味のある方法で コードを整理する柔軟性を与えてくれます NavigationSplitViewに戻ると もう1つの利点は 完全なプログラムナビが有効になること ナビゲーションパスを追加します パスを保持して状態を NavigationStackに バインドするためにStateを追加 完全なプログラマティック ナビゲーションがあるので このナビゲーションで その日のレシピを表示する メソッドを作成できます これがスタックを 使用した複数列のナビゲーションを 新しいNavigationSplitViewや NavigationStack NavigationLinksおよびSwiftUIで選択された リストにより準備する方法です 前のレシピと同様これも自動的に適応し すべてのプラットフォームで機能します Appでナビゲーションを構成するために レシピを探索しましたが ナビゲーションの饗宴は デザートなしでは完成しません ナビゲーション状態を維持する 方法を見てみましょう Appでナビゲーション状態を維持するには もう2つの材料が必要です それはCodableとSceneStorageです このレシピには3つの 基本的なステップがあります まずナビゲーション状態を NavigationModelタイプにカプセル化します これにより1つのユニットとして 保存および復元できるため 一貫性が保たれます 次にナビゲーションモデルをCodableにします 最後にSceneStorageでモデルを復元します 途中ですべきことがあります 潰れたスフレのようにAppが クラッシュしないよう 簡単な手順があります ステップ1を見てみましょう 直近レシピの最後のコードです ナビゲーションの状態はselectedCategoryと pathプロパティに保存 selectedCategoryはサイドバーの選択を追跡 パスは詳細領域のスタックにプッシュされた ビューを追跡します 新しいNavigationModelクラスを導入し ObservableObjectに準拠させます 次にナビゲーション状態を モデルオブジェクトに移動し プロパティラッパーをStateから Publishedに変更します 次に、NavigationModelのインスタンスを 保持するためのStateObjectと モデルオブジェクトを使用 するようパラメータを変更 次にナビゲーションモデルをCodableに変更 まずCodable適合をクラスに 追加します 多くの場合 SwiftはCodable適合性を 自動的に生成できますが ここでは独自の適合性を実装したいと思います 主な理由はレシピがモデル値であるためです 状態復元のためにモデル値全体を 保存する必要はない これには2つの理由があります まずレシピデータベースには すでにレシピのすべての詳細が含まれています 保存したナビゲーション状態で その情報を繰り返すことは ストレージの適切な使用法ではありません またレシピデータベースが ローカルナビから独立して 変更できる場合たとえば 同期の追加に取り掛かれた場合ですが ローカルナビゲーションの状態に 古いデータを含めない方がいい カスタムコーダビリティに ついてはCodingKeysを追加 キーの1つがselectedCategoryです 他の「recipePathIds」という 名称になっていることに着目 あとはパスにレシピの 識別子を保存するだけとなります エンコード方法では コーディングキーを使用して キー付きコンテナを作成し選択したカテゴリを 追加します encodeIfPresentを使用しているので nil以外の場合にのみ値を書き込みます 次にレシピパス識別子を追加します エンコードする識別子を取得するために パスをマッピングしていることに 着目してください たとえばナビゲーション状態に 選択したカテゴリとして デザートが含まれているとします 緑色のボックスに示されているように パス上にアップルパイとパイ生地があります これは他と同様JSON エンコードされている可能性も コーダビリティを完成させるために 必要な初期化子を追加します 興味深いのはレシピIDをデコードしてから 共有データモデルを使用することで IDをレシピに変換し直すことです 見つからなかったレシピを破棄するために compactMapを使用しています たとえばレシピの削除など同期の 機能が確認された後に別のデバイスで 処理したいこととなる可能性があります これは復元されたナビ状態を確認するために 独自のAppで使用する必要がある箇所で それも理にかなっています 最後にモデルをJSONデータとして 読み書きするための 計算プロパティを追加します ナビゲーションモデルができたのでそれ自体を エンコードおよびデコードする 方法は分かっています あとは保存して復元することだけです そのためSceneStorageを使用します これがメインビューを残したところです StateObjectによりNavigationModelを保持 次にNavigationModelを保持するための SceneStorageについて説明します SceneStorageプロパティは 関連する値を自動的に 保存および復元します ここにあるデータのように ストレージのタイプが オプションの場合は新しいシーンの値はnilです システムがシーンを復元すると SwiftUIはSceneStorageプロパティの値も 復元します これを利用してNavigation Modelを維持 そのためにビューにタスク修飾子を追加 タスク修飾子はそのクロージャを非同期で実行 ビューが表示されたときに開始し ビューが消えたときにキャンセルされます ビューが表示されるたびに Appの前回の実行からの 既存のデータがあるかどうかを確認します ある場合はそのデータで ナビゲーションモデルを更新 次にナビゲーションモデル 変更のたびに繰り返される 非同期のforループを開始します このループの本体は変更の たびに実行されるため これを利用してナビゲーション状態を保存して シーンストレージデータに戻ります たったそれだけ! Appを離れてウェブ上で懐かしの 料理番組をチェックしたあとでも どこにいたかを覚えています Appに戻ると先ほど 中断したところに戻るわけです 便利なキッチンのヒントが出ている セクションなしに料理本は完成しません コリアンダーの代わり となるものはありませんが ナビゲーションのヒントを いくつかご紹介します できるだけ早く新しいNavigationStackと NavigationSplitViewに切り替えてください スタックスタイルでNavigationView採用の際は NavigationStackに切り替えます NavigationStackはApple TVやApple Watch またはiPadとiPhoneのシートでは スタックスタイルが常に デフォルトになっています 複数列のNavigationViewを使用している場合は NavigationSplitViewに切り替えます またバインディングを使用する リンクを使用した プログラムナビゲーションを 採用している場合は すぐに移行してください 新しい価値を提示するNavigationLinkと ナビゲーションパスとリストの選択も同時に 旧式のプログラマティックリンクは非推奨で iOS 16以降のリリースでは廃止の予定です 新しいAPIへの移行の詳細と例については 以下の記事をご覧ください 「Migrating to new navigation types」 開発者向けドキュメントに記載されています 次にListと新しいNavigation SplitViewおよび NavigationStackが混在 するよう作成されていることに着目 作成してゲストが気に入る ナビゲーション体験を 作成してください ナビゲーションスタックを使用する場合 遷移先はスタックまたはサブビュー内の どこでもかまいません メンテナンスを容易にするために 対応するリンクの近くに 遷移先を配置すると良いでしょう ただしレイジーコンテナの 中には入れないように 最後にNavigationSplit Viewを使用して ナビゲーション構築を開始することが 理にかなっていると言えます iPhone用に開発している場合でも NavigationSplitViewはデバイス幅に 自動的に適応します 横向きのiPhone Pro Maxをサポートする AppをiPadやMacに持ち込む準備ができたら NavigationSplitViewは そのすべての追加スペースを活用できます 新しいSwiftUIナビ APIを共有できたこの機会に感謝します! 先ほどの話の他に こちらもどうぞ 「SwiftUI Appに複数のウィンドウを追加する」 Appで新しいウィンドウやシーンを開くための 情報が出ています クックブックAppでのナビのためのレシピ お楽しみいただけましたか ご自分のAppで素晴らしいお料理を お試しください 召し上がれ!
-
-
6:05 - Pushable Stack
import SwiftUI // Pushable stack struct PushableStack: View { @State private var path: [Recipe] = [] @StateObject private var dataModel = DataModel() var body: some View { NavigationStack(path: $path) { List(Category.allCases) { category in Section(category.localizedName) { ForEach(dataModel.recipes(in: category)) { recipe in NavigationLink(recipe.name, value: recipe) } } } .navigationTitle("Categories") .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } .environmentObject(dataModel) } } // Helpers for code example struct RecipeDetail: View { @EnvironmentObject private var dataModel: DataModel var recipe: Recipe var body: some View { Text("Recipe details go here") .navigationTitle(recipe.name) ForEach(recipe.related.compactMap { dataModel[$0] }) { related in NavigationLink(related.name, value: related) } } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } subscript(recipeId: Recipe.ID) -> Recipe? { // A real app would want to maintain an index from identifiers to // recipes. recipes.first { recipe in recipe.id == recipeId } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id = UUID() var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( name: "Bolo de rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( name: "Chocolate crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( name: "Crème brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( name: "Ambrosia", category: .salad, ingredients: []), "Bok l'hong": Recipe( name: "Bok l'hong", category: .salad, ingredients: []), "Caprese": Recipe( name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( name: "Ceviche", category: .salad, ingredients: []), "Çoban salatası": Recipe( name: "Çoban salatası", category: .salad, ingredients: []), "Fiambre": Recipe( name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( name: "Niçoise", category: .salad, ingredients: []), ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id, ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct PushableStack_Previews: PreviewProvider { static var previews: some View { PushableStack() } }
-
10:40 - Multiple Columns
import SwiftUI // Multiple columns struct MultipleColumns: View { @State private var selectedCategory: Category? @State private var selectedRecipe: Recipe? @StateObject private var dataModel = DataModel() var body: some View { NavigationSplitView { List(Category.allCases, selection: $selectedCategory) { category in NavigationLink(category.localizedName, value: category) } .navigationTitle("Categories") } content: { List( dataModel.recipes(in: selectedCategory), selection: $selectedRecipe) { recipe in NavigationLink(recipe.name, value: recipe) } .navigationTitle(selectedCategory?.localizedName ?? "Recipes") } detail: { RecipeDetail(recipe: selectedRecipe) } } } // Helpers for code example struct RecipeDetail: View { var recipe: Recipe? var body: some View { Text("Recipe details go here") .navigationTitle(recipe?.name ?? "") } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id = UUID() var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( name: "Bolo de rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( name: "Chocolate crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( name: "Crème brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( name: "Ambrosia", category: .salad, ingredients: []), "Bok l'hong": Recipe( name: "Bok l'hong", category: .salad, ingredients: []), "Caprese": Recipe( name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( name: "Ceviche", category: .salad, ingredients: []), "Çoban salatası": Recipe( name: "Çoban salatası", category: .salad, ingredients: []), "Fiambre": Recipe( name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( name: "Niçoise", category: .salad, ingredients: []), ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id, ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct MultipleColumns_Previews: PreviewProvider { static var previews: some View { MultipleColumns() } }
-
14:10 - Multiple Columns with a Stack
import SwiftUI // Multiple columns with a stack struct MultipleColumnsWithStack: View { @State private var selectedCategory: Category? @State private var path: [Recipe] = [] @StateObject private var dataModel = DataModel() var body: some View { NavigationSplitView { List(Category.allCases, selection: $selectedCategory) { category in NavigationLink(category.localizedName, value: category) } .navigationTitle("Categories") } detail: { NavigationStack(path: $path) { RecipeGrid(category: selectedCategory) } } .environmentObject(dataModel) } } struct RecipeGrid: View { @EnvironmentObject private var dataModel: DataModel var category: Category? var body: some View { if let category = category { ScrollView { LazyVGrid(columns: columns) { ForEach(dataModel.recipes(in: category)) { recipe in NavigationLink(value: recipe) { RecipeTile(recipe: recipe) } } } } .navigationTitle(category.localizedName) .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } else { Text("Select a category") } } var columns: [GridItem] { [GridItem(.adaptive(minimum: 240))] } } struct RecipeDetail: View { @EnvironmentObject private var dataModel: DataModel var recipe: Recipe var body: some View { Text("Recipe details go here") .navigationTitle(recipe.name) ForEach(recipe.related.compactMap { dataModel[$0] }) { related in NavigationLink(related.name, value: related) } } } struct RecipeTile: View { var recipe: Recipe var body: some View { VStack { Rectangle() .fill(Color.secondary.gradient) .frame(width: 240, height: 240) Text(recipe.name) .lineLimit(2, reservesSpace: true) .font(.headline) } .tint(.primary) } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } subscript(recipeId: Recipe.ID) -> Recipe? { // A real app would want to maintain an index from identifiers to // recipes. recipes.first { recipe in recipe.id == recipeId } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id = UUID() var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( name: "Bolo de rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( name: "Chocolate crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( name: "Crème brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( name: "Ambrosia", category: .salad, ingredients: []), "Bok l'hong": Recipe( name: "Bok l'hong", category: .salad, ingredients: []), "Caprese": Recipe( name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( name: "Ceviche", category: .salad, ingredients: []), "Çoban salatası": Recipe( name: "Çoban salatası", category: .salad, ingredients: []), "Fiambre": Recipe( name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( name: "Niçoise", category: .salad, ingredients: []), ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id, ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct MultipleColumnsWithStack_Previews: PreviewProvider { static var previews: some View { MultipleColumnsWithStack() } }
-
18:12 - Use Scene Storage
import SwiftUI import Combine import Foundation // Use SceneStorage to save and restore struct UseSceneStorage: View { @StateObject private var navModel = NavigationModel() @SceneStorage("navigation") private var data: Data? @StateObject private var dataModel = DataModel() var body: some View { NavigationSplitView { List( Category.allCases, selection: $navModel.selectedCategory ) { category in NavigationLink(category.localizedName, value: category) } .navigationTitle("Categories") } detail: { NavigationStack(path: $navModel.recipePath) { RecipeGrid(category: navModel.selectedCategory) } } .task { if let data = data { navModel.jsonData = data } for await _ in navModel.objectWillChangeSequence { data = navModel.jsonData } } .environmentObject(dataModel) } } // Make the navigation model Codable class NavigationModel: ObservableObject, Codable { @Published var selectedCategory: Category? @Published var recipePath: [Recipe] = [] enum CodingKeys: String, CodingKey { case selectedCategory case recipePathIds } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory) try container.encode(recipePath.map(\.id), forKey: .recipePathIds) } init() {} required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.selectedCategory = try container.decodeIfPresent( Category.self, forKey: .selectedCategory) let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds) self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] } } var jsonData: Data? { get { try? JSONEncoder().encode(self) } set { guard let data = newValue, let model = try? JSONDecoder().decode(NavigationModel.self, from: data) else { return } self.selectedCategory = model.selectedCategory self.recipePath = model.recipePath } } var objectWillChangeSequence: AsyncPublisher<Publishers.Buffer<ObservableObjectPublisher>> { objectWillChange .buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest) .values } } struct RecipeGrid: View { var category: Category? @EnvironmentObject private var dataModel: DataModel var body: some View { if let category = category { ScrollView { LazyVGrid(columns: columns) { ForEach(dataModel.recipes(in: category)) { recipe in NavigationLink(value: recipe) { RecipeTile(recipe: recipe) } } } } .navigationTitle(category.localizedName) .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } else { Text("Select a category") } } var columns: [GridItem] { [GridItem(.adaptive(minimum: 240))] } } struct RecipeDetail: View { @EnvironmentObject private var dataModel: DataModel var recipe: Recipe var body: some View { Text("Recipe details go here") .navigationTitle(recipe.name) ForEach(recipe.related.compactMap { dataModel[$0] }) { related in NavigationLink(related.name, value: related) } } } struct RecipeTile: View { var recipe: Recipe var body: some View { VStack { Rectangle() .fill(Color.secondary.gradient) .frame(width: 240, height: 240) Text(recipe.name) .lineLimit(2, reservesSpace: true) .font(.headline) } .tint(.primary) } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes static var shared: DataModel { // Just instantiate each time for the example. A real app would need to // persist the data model as well. DataModel() } func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } subscript(recipeId: Recipe.ID) -> Recipe? { // A real app would want to maintain an index from identifiers to // recipes. recipes.first { recipe in recipe.id == recipeId } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id: UUID var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( id: UUID(uuidString: "E35A5C9C-F1EA-4B3D-9980-E2240B363AC8")!, name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( id: UUID(uuidString: "B95B2D99-F45D-4B74-9EC4-526914FFC414")!, name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( id: UUID(uuidString: "E17C729D-1E09-48F6-99E2-5BB959F5AE70")!, name: "Bolo de Rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( id: UUID(uuidString: "89202A12-2B04-4EFE-ADC5-D1ECE7A25389")!, name: "Chocolate Crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( id: UUID(uuidString: "412EA92A-40B5-4CFE-9379-627A1C80FFE1")!, name: "Crème Brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( id: UUID(uuidString: "4792C8AE-9596-4502-A9CB-806E2DFEA408")!, name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( id: UUID(uuidString: "331C25F6-4FED-4DA5-980E-7E619855DE92")!, name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( id: UUID(uuidString: "1EAA5288-8D2B-4969-AF97-ED591796B456")!, name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( id: UUID(uuidString: "416F4F5A-A81C-40FD-87F1-060B0F57DE6D")!, name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( id: UUID(uuidString: "D0820C1A-1AFB-4472-97DA-39A475304048")!, name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( id: UUID(uuidString: "3D9FEA8C-B38E-4739-8B4B-424885D76926")!, name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( id: UUID(uuidString: "586B9A4C-410A-40D2-AE40-BC32351A5C08")!, name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( id: UUID(uuidString: "9BD6C3B2-30CB-425E-8D60-7F07D0BA720C")!, name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( id: UUID(uuidString: "117E5CD4-8FF9-43FB-ACAE-53C35A648F6F")!, name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( id: UUID(uuidString: "4584B877-E482-4FF2-824E-FC667BFAD271")!, name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( id: UUID(uuidString: "5666FEB6-90DB-4CD2-91FA-D6F00986E90E")!, name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( id: UUID(uuidString: "752DAEB8-123E-4C48-A190-79742AA56869")!, name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( id: UUID(uuidString: "F0D54AF2-04AD-4F08-ACE4-7886FCAE1F7B")!, name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( id: UUID(uuidString: "F7FD59E8-F1AE-4331-8667-D5534817F7E7")!, name: "Ambrosia", category: .salad, ingredients: []), "Bok L'hong": Recipe( id: UUID(uuidString: "3DE38C07-F985-4E05-810C-1108A777766B")!, name: "Bok L'hong", category: .salad, ingredients: []), "Caprese": Recipe( id: UUID(uuidString: "055D963C-0546-4578-AF18-6FBEE249EF35")!, name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( id: UUID(uuidString: "50B62AF4-89AF-4D00-9832-E200FEC01279")!, name: "Ceviche", category: .salad, ingredients: []), "Çoban Salatası": Recipe( id: UUID(uuidString: "87AD6B33-FFD2-4E5C-BC4B-59769F7AC7E3")!, name: "Çoban Salatası", category: .salad, ingredients: []), "Fiambre": Recipe( id: UUID(uuidString: "8A9BC0D5-A931-4381-BDA8-713DF6389FE7")!, name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( id: UUID(uuidString: "E9497D38-49E0-4A18-939B-63A3F2C7C0B4")!, name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( id: UUID(uuidString: "DE9F7106-4D0C-4EAC-B44C-A8D8ECD81087")!, name: "Niçoise", category: .salad, ingredients: []) ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct UseSceneStorage_Previews: PreviewProvider { static var previews: some View { UseSceneStorage() } }
-
25:33 - Biscuits
import SwiftUI struct Biscuits: View { @State private var step = 0 @ScaledMetric private var fontSize = 18 var body: some View { VStack(alignment: .leading) { HStack { Spacer() VStack { Text("Biscuits") .font(.headline) Text(subtitle) .font(.subheadline) } .padding(16) Spacer() } Spacer() Text(LocalizedStringKey(steps[step])) .font(.system( size: fontSize, weight: .semibold, design: .serif)) .padding(16) .lineLimit(1...) Spacer() HStack { Button { withAnimation { step -= 1 } } label: { Label("Previous", systemImage: "chevron.backward") } .disabled(step - 1 < 0) Spacer() Button { withAnimation { step += 1 } } label: { Label("Next", systemImage: "chevron.forward") } .disabled(step + 1 >= steps.count) } .buttonStyle(CarouselButtonStyle()) .padding(16) } .foregroundStyle(Color.white) .background(gradient) .ignoresSafeArea(edges: .bottom) } var subtitle: LocalizedStringKey { if step == 0 { return "Ingredients" } return "Step \(step)" } var gradient: AngularGradient { AngularGradient( colors: colors, center: UnitPoint(x: 0.5, y: 1.0), angle: .degrees(180 * Double(step) / Double(steps.count - 1))) } } struct CarouselButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { ZStack { Circle() .fill(.ultraThinMaterial.shadow(.inner( radius: configuration.isPressed ? 3 : 0))) .frame(width: 44, height: 44) configuration.label .labelStyle(.iconOnly) .foregroundStyle(isEnabled ? .black : .secondary) .opacity(configuration.isPressed ? 0.3 : 0.8) } } } let steps = [ """ 2 cups all-purpose flour ¼ teaspoons coarse salt 1 cup (2 sticks) unsalted butter, room temperature ¾ cup confectioners' sugar """, "Sift flour and salt, mix into bowl and set aside.", "Mix butter on high speed until fluffy (3 to 5 minutes).", "Gradually add sugar slowly, continuing to mix until pale and fluffy.", "Add flour all at once and mix until combined.", "Butter a square pan.", "Pat and roll shortbread into pan no more than 1/2-inch thick.", "Refrigerate for at least 30 minutes.", "Preheat oven to 300 F.", "Cut chilled shortbread into squares.", """ Bake until golden and make sure the middle is firm. \ Approximately 45 to 60 minutes. """, "Cool completely. Re-slice them, if necessary, and serve.", ] let colors = [Color.yellow, .red, .purple] struct Biscuits_Previews: PreviewProvider { static var previews: some View { Biscuits() } }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。