ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
SwiftUIの新機能
今こそSwiftUIを使用してAppを開発する絶好のタイミングです。UIフレームワークの最新のアップデート内容(リスト、ボタン、テキストフィールドなど)を紹介して、AppでSwiftUIを全面的に採用する上でこれらの機能がどのように役立つのかを解説します。キャンバスビュー、マテリアル、シンボルの拡張点を活かして、視覚的に豊かな美しいグラフィックスを作成する方法を紹介します。macOS上の複数列テーブル、フォーカスとキーボード操作の改良点、マルチプラットフォーム検索APIについて検証します。Swift並列処理、まったく新しいAttributedString、フォーマットスタイル、ローカリゼーションなどの多くの機能を活用する方法を紹介します。
リソース
関連ビデオ
WWDC21
- 優れたウィジェットの原則
- Apple Watch用ワークアウトAppの構築
- Foundationの新機能
- macOSのショートカットについて
- SF Symbolsの新機能
- SwiftとSwiftUIへのCore Dataの並行処理の導入
- Swiftの並行処理: サンプルAppの更新
- Swiftの新機能
- SwiftUI Appのローカライズ
- SwiftUI Appへのリッチなグラフィックスの追加
- SwiftUIでの並行処理
- SwiftUIで創り上げる検索体験
- SwiftUIにおけるSF Symbols
- SwiftUIのアクセシビリティ: 基礎を超えて
- SwiftUIのダイレクトフォーカスとリフレクションフォーカス
- SwiftUIの徹底解説
- watchOS 8の新機能
-
ダウンロード
♪ (SwiftUIの新機能) 「SwiftUIの新機能」 へようこそ 私はマットです そして後で テイラーが加わります ここではSwiftUI の全てをご紹介します Appleの宣言型 UI フレームワークです SwiftUIは新しいのに 既にここまで来ています 2019年にリリースされた SwiftUIは 宣言的かつ 状態駆動型で UIを構築する 強力な新方法を紹介しました セカンドリリースでは 次の大きな一歩を踏み出し 新しいAppと Scene APIで 100% SwiftUI App を 実現しました 今年は 充実した新機能により AppでのSwiftUIの さらなる採用を サポートすることに 重点を置いています まだSwiftUIを 試したことがない人も 大丈夫です Appに何が最適かが わかるのはあなただけです しかし 今年の新機能を 知る上で いくつかのヒントがあります SwiftUIを 使いこなすには iOS iPadOS macOS用のNotesに 新しいアクティビティ ストリームを強化する様に 既存のAppに 全く新しい機能を 追加するために使用します またこれは 同じく SwiftUIで作られた macOSの新しい アバターピッカーです SwiftUIは 既存の UIKitや AppKitコードに 混在させることができます 新しいShortcuts Appを macOS上で構築するのに SwiftUIが使用されたように Appの新プラットフォーム への拡張にも便利です SwiftUIを使えば プラットフォーム間で 共通のコードを 簡単に共有しながら 各デバイスに固有の体験を 作り上げることができます また Appのデザインを 変更する際は SwiftUIの力を 借りる絶好のチャンスです 新しいApple Payの 購入フローは SwiftUIを使って 再設計されており macOSの新しい ヘルプビュアーや watchOSのTipsの UIにも使われています そして最後に 忘れてはならないのが 同じくSwiftUIで 1から作り直された iOS用の豪華な 新しい天気予報アプリです これらは SwiftUIが 次世代Appの構築に どのように 役立っているかの一例です このセッションでは それを可能にした APIのいくつかを ご紹介したいと思います まずは リストやグリッドを 使ってコンテンツの 集合体を作る方法の改善 について説明します 次にリストにとどまらず データ駆動型のアプリを さらに進化させる 新機能を紹介します 3つ目は グラフィックの駆動と 視覚効果向けの優れた 新ツールをお見せします テキスト、キーボード、 フォーカスベースの ナビゲーションの 強化についてお話をします そして最後にボタンです それでは早速 リストと グリッドから始めましょう これらはSwiftUIの アプリ内でデータを整理して 表示するための 重要な機能です 今年は リッチでインタラクティブな リストやグリッドを より簡単に 作成できるようになりました まずは楽しいものから 始めましょう SwiftUIは 非同期で 画像を読み込むための 機能をサポーtしました SwiftUIでは 新しい AsyncImageビューを使って これらの画像を簡単に 読み込むことができます URLを指定するだけで SwiftUIが自動的に リモートの画像を 取得して表示します デフォルトの プレースホルダも提供します AsyncImageの カスタマイズもできます 例えば 読み込まれた画像に 修飾子を追加したり 楽しい色を付けたりして カスタムのプレースホルダを 定義することができます カスタムアニメーションや エラー処理の追加もできます AsyncImageは 全プラットフォーム対応です 是非チェックしてください AsyncImageはすぐに コンテンツを読み込みますが フィードを表示する時の様に リクエストに応じて コンテンツを読み込む 必要がある場合があります これはiOSやiPadOSで 「引っ張って更新」が良い事例で 新しい refreshable 修飾子を使います この修飾子は 更新アクションを設定し environment を介して継承されます iOSやiPadOS上のリストは 自動的にこのアクションを プルリフレッシュとして追加しますが 独自のカスタム 更新アクションを 作ることもできます この新しい awaitキーワードは Swift 5.5の新しい 並行処理言語の機能の 1つであることに お気づきでしょうか updateItems メソッドを 非同期で呼び出し UIをブロックすることなく リストを更新できます SwiftUIのもう1つの 新しい並行処理関連の機能は task 修飾子です このAPIでは ビューのライフタイムに 非同期タスクを 設定することができます ビューが最初にロードされた時に 起動され ビューが削除された時に 自動的にキャンセルされます これにより 写真の最初のバッチを 自動的に読み取る ことができるのです これらの新しい 並行処理の修飾子は 一見シンプルに見えますが Appに高度な非同期動作を 組み込むことができます 例えば ここでは 新しい 写真が利用可能になると それを読み込むタスクを 設定しています ここでは通常のforループを 作成しただけですが ここでもawait キーワードが使われています newestCandidates は実際には 非同期シーケンスであり これはSwift 5.5の もう1つの新しい並行処理 機能です つまり新しい候補が追加されるのを 非同期で待ち 次の候補が追加された時だけ ループを回します 1つの修飾子に 非常に多くの機能を 詰め込んでいます ビューは表示されると すぐに非同期で 候補を待つタスクを開始し 新しい候補が利用可能になる度に リストを更新し ビューが消えると自動的に タスクをキャンセルしますが AppのUIをブロックすることは 全くありません Swiftの並行処理や SwiftUIでの活用法については まだ学ぶべきことが たくさんありますので 詳細を掘り下げるために他の 講義をいくつか用意しました 「SwiftUIでの並行処理」では 並行処理がSwiftUIの 更新モデルとどのように 関係しているかを説明し 先程説明した新機能の いくつかをデモします そして「Swiftの並行処理: サンプルAppの更新」では 非同期モデルのコードで 既存のプロジェクトを アップグレードする方法を 順を追って説明します 次に リストのコンテンツに 双方向性を持たせる 新しい方法をご紹介します この例では 私の隠れ家への 道順を共有するための シンプルなリストを 書きました 見た目はいいのですが テキストの編集ができません その問題を解決しましょう テキストを編集可能に するには 代わりにテキストフィールド に交換します しかし テキストへの バインディングが必要です リストのコンテンツ クロージャー内では コレクションの各要素の 値がそのまま与えられただけで バインディングでは ありません このような状況では 各行のコレクション要素への バインディングを取得するのは 困難です 一般的なアプローチとしては コレクションのインデックスを 繰り返し処理し 添え字を使用して そのインデックスの要素への バインディングを 取得します しかし 何か変更が あった時にSwiftUIが リスト全体をリロードする ことになるため この方法は推奨されません 実は このテーマについて もっと詳しく説明した 講義を用意しています 詳しくは「SwiftUIの徹底解説」 をご覧ください とりあえず これらの変更を元に戻して より良いソリューションを 見てみましょう 今年のSwiftUIは コレクション内の 個々の要素のバインディング にアクセスするための より簡単な方法を 提供しています 通常のドル記号 演算子を使って コレクションへの バインディングを リストに渡すだけで SwiftUIは クロージャ内の個々の要素へ バインディングを返します 値を読み取るだけのコードは これまでと全く同じままの 状態で 構いませんが 現在は使い慣れた通常の バインディング構文を使って テキストフィールドのような インタラクティブ コントロールを簡単に 追加することができます つまり 以前に入れ忘れた 極秘のドアコードを ようやく記入することが できるのです この新しい構文は Swift言語の一部なので リストだけでなく 期待されるあらゆる場所で 動作します 例えば リスト内の ForEachビューでも 同じ手法が使えます 更にこのコードは SwiftUIがサポートされる 以前のリリースに バックデプロイもできます 既存のコードを 書き込みやすくするだけ ではありません リストにも新しい機能が 追加されています! まずは リストを視覚的に カスタマイズする 新しい方法を ご紹介しましょう 新しい修飾子 listRowSeparatorTint は 個々のセパレータの色を 変えることができます ここでは 各行の セパレータとアイコンの色を 揃えるようにしました SwiftUIには セクションセパレータ用の 同等の修飾子もあります このアプリでは これらのセパレータは少し バラバラな感じがしますね 案内が一つの統一した流れの ようにしたいと思います これらを削除してみたいと 思います 新しい listRowSeparator修飾子で セパレータを隠すように 設定することで それが可能になります これで案内に統一感が 感じられるようになりました 作成中の別のアプリを 見てみましょう
これは 漫画家が スーパーヒーローや悪役を 管理するアプリです このアプリでは スワイプアクションで 登場人物の固定や削除を 素早く便利に行えます 余計なUI部品を追加して 見にくくする必要はありません 今年の新機能として SwiftUIでは 新しい swipeActions修飾子で 完全にカスタマイズされた スワイプアクションを定義できます SwiftUIの他の メニューと同様に ボタンを使って スワイプアクションを定義します また 新しい tint 修飾子を追加して 色をカスタマイズ することもできます 私はピンのアクションを 黄色にするのに使っています デフォルトでは SwiftUIは スワイプアクションを 行の後ろに表示します しかし 修飾子の edge パラメータを使って それらを先頭に 変えることができます edge の設定が異なる 複数の修飾子を 追加することで 先頭のスワイプアクションと 行末のスワイプアクションの 両方をサポートすることも できます そして最後に swipeActions修飾子は サポートしている全ての プラットフォームで利用でき マルチプラットフォームApp内での 共用を容易にします 他のプラットフォーム といえば 私のAppのmacOS版を 見てみましょう Macの余ったスペースを 利用して マルチカラムのインターフェースを 表示しています 全てのデータをサイドバーに 詰め込むのではなく 全登場人物のリストを持った オーバービューのタブを用意しました これでピン留めした 登場人物を サイドバーに 置いておくことができます でも このリストは 少し地味な感じがしますね ちょっと工夫を 凝らしてみましょう ここに既存のコードが あります 現在 リストをウィンドウに スムーズに収めるために Inset の listStyle を 使っています そして このスタイルを コードで美しく記述するために 全てのビュースタイルで 利用できる 新しい enum の様な 構文を採用しました また 今年の新しい機能として インセットリストの 新しい仕掛けがあります alternatesRowBackgrounds のフラグで スタイルを変更するだけで 行の背景を交互に 変えることができます 私たちのリストは各行が 明確に区別されるようになり 見栄えが かなり良くなりました しかし macOSアプリとしては ウィンドウの スペースを十分に 活用できていない気がします そこで 次のセクションでは リストを超えて Appをさらに活用します このスペースをより 有効に活用するために リストを リッチな複数列の表に アップグレードしましょう 4つの列で 1つのプライスで 4つのリストを取得しました しかし 最も優れているのは このようにやや複雑な表を スライド1枚に収まるほどの 少ないコードで宣言する ことができることです これは 表がSwiftUI全体で 使用されているのと同じ 宣言的な構造を 使用しているからです リストの場合と同様に1つの コンテンツのコレクションから 表を作成することができます しかし リストとは異なり 表は TableColumnsで構成されており 各ビジュアル列内の コンテンツを定義します これらの列は視覚的には ラベルとなっていて コレクションのデータを テキストとして表示するなどの よく使われる便利な機能を使って 視覚的なコンテンツとして 定義して使っています しかし 表も インターラクティブであり 通常のリストと同様に 単一行と複数行の 両方の行選択を サポートします また 表は列上の ソート可能な値への キーパスを用いたソートにも 対応しています また 表には複数の 異なるビジュアルスタイルや 各列の外観の微調整など いくつかの機能が サポートされています しかし 表やリストに 提供するデータについては もっと詳しく お話ししましょう 今年 SwiftUIのCoreData fetchリクエストの サポートに 幾つかの新しい 機能強化が行われました FetchRequests は並び替え記述子への バインディングを提供し 表につなげることが できるようになりました これにより わずか数行のコードで 選択とソート可能な 列を備えた 完全なCore Data主導の表を 作成できます SwiftUIはセクション化された fetchリクエストも提供するようになり これで右図のような複雑で 複数のセクションを持つ リストを1つのリクエストで 駆動できるようになりました この例では ピン留め されているかに基づいて データをセクションに 分割します 複数のSortDescriptors を使ってデータを整理します まずピン留されたセクション とされていないセクションに 分割し 次に 最近 変項された文字を 最後に並べます 次に変更をアニメーション化 することを指示します そして最後に リクエストの結果に基づいて リストのセクションと行を 動的に作成します 全体として この一つのリクエストで 右のアニメーションを 動かすことができます macOS用アプリの作成 テーブルの操作 Core DataとSwiftUI の統合についてのより詳細は これらの他の講義を ご覧ください 「Mac における SwiftUI」 2部作では Macに最適化された App の構築を 順を追ってご説明します 「SwiftとSwiftUIへの Core Dataの並行処理の導入」では 新しいコアデータの fetchリクエストAPIを より詳しくご説明します 今度は一歩下がって このすべてのデータから ユーザーが必要なものを 見つけるにはどうすれば よいか考えましょう もちろん 検索をします 検索は 全プラットフォーム で重要な要素です これは ユーザーが 必要な時に必要なものを 見つけるのに役立ちます Apple TV のような 大型デバイスから Apple Watch のような 小型デバイスまで さまざまなデバイスに 搭載されています 検索はマルチプラットフォーム の問題であるため これらすべての デバイスに対応できる マルチプラットフォームの ソリューションが必要です 幸いなことに Appに検索機能を 追加するのはとても簡単です NavigationView でやったように searchable修飾子を 追加するだけです この修飾子一つで SwiftUIは 自動的に アプリ内の適切な場所に 検索フィールドを追加し オプションで プラットフォームや コンテキストに 応じた方法で 候補を表示します 修飾子は 検索テキストに バインディングされるため 現在の値に基づいてデータを フィルタリングできます SwiftUIでの検索に ついてはお話することは まだたくさんありますが 幸いにも複数の プラットフォームでの 検索機能についての考え方を 説明するセッションがあります 詳しくは「SwiftUIで創り上げる 検索体験」を ご覧ください これまでリストとグリッドを使って Appのデータを読み込み 表示し、整理し、検索する 方法を見てきました では そのデータを Appの外で どのように共有するか について説明します データを共有する 最も簡単な方法の一つは データをAppの外へ ドラッグする方法です 私のHeroes & Villains のAppでは 既存のonDrag修飾子を 使って詳細画面の 登場人物アイコンをドラッグ できるように設定しています 今年の新機能として ドラッグ可能なビューに カスタムプレビューを 追加できるようになりました このプレビューは ドラッグ中のビューの 代わりに表示されます ドラッグ&ドロップには アイテムプロバイダーがあり 異なるプロセス間で データをコピーして 共有することができます 今年 SwiftUIは新しい importsItemProvers 修飾子を使用して 外部サービスからの アイテムプロバイダの インポートを サポートするように 設定するなど アイテムプロバイダを使用し 他のアプリやサービスと 統合する方法を さらにいくつか提供します この例では 画像を インポートして ストーリーの登場人物に 添付できるように 設定しています この機能は 次のmacOSの 新機能と組み合わせ可能です コンティニュイティカメラ Appのメインメニューに 「デバイスからインポート」 コマンドを追加することで iPhoneやiPadで 写真を撮影して Macアプリにインポート できるようになりました やってみましょう スーパーヒーロー”The View Builder”の シンボルは彼女の愛用ハンマーで 彼女のプロフィールにその 写真を添付できれば最高です 運よく 丁度 それがここにあります
App内の 「ファイル」メニューにある 「デバイスからインポート」 コマンドにアクセスします そして iPhoneで 写真を撮ります・・・
・・・すると自動的に カメラAppが開くので すぐに写真を 撮ることができます
そして 先程の importsItemProviders修飾子を使い 新しい写真がインポートされ Appに追加されます SwiftUIはAppからのデータの エクスポートもサポートしています データをエクスポート できるようにすると App内で直接 ショートカットを 起動できるようになるなど 他のサービスを 利用できるようになります SwiftUIでは 新しい exportsItemProviders修飾子を使って データをエクスポートする ことができます これによりAppのデータが システムの他部に公開され 例えば macOSや ショートカットのサービスで 利用できるようになります このAppを使っている人に どのように見えるか 見てみましょう ピン留めした 登場人物を選択すると Appのサービスメニューに クイックアクションが 表示されるようになりました これは最新の写真にタイトル バナーを追加するための 便利なショートカットで 最新のsuperheroの アイデアを友達と 共有するのにも使えます 愛らしい犬である Stylizer superhero用に この素晴らしい 写真を見つけました 私のカスタムショートカットが ヒーローの名前をオーバーレイした この楽しいバナーを上部に追加しました 私のショートカットでは 写真の共有もできます テイラーはかっこいい グラフィックに詳しいので 是非 彼の意見を 聞きたいと思います Taylorを受信者に追加して 簡単なメッセージを 入力して送るだけです テイラー どう思う? ありがとう Matt 見た目は完璧だよ 君の新しい連絡先写真になる のは間違いないね このかわいい写真は 次のセクション 「高度なグラフィックス」 への良いきっかけになります 今年は たくさんの エキサイティングな機能が 追加されています シンボルの更新 マテリアル ガラス効果から パワフルな新しい キャンバスビューまで まずはシンボルです SF Symbolsは アプリ全体 に美しいアイコンを 簡単に追加する 優れた方法です 今年は 新しいものが たくさんあるだけでなく アプリでの使用をより簡単に より表現豊かにするための いくつかの新機能が 搭載されています 2つのレンダリングモードが 新しく追加され シンボルのスタイルをさらに 制御できるようになりました ヒエラルキーはモノクロと 同様に現在のフォアグラウンドの スタイルを使用して シンボルを着色しますが シンボルの重要な要素を 強調するために 複数レベルの不透明度を 自動的に追加します また パレットを使用すると カスタムの塗りつぶしを使って シンボルの個々のレイヤーを さらに細かく制御できます これらの新しいモードの詳細 と設計ガイダンスについては 「SF Symbolsの新機能」 をご確認ください これに合わせて SwiftUIで使用可能な カラーセットも 更新されました これらの色は表示される 全ての異なる構成に合わせて 最適化されています ライトモードやダークモード ぼかした時の見え方 さらには表示される プラットフォームなど さまざまな要素があります シンボルには色だけでなく さまざまな形があります 多くのシンボルには 塗りつぶし 丸で囲むなどの表示をする 修飾子があります 以前はこれらのバリアントをハード コーディングする必要がありました それ以上にどのバリアントを どのコンテキストで使うのが 正しいのかを知る 必要がありました iOSヒューマンインタフェース ガイドラインでは タブバーでは 塗りつぶしたバリアントを 優先すべきだと書かれているので .fill修飾子を名前に 明記する必要がありました 今年は その心配は ありません SwiftUIは 使用する コンテキストに基づいて 適したバリアントを 自動的に選択します やるべきことは 使用したいベースシンボルを 用意するだけです また必要な構成を 過剰に指定しないことで 再利用性の高いコードを 得ることができます 例えば同じコードを macOSで実行すると プラットフォームに合ったバリアント 「アウトライン」が表示されます 独自のカスタムビューで この自動サポートを 利用する方法や その他のシンボルの 強化については 「SwiftUIにおけるSF Symbols 」 をご覧ください 今では たくさんの SF Symbolsがあるので それらをすべて閲覧できる クールなビジュアライザー を作りたいと思いました これは SwiftUIの新しい Canvasビューの優れた使い方です Canvasは UIKit やAppKitの drawRectに似た即時モード の描画をサポートしています 個別のトラッキングや 無効化を必要としない 多くのグラフィック 要素を合成する場合 これは素晴らしいツールです OSに搭載されている すべてのSF Symbolsを 表示するキャンバスを 用意しました そして その3166個全てが それぞれのフレームに 描画されます Canvasは全プラット フォームに対応しています またCanvasは 他のビューと同様に ジェスチャーや アクセシビリティ情報を付加したり ダークモードの対応など 状態や環境に応じて 更新することができます ここではズームした時に その中心点を設定する ジェスチャーを追加しました そして それをもとに 各シンボルのフレームや 不透明度を更新します 今では クリックして ドラッグするだけで カーソルが画面上を移動し すべてのシンボルが スムーズに更新されます また 新しい accessibilityChildren修飾子を 利用することで完全な アクセシビリティを実装できます 素晴らしいのは SwiftUIで使い慣れた 同じビューを再利用して アクセシビリティ機能による 見え方を 改善できることです この場合 他の方法で リスト内の要素を表示し 動きに合わせて各要素を 読み上げながら シンボルを見られる ようになりました この修飾子は Canvasに限らず どのビューにも使用でき アクセシビリティを 向上することができます Canvasに追加できる 最後の機能として 新しいTimelineViewで時間経過 と共に更新可能な機能があります tvOS用に改良したものは そのフォーカルポイントが アニメーションで画面を 動き回りスクリーンセーバー のような役割を果たします TimelineViewは スケジュールを作成します この場合 アニメーションの スケジュールが作成され レンダリングすべき 現在の時間を提供します そして その時間を利用して フォーカルポイントの トランスフォームを更新して 美しいシンボルの スクリーンセーバーを 作ることができます この TimelineViewは さらに多くのことができます Apple Watchの 素晴らしい機能は Always On ディスプレイです これまでは 常時点灯状態になると Appはブラー表示となり 時刻が表示されていました watchOS 8を使うとAppは デフォルトでは暗く表示されますが SwiftUIで必要な ツールを提供することで 表示をより細かく制御可能になりま した その一つがTimelineViewです 時計がAlways Onの 状態になると TimelineViewは 将来の日付を指定して ビューの表示を プリロードできます そして時間が経つと Appはバックグラウンドのまま プリロードしたビューが 自動的に画面に表示されます その中でも重要なのが TimelineScheduleです この例ではシンプルな everyMinute スケジュールを 使用しているので TimelineViewは 分毎の表示をプリロードして ブラウザに 次の記号を表示します スケジュールには 他にも様々な種類があり Appのニーズに合わせて 選ぶことができます 例えば 特定の時間に イベントがある場合に有効な 明示的に日付時間を指定する コレクションなどがあります さて このモードの もう一つの重要な点は 他人に見える可能性があるため ユーザーの機密情報は 非表示にすることです 自分の好きな記号を 秘密にしておきたい場合は privacySensitive修飾子を 追加するだけで 時計がAlways On状態に なったときに 自動的に再編集されます Always On表示などの詳細は 「watchOS 8の新機能」を ご覧ください また このプライバシーに 配慮した修飾子は ウィジェットでも機能します ロック画面に追加された ウィジェットは この修飾子を使って デバイスがまだ ロックされている間は 機密情報を非表示にし ロックが解除されると 機密情報を表示します 「優れたウィジェットの原則」 では この方法や App用の優れたウィジェットを 作成する方法について 更に詳しく説明します マテリアルはAppleの全プラット フォームとAppで使用され コンテンツを真に強調する 美しい視覚効果を作成します そして今 SwiftUIで 直接それらを作成できます Symbol Browserに 色や素材を追加する 実験をしていて 記号の数を表示するために マテリアルを使ったオーバーレイを 追加しています マテリアルの追加は 背景を追加 するのと同じくらい簡単です ここではultraThinMaterialを 使用しており 任意のカスタム形状で 塗りつぶすことができます これらの素材は プライマリ セカンダリ ターシャリ クォータナリの フォアグラウンドスタイルの コンテンツ上で 自動的に期待通り きれいにブレンドされます また 絵文字は自動的に 除外されるため 本来の外観になります Macでは サイドバーや ポップオーバーなどの システムコンテキストの背景が 自動的にブラーがかかり その中のコンテンツも 期待通りの鮮やかな 外観になります これらの新しいマテリアルは 新しい safeAreaInset 修飾子と 組み合わせて使用すると スクロールビューの上に コンテンツを配置しても 期待通りのコンテンツの位置で 開始および 終了することができます 「SwiftUI Appへのリッチな グラフィックスの追加」では Canvasやマテリアルなどに ついて さらに詳しく 解説しています 最後に これらの美しい カスタムビューを定義する 新しい方法を補完するために XcodeでのSwiftUI のプレビューを強化しました previewInterfaceOrientation 修飾子が新たに追加され プレビューに表示される iOSデバイスの向きを 指定したり 異なる向きのプレビューを 混在させることもできます そして2つ目はプレビューで Appのアクセシビリティを 編集・表示する方法が 大きく改善されたことです プロパティエディタに アクセシビリティの 修飾子リストが追加され ビューのアクセシビリティ設定を より簡単に改善できるように なりました また Accessibility Preview タブが追加され プレビューの表示方法も 一新されました アクセシビリティ要素と そのプロパティを 即座にテキストで表示します これは、 アクセシビリティ機能を 強化する情報と同じですが ユーザーにとってより 親しみやすい形式で 表示されます 「SwiftUIのアクセシビリティ: 基礎を超えて」では この点に関する詳細情報や Appに優れた アクセシビリティ体験を 提供する方法について 更に多くの 情報を得ることができます さて 次は テキスト、 テキスト関連のコントロール キーボードナビゲーションに 関する様々な機能強化です テキストは すべてのAppの基本で Appが人と コミュニケーションを取るための 主要な手段の一つです 最初に書くビューは テキストが多いです 今年は スタイリング、 ローカリゼーション、インタラクション フォーマットなど画期的な 新機能を多く搭載しています まず最初にMarkdownの サポートです テキストにMarkdownのフォーマット を直接追加できるようになりました これにより以下のような機能 を追加することができます 強い強調 インタラクティブなリンク コードスタイルの プレゼンテーションまでも そしてこれらはすべて Foundationの新しく強力な SwiftベースのAttributedString の上に構築されています Markdownのサポート に加えてリッチで タイプセーフな属性と 独自の属性を定義する機能 そしてMarkdownの シンタックスの中でそれらを 使用する機能も提供します この点や 素晴らしい 新機能である「自動文法」 の規約については 「Foundationの新機能」の セッションをご覧ください 重要なのは 世界中の人々が あなたのAppを使えるよう テキストもコンテンツを ローカライズすることです 新しいMarkdown のサポートでも同様で 言語依存の属性を適切に ローカライズすることが できます ローカリゼーションの もう一つの大きな改善点は Xcode 13です Swiftコンパイラを使い LocalizedStringKeyや 新らしいlocalizedStringと attributedString初期化子を使用する度 文字列とローカリゼーション カタログを生成するようになりました この他のローカリゼーション のヒントやコツについては 「SwiftUI Appのローカライズ」 をご覧ください さて テキストを表示する これらの新しい方法に加えて テキストを更にダイナミック にする新しい方法があります 1つ目は 重要な アクセシビリティ機能 Dynamic Typeです SwiftUIは開始時から Dynamic Typeを サポートしており 今年はUIが大きすぎたり 小さすぎたりしないように サポートする文字サイズの 範囲を制御できる 新しいAPIが 追加されました これは デフォルトの 大きなサイズでの ヘッダーの様子を 示しています 私は個人的に Dynamic Typeを使って より多くの情報が表示できるよう 小さな文字表示を使っていますが このヘッダーでは 最低でも大きなサイズになるように 制限されているため 小さな文字サイズ表示でも 同じサイズのままでした 逆に非常に大きな表示となる アクセシビリティサイズを 使用すると ヘッダーは大きくなりますが extra extra largeまでしか 大きくなりません macOSはDynamic Typeには 対応していませんが もう一つの 重要なテキスト操作である テキストの選択には対応しています これにより ユーザーは 編集不可のテキストに対して アクションを実行できるように なります textSelection修飾子を使用して これを有効にできるようになりました この修飾子は どのビュー にも適用でき その中のすべてのテキストに 適用されます この例では ヘッダーの テキストに適用されています また iOSやiPadOSでも この修飾子を導入し 長押しでテキストをコピーしたり 共有したりできます 更にFoundationの 新しいフォーマットスタイルのAPIは テキストのフォーマットを 非常にシンプルにしながらも 正確な表現を可能にします ここでは デフォルトの書式を 適用した日付を表示しています そしてこれは アクティビティリストで 使用されている時間のみを 表示するバリアントです 最後は 表示する コンポーネントを 正確に指定できる 拡張フォーマットです 私たちのアクティビティリストには 様々な人を適切にローカライズされた 表現形式することも含まれています 早速 説明していきましょう 人の名前は PersonNameComponentsの 配列にマッピングし リスト形式のスタイルで フォーマットしています またリストの各メンバーについては PersonNameComponentの フォーマットを ショートスタイルで使用し ファーストネームだけを 表示しています そして最後に 「and」 という接続しでつなぎます これらを組み合わせることで 何人もの人を適切に処理する ことができるパフォーマンス に優れた型安全な フォーマットの表現を 作成します TextFieldはこれらの新しい フォーマットスタイルにも 対応しており 基になる値にタイプセーフな バインディングを使用して 編集可能なフォーマットされた テキストを追加することができます 新参加者フィールドは PersonNameComponentsの値に バインドされており標準的な名前の フォーマットを使用しています これにより、入力が解析され その結果の個人名が生成されます また「Foundationの新機能」では これら新しいフォーマットスタイルの 威力について説明します TextFieldは フィールドが 期待するコンテンツの種類を ユーザーに知らせるために ラベルとは別に明示的な プロンプトを追加することも サポートしています またmacOSのフォームに TextFieldを追加すると 他のコントロールと同様に ラベルが配置され プレースホルダのコンテンツとして プロンプトが使用されます さて テキストフィールドの 目的はテキストを 追加することであり キーボードはそのツールです ソフトウェアキーボードの iPhoneから ソフトウェアキーボードと ハードウェアキーボード 両方に対応した iPad そしてもちろん 常にハードウェアキーボードを 備えたmacOSまで 今年はキーボードの操作性が さらに向上しました 新しいonSubmit修飾子 を使えば リターンキーを押したときなど ユーザーがフィールドの テキストを送信したときの 補助的なアクションを 簡単に追加することができます この修飾子はコントロールの フォーム全体に適用する こともできるので 柔軟性があります また フィールドを 送信する際に どのような操作が 行われるのか ユーザーにヒントを 与えるために submitLabel 修飾子があります ソフトウェアキーボードでは Returnキーのラベル として使用されます 最後に 新しいキーボード ツールバーの配置を利用して キーボードにアクセサリーの 表示を追加できるようにしました これらのビューは iOS およびiPadOSでは ソフトウェアキーボード上の ツールバーに macOSではTouch Barに 表示されます これはテキストの編集作業を 妨げないよう キーボードを閉じることなく ユーザがキーボード上のアクションに 素早くアクセスできるようにする為の 優れた方法です キーボードにはナビゲーション とフォーカスという もう一つの重要な 役割があります この機能は watchOS ではフォーカスを使って Digital Crownの 入力を指示したり tvOSでSiri Remoteを使って コンテンツをナビゲートしたりと あらゆるプラットフォームに存在します ほとんどの場合SwiftUIでは どのビューがフォーカス可能で それらの間をどのように移動するかを 指定するだけです しかし 時には よりスムーズな体験のために さらに改善できる場合があります それをサポートするために SwiftUIには FocusStateという 新しく強力なツールがあります これはフォーカスの状態を反映し 厳密な制御を提供する プロパティラッパーです 最も簡単な場合 それはブール値を 反映させることができます これは focus修飾子を使ってフォーカス 可能なビューに結びつけることができます そのビューがフォーカスされて いる時はこの値はtrueとなり フォーカスされていない時は falseとなります この値は フォーカスを制御する ために書き込むこともできます たとえば、誰かがボタンを押したときの レスポンスです。 この例は 関連する アクションを実行した後 ユーザがすぐに入力を 開始できるような アクセラレータとして 機能します このブール値は簡易版ですが 任意のハッシュ型が使えます このコードは 機能的には 前のスライドと同じですが 柔軟性が増しています では 実際に使ってみましょう まず フォーカスされている ことを知りたいと思われる フィールドの簡単なenumを 定義しました FocusStateプロパティは そのenumを使って現在の状態を 表しています オプショナルを使って どのフィールドにも フォーカスが当たっていないことを 示すこともできます フォーカスされた修飾子は 同じフォーカスステートに バインドされていますが addAttendeeとなった時のみ フォーカスされます 最後にそのフィールドを フォーカスしたいときは フォーカスステートの値を addAttendeeに設定します この新しい柔軟性により ツールバーのボタンを事前に作成したり 各フィールド間のフォーカスの移動 フォーカスが最初か最後に到達した事の 表示 といった機能を追加できます フォーカスステートはまた 値をクリアすることによって iOSアプリでソフトウェアキーボードを 閉じるための優れた方法を提供します アプリでのフォーカス体験を 改善する他の方法について 興味がある方は 今年のセッション 「SwiftUIのダイレクトフォーカスと リフレクションフォーカス」 をご覧ください 最後に重要な ボタンに焦点を当てます 典型的なボタンの形は 誰もが知っています プラットフォームごとに 異なりますが これは人々がアプリと 対話するための 最も簡単な方法の一つです そして特にSwiftUIでは ボタンは多くのことに 使われています 先程マットが スワイプ操作が ボタンで構成されている ことを説明しました そして今年 ボタンには 多くの新機能があります まず SwiftUIはiOSで標準的な ボーダーのボタンをサポートします 私がこの追加ボタンで行って いるように buttonStyle修飾子を追加するだけで ボタンをボーダーにすることができます 他のstyle修飾子のように これはコントロールのグループに 追加することができ それらのすべてに適用されます 特定のボタンに 特定の外観を持たせたい場合の ティントにも対応しています しかし私は このUIでは アクセントカラーを使用する デフォルトの外観が好みです さらに多くのカスタマイズ機能が 搭載されています まずは コントロールサイズと 強調表示です これらを使って タグを表すボタンを カスタマイズしています コントロールサイズは新標準の 小さいサイズを採用し 分かりやすく 色を付けて目立たせています これらの同じ修飾子を使って追加でき 別の一般的なボタンを 作成することができます これらの大きなサイズのものは 現在SwiftUIに組み込まれています 大きなコントロールサイズを 指定することで 自動的にこれらの 美しい丸みを帯びた 長方形のボタンを 作ることができます そして 階層性を 持たせるために 最も重要なボタンを 目立たせるようにし ハイコントラストの アクセントカラーで塗りました そして 第二ボタンは 色を変えていますが コントラストは低くなる ようにしています これらのボタンには iPadでも使えるように 修飾子がほとんどありません テキストラベルには 最大幅があり ボタン全体には 柔軟性がありながらも 滑稽なほど大きく ならないようになっています また プライマリボタンには デフォルトで キーボードアクションの ショートカットが設定されており キーボードでAppを 操作する際も安心です 素早くリターンキーを押し この ボタンをジャーに追加できます このAPIの多くはすでに macOSに搭載されているので マルチプラットフォーム対応の Appをより簡単に作成できます 今回 新たに追加されたのは 目立つ色合いのサポートで このような明るいボタンを センスよくAppに 追加できるようになりました この追加ボタンのような 非強調表示のボタンは macOSではクロームが インタラクティブ性を 示すため 色合いは表示されません 強調表示について学んだので すべての追加ボタンに 適用したくなるところですが 画面上にたくさんの強調表示 されたボタンがあると 圧倒されて 混乱してしまいます 1つの主要なアクションだけに 使用するのがベストです 目立たない色合いは iOSで彩りを加えたい時の 最適な選択肢です これらの新しい ボタンスタイルで 私が気に入っているのは 押された状態と無効な状態が 自動的に設定されること ダークモードのサポート そしてもちろん 完全なアクセシビリティと Dynamic Typeとの互換性です これらは App間の 一貫性を保つことができます ボタンの新しいAPIは これだけではありません SwiftUIは ボタンを 破壊的とマークするような 追加のセマンティクスを 備えたボタンのための ファーストクラスのサポート も追加しており これにより ボタンに期待される赤の 色合いが自動的に与えられます これは データに重大な 影響を与える操作を ユーザが確認するための 確認ダイアログという新しい コンテキストでも使用する ことができるものです iOSではアクションシートとして iPadではポップオーバーとして macOSではアラート として表示されます SwiftUIは 各プラットフォームの 設計感度に従って 自動的に処理します 次に 「大文字のB」ボタンでは ないボタンについて説明します 現在 このAppの追加ボタンは ユーザのデフォルトの ジャーに追加するだけです しかし 熱心なコレクターの ために 特定のジャーへの追加を サポートしたいと思います これは メニューボタンに ぴったりの使用例です 同じ「追加」という ラベルを使いますが ボタンがクリックされると 可能なすべてのジャーの メニューを表示します ただし これらの メニューボタンは 視覚的に目立つように なっています 今年追加された menuIndicator修飾子 を使えば インジケータを 隠すことができます インジケータがなくても このボタンはクリックすると メニューを表示します しかし これらのボタンは 両方の長所を兼ね備えている のが理想です 1回のクリックでデフォルト のジャーに追加でき 他のメニューも表示できる 柔軟性を備えたボタンです 今年の新機能として メニューの 主なアクションをカスタマイズ できるようになり このような ケースに対応できるようになりました macOSのデフォルトでは 主要なアクションを持つメニューは 2分割で表示されます ボタンのメイン部分は メニューを表示する インジケータの主要な アクションをトリガーします インジケータが 非表示になると 視覚的には先程のボタンと 同じように見えますが 動作上の違いがあります クリックすると主要な アクションが実行され 長押しするとメニューが 表示されます 素晴らしいのはこれと同じこと がiOSでも通用することです これらのメニューは 自由度が高く アプリのニーズに合わせて 使うことができます 新しくボタンスタイルを 取得したコントロールの もう一つの例として Toggleがあります これはタップすると 視覚的にオンとオフが切り替わる ボタンで 他のToggleと同様に 使うことができます また これらの新しい コントロールスタイルに加え 関連するコントロールを グループ化する コンテナが用意されています iOSでは グループ内の コントロールは ツールバーの中で少し タイトに配置されます また macOSでは グループ化 された2つのボタンを示す 視覚的アフォーダンス があります さらに言えば 当然ながら これらすべてのものを一緒に 構成することができます 例えば この標準的な 戻る/進むボタンは 二つのメニューの ControlGroupです これらのメニューはそれぞれ クリックされた時に実行される primaryActionを持っています そして メニューが 長押しされると その内容が表示されます 今回 ボタンの カスタマイズと 新しいスタイルが 追加されたことで これらのコントロールを Appで使う際の 柔軟性が 大幅に広がりました 今回のセッションでは たくさんのことを紹介しましたが 紹介しきれなかったことも たくさんあります これらの新機能をご自身の SwiftUI Appで活用し さらに多くの場所で SwiftUIを採用して いただけることを 期待しています ありがとうございました 残りの2021年もお元気で ♪
-
-
3:29 - AsyncImage
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(width: 400, height: 266) .mask(RoundedRectangle(cornerRadius: 16)) } } .padding() } .navigationTitle("Superhero Recruits") } .navigationViewStyle(.stack) } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
3:45 - AsyncImage with custom placeholder
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { randomPlaceholderColor() .opacity(0.2) } .frame(width: 400, height: 266) .mask(RoundedRectangle(cornerRadius: 16)) } } .padding() } .navigationTitle("Superhero Recruits") } .navigationViewStyle(.stack) } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] } struct Photo: Identifiable { var id: URL { url } var url: URL } func randomPlaceholderColor() -> Color { placeholderColors.randomElement()! } let placeholderColors: [Color] = [ .red, .blue, .orange, .mint, .purple, .yellow, .green, .pink ]
-
4:00 - AsyncImage with custom animations and error handling
struct Contentiew: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url, transaction: .init(animation: .spring())) { phase in switch phase { case .empty: randomPlaceholderColor() .opacity(0.2) .transition(.opacity.combined(with: .scale)) case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .transition(.opacity.combined(with: .scale)) case .failure(let error): ErrorView(error) @unknown default: ErrorView() } } .frame(width: 400, height: 266) .mask(RoundedRectangle(cornerRadius: 16)) } } .padding() } .navigationTitle("Superhero Recruits") } .navigationViewStyle(.stack) } } struct ErrorView: View { var error: Error? init(_ error: Error? = nil) { self.error = error } var body: some View { Text("Error") // Display the error } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] } struct Photo: Identifiable { var id: URL { url } var url: URL } func randomPlaceholderColor() -> Color { placeholderColors.randomElement()! } let placeholderColors: [Color] = [ .red, .blue, .orange, .mint, .purple, .yellow, .green, .pink ]
-
4:24 - refreshable() modifier
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { List { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(minHeight: 200) .mask(RoundedRectangle(cornerRadius: 16)) .listRowSeparator(.hidden) } } .listStyle(.plain) .navigationTitle("Superhero Recruits") .refreshable { await photoStore.update() } } } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] func update() async { // Fetch new photos } } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
4:58 - task() modifier
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { List { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(minHeight: 200) .mask(RoundedRectangle(cornerRadius: 16)) .listRowSeparator(.hidden) } } .listStyle(.plain) .navigationTitle("Superhero Recruits") .refreshable { await photoStore.update() } .task { await photoStore.update() } } } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] func update() async { // Fetch new photos } } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
5:28 - task() modifier iterating over an AsyncSequence
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { List { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(minHeight: 200) .mask(RoundedRectangle(cornerRadius: 16)) .listRowSeparator(.hidden) } } .listStyle(.plain) .navigationTitle("Superhero Recruits") .refreshable { await photoStore.update() } .task { for await photo in photoStore.newestPhotos { photoStore.push(photo) } } } } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] var newestPhotos: NewestPhotos { NewestPhotos() } func update() async { // Fetch new photos from remote service } func push(_ photo: Photo) { photos.append(photo) } } struct NewestPhotos: AsyncSequence { struct AsyncIterator: AsyncIteratorProtocol { func next() async -> Photo? { // Fetch next photo from remote service } } func makeAsyncIterator() -> AsyncIterator { AsyncIterator() } } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
7:02 - Non-interactive directions list
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List(directions) { direction in Label { Text(direction.text) } icon: { DirectionsIcon(direction) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
8:08 - Interactive directions list
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
8:49 - Interactive directions list using ForEach
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List { ForEach($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
9:09 - listRowSeparatorTint() modifier
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List { ForEach($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } .listRowSeparatorTint(direction.color) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
9:38 - listRowSeparator() modifier
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List { ForEach($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } .listRowSeparator(.hidden) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
10:08 - Swipe actions
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: $characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: $characters.unpinned) } } .listStyle(.sidebar) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: Binding<[StoryCharacter]>) -> some View { ForEach(characters) { $character in CharacterProfile(character) .swipeActions { Button { togglePinned(for: $character) } label: { if character.isPinned { Label("Unpin", systemImage: "pin.slash") } else { Label("Pin", systemImage: "pin") } } .tint(.yellow) } } } private func togglePinned(for character: Binding<StoryCharacter>) { withAnimation { var tmp = character.wrappedValue tmp.isPinned.toggle() tmp.lastModified = Date() character.wrappedValue = tmp } } private func delete<C: RangeReplaceableCollection & MutableCollection>( _ character: StoryCharacter, in characters: Binding<C> ) where C.Element == StoryCharacter { withAnimation { if let i = characters.wrappedValue.firstIndex(where: { $0.id == character.id }) { characters.wrappedValue.remove(at: i) } } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
10:27 - Swipe actions on the leading edge
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: $characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: $characters.unpinned) } } .listStyle(.sidebar) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: Binding<[StoryCharacter]>) -> some View { ForEach(characters) { $character in CharacterProfile(character) .swipeActions(edge: .leading) { Button { togglePinned(for: $character) } label: { if character.isPinned { Label("Unpin", systemImage: "pin.slash") } else { Label("Pin", systemImage: "pin") } } .tint(.yellow) } } } private func togglePinned(for character: Binding<StoryCharacter>) { withAnimation { var tmp = character.wrappedValue tmp.isPinned.toggle() tmp.lastModified = Date() character.wrappedValue = tmp } } private func delete<C: RangeReplaceableCollection & MutableCollection>( _ character: StoryCharacter, in characters: Binding<C> ) where C.Element == StoryCharacter { withAnimation { if let i = characters.wrappedValue.firstIndex(where: { $0.id == character.id }) { characters.wrappedValue.remove(at: i) } } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
10:32 - Swipe actions on both edges
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: $characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: $characters.unpinned) } } .listStyle(.sidebar) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: Binding<[StoryCharacter]>) -> some View { ForEach(characters) { $character in CharacterProfile(character) .swipeActions(edge: .leading) { Button { togglePinned(for: $character) } label: { if character.isPinned { Label("Unpin", systemImage: "pin.slash") } else { Label("Pin", systemImage: "pin") } } .tint(.yellow) } .swipeActions(edge: .trailing) { Button(role: .destructive) { delete(character, in: characters) } label: { Label("Delete", systemImage: "trash") } Button { // Open "More" menu } label: { Label("More", systemImage: "ellipsis.circle") } .tint(Color(white: 0.8)) } } } private func togglePinned(for character: Binding<StoryCharacter>) { withAnimation { var tmp = character.wrappedValue tmp.isPinned.toggle() tmp.lastModified = Date() character.wrappedValue = tmp } } private func delete<C: RangeReplaceableCollection & MutableCollection>( _ character: StoryCharacter, in characters: Binding<C> ) where C.Element == StoryCharacter { withAnimation { if let i = characters.wrappedValue.firstIndex(where: { $0.id == character.id }) { characters.wrappedValue.remove(at: i) } } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
11:14 - Basic macOS list
struct ContentView: View { @State private var characters = StoryCharacter.previewData @State private var selection = Set<StoryCharacter.ID>() var body: some View { List(selection: $selection) { ForEach(characters) { character in Label { Text(character.name) } icon: { CharacterIcon(character) } .padding(.leading, 4) } } .listStyle(.inset) .navigationTitle("All Characters") } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
11:35 - Inset list style alternating row backgrounds
struct ContentView: View { @State private var characters = StoryCharacter.previewData @State private var selection = Set<StoryCharacter.ID>() var body: some View { List(selection: $selection) { ForEach(characters) { character in Label { Text(character.name) } icon: { CharacterIcon(character) } .padding(.leading, 4) } } .listStyle(.inset(alternatesRowBackgrounds: true)) .navigationTitle("All Characters") } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
12:13 - Tables
struct ContentView: View { @State private var characters = StoryCharacter.previewData var body: some View { Table(characters) { TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) } } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
12:49 - Tables with selection
struct ContentView: View { @State private var characters = StoryCharacter.previewData // Single selection @State private var singleSelection: StoryCharacter.ID? // Multiple selection @State private var multipleSelection: Set<StoryCharacter.ID>() var body: some View { Table(characters, selection: $singleSelection) { // or `$multipleSelection` TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) } } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
12:57 - Tables with selection and sorting
struct ContentView: View { @State private var characters = StoryCharacter.previewData @State private var selection = Set<StoryCharacter.ID>() @State private var sortOrder = [KeyPathComparator(\StoryCharacter.name)] @State private var sorted: [StoryCharacter]? var body: some View { Table(sorted ?? characters, selection: $selection, sortOrder: $sortOrder) { TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) } .onChange(of: characters) { sorted = $0.sorted(using: sortOrder) } .onChange(of: sortOrder) { sorted = characters.sorted(using: $0) } } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
13:15 - CoreData Tables
@FetchRequest(sortDescriptors: [SortDescriptor(\.name)]) private var characters: FetchedResults<StoryCharacter> @State private var selection = Set<StoryCharacter.ID>() Table(characters, selection: $selection, sortOrder: $characters.sortDescriptors) { TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) }
-
13:34 - Sectioned fetch requests
@SectionedFetchRequest( sectionIdentifier: \.isPinned, sortDescriptors: [ SortDescriptor(\.isPinned, order: .reverse), SortDescriptor(\.lastModified) ], animation: .default) private var characters: SectionedFetchResults<...> List { ForEach(characters) { section in Section(section.id ? "Pinned" : "Heroes & Villains") { ForEach(section) { character in CharacterRowView(character) } } } }
-
15:20 - searchable() modifier
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if characters.filterText.isEmpty { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: characters.unpinned) } } else { sectionContent(for: characters.filtered) } } .listStyle(.sidebar) .searchable(text: $characters.filterText) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: [StoryCharacter]) -> some View { ForEach(characters) { character in CharacterProfile(character) } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { all.prefix { $0.isPinned } } var unpinned: [StoryCharacter] { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } var filterText: String = "" var filtered: [StoryCharacter] { if filterText.isEmpty { return all } else { return all.filter { $0.name.contains(filterText) || $0.powers.contains(filterText) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
16:22 - Drag previews
struct ContentView: View { let character = StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true ) var body: some View { CharacterIcon(character) .controlSize(.large) .padding() .onDrag { character.itemProvider } preview: { Label { Text(character.name) } icon: { CharacterIcon(character) .controlSize(.small) } .padding(.vertical, 8) .frame(width: 150) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() var itemProvider: NSItemProvider { let item = NSItemProvider() item.registerObject(name as NSString, visibility: .all) return item } } struct CharacterIcon: View { var character: StoryCharacter #if os(iOS) || os(macOS) @Environment(\.controlSize) private var controlSize #endif init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(symbolPadding) .frame(width: symbolLength, height: symbolLength) if character.isVillain { symbol .background( character.color, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { symbol .background(character.color, in: Circle()) } } } var symbolPadding: CGFloat { switch controlSize { case .small: return 4 case .large: return 10 default: return 6 } } var symbolLength: CGFloat { switch controlSize { case .small: return 20 case .large: return 60 default: return 33 } } var cornerRadius: CGFloat { switch controlSize { case .small: return 4 case .large: return 16 default: return 8 } } }
-
16:48 - importsItemProviders() modifier
import UniformTypeIdentifiers @main private struct Catalog: App { var body: some Scene { WindowGroup { ContentView() } .commands { ImportFromDevicesCommands() } } } struct ContentView: View { @State private var character: StoryCharacter = StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true ) var body: some View { VStack { CharacterIcon(character) .controlSize(.large) .onDrag { character.itemProvider } preview: { Label { Text(character.name) } icon: { CharacterIcon(character) .controlSize(.small) } .padding(.vertical, 8) .frame(width: 150) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } if let headerImage = character.headerImage { headerImage .resizable() .aspectRatio(contentMode: .fill) .frame(width: 150, height: 150) .mask(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } .padding() .importsItemProviders(StoryCharacter.headerImageTypes) { itemProviders in guard let first = itemProviders.first else { return false } async { character.headerImage = await StoryCharacter.loadHeaderImage(from: first) } return true } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() var headerImage: Image? static var headerImageTypes: [UTType] { NSImage.imageTypes.compactMap { UTType($0) } } var itemProvider: NSItemProvider { let item = NSItemProvider() item.registerObject(name as NSString, visibility: .all) return item } static func loadHeaderImage(from itemProvider: NSItemProvider) async -> Image? { for type in Self.headerImageTypes.map(\.identifier) { if itemProvider.hasRepresentationConforming(toTypeIdentifier: type) { return await withCheckedContinuation { continuation in itemProvider.loadDataRepresentation(forTypeIdentifier: type) { data, error in guard let data = data, let image = NSImage(data: data) else { return } continuation.resume(returning: Image(nsImage: image)) } } } } return nil } } struct CharacterIcon: View { var character: StoryCharacter #if os(iOS) || os(macOS) @Environment(\.controlSize) private var controlSize #endif init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(symbolPadding) .frame(width: symbolLength, height: symbolLength) if character.isVillain { symbol .background( character.color, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { symbol .background(character.color, in: Circle()) } } } var symbolPadding: CGFloat { switch controlSize { case .small: return 4 case .large: return 10 default: return 6 } } var symbolLength: CGFloat { switch controlSize { case .small: return 20 case .large: return 60 default: return 33 } } var cornerRadius: CGFloat { switch controlSize { case .small: return 4 case .large: return 16 default: return 8 } } }
-
18:17 - exportsItemProviders() modifier
import UniformTypeIdentifiers @main private struct Catalog: App { var body: some Scene { WindowGroup { ContentView() } .commands { ImportFromDevicesCommands() } } } struct ContentView: View { @State private var character: StoryCharacter = StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true ) var body: some View { VStack { CharacterIcon(character) .controlSize(.large) .onDrag { character.itemProvider } preview: { Label { Text(character.name) } icon: { CharacterIcon(character) .controlSize(.small) } .padding(.vertical, 8) .frame(width: 150) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } if let headerImage = character.headerImage { headerImage .resizable() .aspectRatio(contentMode: .fill) .frame(width: 150, height: 150) .mask(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } .padding() .importsItemProviders(StoryCharacter.headerImageTypes) { itemProviders in guard let first = itemProviders.first else { return false } async { character.headerImage = await StoryCharacter.loadHeaderImage(from: first) } return true } .exportsItemProviders(StoryCharacter.contentTypes) { [character.itemProvider] } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() var headerImage: Image? static var contentTypes: [UTType] { [.utf8PlainText] } static var headerImageTypes: [UTType] { NSImage.imageTypes.compactMap { UTType($0) } } var itemProvider: NSItemProvider { let item = NSItemProvider() item.registerObject(name as NSString, visibility: .all) return item } static func loadHeaderImage(from itemProvider: NSItemProvider) async -> Image? { for type in Self.headerImageTypes.map(\.identifier) { if itemProvider.hasRepresentationConforming(toTypeIdentifier: type) { return await withCheckedContinuation { continuation in itemProvider.loadDataRepresentation(forTypeIdentifier: type) { data, error in guard let data = data, let image = NSImage(data: data) else { return } continuation.resume(returning: Image(nsImage: image)) } } } } return nil } } struct CharacterIcon: View { var character: StoryCharacter #if os(iOS) || os(macOS) @Environment(\.controlSize) private var controlSize #endif init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(symbolPadding) .frame(width: symbolLength, height: symbolLength) if character.isVillain { symbol .background( character.color, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { symbol .background(character.color, in: Circle()) } } } var symbolPadding: CGFloat { switch controlSize { case .small: return 4 case .large: return 10 default: return 6 } } var symbolLength: CGFloat { switch controlSize { case .small: return 20 case .large: return 60 default: return 33 } } var cornerRadius: CGFloat { switch controlSize { case .small: return 4 case .large: return 16 default: return 8 } } }
-
19:47 - Symbol rendering modes
struct ContentView: View { var body: some View { VStack { HStack { symbols } .symbolRenderingMode(.monochrome) HStack { symbols } .symbolRenderingMode(.multicolor) HStack { symbols } .symbolRenderingMode(.hierarchical) HStack { symbols } .symbolRenderingMode(.palette) .foregroundStyle(Color.cyan, Color.purple) } .foregroundStyle(.blue) .font(.title) } @ViewBuilder var symbols: some View { Group { Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "pc") Image(systemName: "phone.down.circle") Image(systemName: "hourglass") Image(systemName: "heart.fill") Image(systemName: "airplane.circle.fill") } .frame(width: 40, height: 40) } }
-
20:27 - Symbol variants
struct ContentView: View { var body: some View { VStack { HStack { symbols } HStack { symbols } .symbolVariant(.fill) } .foregroundStyle(.blue) } @ViewBuilder var symbols: some View { let heart = Image(systemName: "heart") Group { heart heart.symbolVariant(.slash) heart.symbolVariant(.circle) heart.symbolVariant(.square) heart.symbolVariant(.rectangle) } .frame(width: 40, height: 40) } }
-
20:42 - Tab symbol variants: iOS 13
struct TabExample: View { var body: some View { TabView { CardsView().tabItem { Label("Cards", systemImage: "rectangle.portrait.on.rectangle.portrait.fill") } RulesView().tabItem { Label("Rules", systemImage: "character.book.closed.fill") } ProfileView().tabItem { Label("Profile", systemImage: "person.circle.fill") } SearchPlayersView().tabItem { Label("Magic", systemImage: "sparkles") } } } } struct CardsView: View { var body: some View { Color.clear } } struct RulesView: View { var body: some View { Color.clear } } struct ProfileView: View { var body: some View { Color.clear } } struct SearchPlayersView: View { var body: some View { Color.clear } }
-
20:50 - Tab symbol variants
@main struct SnippetsApp: App { var body: some Scene { WindowGroup { #if os(iOS) TabExample() #else VStack{ Text("Open Preferences") Text("⌘,").font(.title.monospaced()) } .fixedSize() .scenePadding() #endif } #if os(macOS) Settings { TabExample() } #endif } } struct TabExample: View { var body: some View { TabView { CardsView().tabItem { Label("Cards", systemImage: "rectangle.portrait.on.rectangle.portrait") } RulesView().tabItem { Label("Rules", systemImage: "character.book.closed") } ProfileView().tabItem { Label("Profile", systemImage: "person.circle") } SearchPlayersView().tabItem { Label("Magic", systemImage: "sparkles") } } } } struct CardsView: View { var body: some View { Color.clear } } struct RulesView: View { var body: some View { Color.clear } } struct ProfileView: View { var body: some View { Color.clear } } struct SearchPlayersView: View { var body: some View { Color.clear } }
-
21:31 - Canvas
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) var body: some View { Canvas { context, size in let metrics = gridMetrics(in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let image = context.resolve(symbol.image) context.draw(image, in: rect.fit(image.size)) } } } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } }
-
22:03 - Canvas with gesture
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) @GestureState private var focalPoint: CGPoint? = nil var body: some View { Canvas { context, size in let metrics = gridMetrics(in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let (sRect, opacity) = rect.fishEyeTransform(around: focalPoint) context.opacity = opacity let image = context.resolve(symbol.image) context.draw(image, in: sRect.fit(image.size)) } } .gesture(DragGesture(minimumDistance: 0).updating($focalPoint) { value, focalPoint, _ in focalPoint = value.location }) } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`. /// The rectangles closer to the center of that point will be larger and brighter, and those further away will be smaller, up to a distance of `radius`. func fishEyeTransform(around point: CGPoint?, radius: CGFloat = 300, zoom: CGFloat = 1.0) -> (frame: CGRect, opacity: CGFloat) { guard let point = point else { return (self, 1.0) } let deltaX = midX - point.x let deltaY = midY - point.y let distance = sqrt(deltaX*deltaX + deltaY*deltaY) let theta = atan2(deltaY, deltaX) let scaledClampedDistance = pow(min(1, max(0, distance/radius)), 0.7) let scale = (1.0 - scaledClampedDistance)*zoom + 0.5 let newOffset = distance * (2.0 - scaledClampedDistance)*sqrt(zoom) let newDeltaX = newOffset * cos(theta) let newDeltaY = newOffset * sin(theta) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let newOrigin = CGPoint(x: (newDeltaX + point.x) - newSize.width/2, y: (newDeltaY + point.y) - newSize.height/2) // Clamp the opacity to be 0.1 at the lowest let opacity = max(0.1, 1.0 - scaledClampedDistance) return (CGRect(origin: newOrigin, size: newSize), opacity) } }
-
22:24 - Canvas with accessibility children
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) @GestureState private var focalPoint: CGPoint? = nil var body: some View { Canvas { context, size in let metrics = gridMetrics(in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let (sRect, opacity) = rect.fishEyeTransform(around: focalPoint) context.opacity = opacity let image = context.resolve(symbol.image) context.draw(image, in: sRect.fit(image.size)) } } .gesture(DragGesture(minimumDistance: 0).updating($focalPoint) { value, focalPoint, _ in focalPoint = value.location }) .accessibilityLabel("Symbol Browser") .accessibilityChildren { List(symbols) { Text($0.name) } } } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`. /// The rectangles closer to the center of that point will be larger and brighter, and those further away will be smaller, up to a distance of `radius`. func fishEyeTransform(around point: CGPoint?, radius: CGFloat = 300, zoom: CGFloat = 1.0) -> (frame: CGRect, opacity: CGFloat) { guard let point = point else { return (self, 1.0) } let deltaX = midX - point.x let deltaY = midY - point.y let distance = sqrt(deltaX*deltaX + deltaY*deltaY) let theta = atan2(deltaY, deltaX) let scaledClampedDistance = pow(min(1, max(0, distance/radius)), 0.7) let scale = (1.0 - scaledClampedDistance)*zoom + 0.5 let newOffset = distance * (2.0 - scaledClampedDistance)*sqrt(zoom) let newDeltaX = newOffset * cos(theta) let newDeltaY = newOffset * sin(theta) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let newOrigin = CGPoint(x: (newDeltaX + point.x) - newSize.width/2, y: (newDeltaY + point.y) - newSize.height/2) // Clamp the opacity to be 0.1 at the lowest let opacity = max(0.1, 1.0 - scaledClampedDistance) return (CGRect(origin: newOrigin, size: newSize), opacity) } }
-
22:48 - Canvas with TimelineView
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) var body: some View { TimelineView(.animation) { let time = $0.date.timeIntervalSince1970 Canvas { context, size in let metrics = gridMetrics(in: size) let focalPoint = focalPoint(at: time, in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let (sRect, opacity) = rect.fishEyeTransform( around: focalPoint, at: time) context.opacity = opacity let image = context.resolve(symbol.image) context.draw(image, in: sRect.fit(image.size)) } } } } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`. /// The rectangles closer to the center of that point will be larger and brighter, and those further away will be smaller, up to a distance of `radius`. func fishEyeTransform(around point: CGPoint?, radius: CGFloat = 200, zoom: CGFloat = 3.0) -> (frame: CGRect, opacity: CGFloat) { guard let point = point else { return (self, 1.0) } let deltaX = midX - point.x let deltaY = midY - point.y let distance = sqrt(deltaX*deltaX + deltaY*deltaY) let theta = atan2(deltaY, deltaX) let scaledClampedDistance = pow(min(1, max(0, distance/radius)), 0.7) let scale = (1.0 - scaledClampedDistance)*zoom + 0.5 let newOffset = distance * (2.0 - scaledClampedDistance)*sqrt(zoom) let newDeltaX = newOffset * cos(theta) let newDeltaY = newOffset * sin(theta) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let newOrigin = CGPoint(x: (newDeltaX + point.x) - newSize.width/2, y: (newDeltaY + point.y) - newSize.height/2) // Clamp the opacity to be 0.1 at the lowest let opacity = max(0.1, 1.0 - scaledClampedDistance) return (CGRect(origin: newOrigin, size: newSize), opacity) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`, based on a preset path indexed using `time`. func fishEyeTransform(around point: CGPoint, at time: TimeInterval) -> (frame: CGRect, opacity: CGFloat) { // Arbitrary zoom and radius calculation based on time let zoom = cos(time) + 3.0 let radius = ((cos(time/5) + 1)/2) * 150 + 150 return fishEyeTransform(around: point, radius: radius, zoom: zoom) } } /// Returns a focal point within `size` based on a preset path, indexed using `time`. func focalPoint(at time: TimeInterval, in size: CGSize) -> CGPoint { let offset: CGFloat = min(size.width, size.height)/4 let distance = ((sin(time/5) + 1)/2) * offset + offset let scalePoint = CGPoint(x: size.width / 2 + distance * cos(time / 2), y: size.height / 2 + distance * sin(time / 2)) return scalePoint }
-
24:10 - Privacy sensitive
Button { showFavoritePicker = true } label: { VStack(alignment: .center) { Text("Favorite Symbol") .foregroundStyle(.secondary) Image(systemName: favoriteSymbol) .font(.title2) .privacySensitive(true) } } .tint(.purple)
-
24:27 - Privacy sensitive (widgets)
VStack(alignment: .leading) { Text("Favorite Symbol") .textCase(.uppercase) .font(.caption.bold()) ContainerRelativeShape() .fill(.quaternary) .overlay { Image(systemName: favoriteSymbol) .font(.system(size: 40)) .privacySensitive(true) } }
-
25:03 - Materials
struct ColorList: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) var body: some View { ZStack { gradientBackground materialOverlay } } var materialOverlay: some View { VStack { Text("Symbol Browser") .font(.largeTitle.bold()) Text("\(symbols.count) symbols 🎉") .foregroundStyle(.secondary) .font(.title2.bold()) } .padding() .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16.0)) } var gradientBackground: some View { LinearGradient( gradient: Gradient(colors: [.red, .orange, .yellow, .green, .blue, .indigo, .purple]), startPoint: .leading, endPoint: .trailing) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } }
-
25:40 - Safe area inset
struct ContentView: View { let newSymbols = Array(repeating: Symbol("swift"), count: 645) let systemColors: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .brown] var body: some View { ScrollView { symbolGrid } .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { Divider() VStack(spacing: 0) { Text("\(newSymbols.count) new symbols") .foregroundStyle(.primary) .font(.body.bold()) Text("\(systemColors.count) system colors") .foregroundStyle(.secondary) } .padding() } .background(.regularMaterial) } } var symbolGrid: some View { LazyVGrid(columns: [.init(.adaptive(minimum: 40, maximum: 40))]) { ForEach(0 ..< newSymbols.count, id: \.self) { index in newSymbols[index].image .foregroundStyle(.white) .frame(width: 40, height: 40) .background(systemColors[index % systemColors.count]) } } .padding() } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } }
-
26:03 - Preview orientation
struct ColorList_Previews: PreviewProvider { static var previews: some View { ColorList() .previewInterfaceOrientation(.portrait) ColorList() .previewInterfaceOrientation(.landscapeLeft) } } struct ColorList: View { let newSymbols = Array(repeating: Symbol("swift"), count: 645) let systemColors: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .brown] var body: some View { ScrollView { symbolGrid } .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { Divider() VStack(spacing: 0) { Text("\(newSymbols.count) new symbols") .foregroundStyle(.primary) .font(.body.bold()) Text("\(systemColors.count) system colors") .foregroundStyle(.secondary) } .padding() } .background(.regularMaterial) } } var symbolGrid: some View { LazyVGrid(columns: [.init(.adaptive(minimum: 40, maximum: 40))]) { ForEach(0 ..< newSymbols.count, id: \.self) { index in newSymbols[index].image .foregroundStyle(.white) .frame(width: 40, height: 40) .background(systemColors[index % systemColors.count]) } } .padding() } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } }
-
27:06 - Hello, World!
Text("Hello, World!")
-
27:17 - Markdown Text: strong emphasis
Text("**Hello**, World!")
-
27:24 - Markdown Text: links
Text("**Hello**, World!") Text(""" Have a *happy* [WWDC](https://developer.apple.com/wwdc21/)! """)
-
27:30 - Markdown Text: inline code
Text(""" Is this *too* meta? `Text("**Hello**, World!")` `Text(\"\"\"` `Have a *happy* [WWDC](https://developer.apple.com/wwdc21/)!` `\"\"\")` """)
-
27:37 - AttributedString
struct ContentView: View { var body: some View { Text(formattedDate) } var formattedDate: AttributedString { var formattedDate: AttributedString = Date().formatted(Date.FormatStyle().day().month(.wide).weekday(.wide).attributed) let weekday = AttributeContainer.dateField(.weekday) let color = AttributeContainer.foregroundColor(.orange) formattedDate.replaceAttributes(weekday, with: color) return formattedDate } }
-
29:17 - Text selection
struct ContentView: View { var activity: Activity = .sample var body: some View { VStack(alignment: .leading, spacing: 0) { ActivityHeader(activity) Divider() Text(activity.info) .textSelection(.enabled) .padding() Spacer() } .background() .navigationTitle(activity.name) } } struct ActivityHeader: View { var activity: Activity init(_ activity: Activity) { self.activity = activity } var body: some View { VStack(alignment: alignment.horizontal, spacing: 8) { HStack(alignment: .firstTextBaseline) { #if os(macOS) Text(activity.name) .font(.title2.bold()) Spacer() #endif Text(activity.date.formatted(.dateTime.weekday(.wide).day().month().hour().minute())) .foregroundStyle(.secondary) } HStack(alignment: .firstTextBaseline) { Image(systemName: "person.2") Text(activity.people.map(\.nameComponents).formatted(.list(memberStyle: .name(style: .short), type: .and))) } } #if os(macOS) .padding() #else .padding([.horizontal, .bottom]) #endif .frame(maxWidth: .infinity, alignment: alignment) .background(activity.tint.opacity(0.1).ignoresSafeArea()) } private var alignment: Alignment { #if os(macOS) .leading #else .center #endif } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
29:28 - Text selection: view hierarchy
struct ContentView: View { var activity: Activity = .sample var body: some View { VStack(alignment: .leading, spacing: 0) { ActivityHeader(activity) Divider() Text(activity.info) .padding() Spacer() } .textSelection(.enabled) .background() .navigationTitle(activity.name) } } struct ActivityHeader: View { var activity: Activity init(_ activity: Activity) { self.activity = activity } var body: some View { VStack(alignment: alignment.horizontal, spacing: 8) { HStack(alignment: .firstTextBaseline) { #if os(macOS) Text(activity.name) .font(.title2.bold()) Spacer() #endif Text(activity.date.formatted(.dateTime.weekday(.wide).day().month().hour().minute())) .foregroundStyle(.secondary) } HStack(alignment: .firstTextBaseline) { Image(systemName: "person.2") Text(activity.people.map(\.nameComponents).formatted(.list(memberStyle: .name(style: .short), type: .and))) } } #if os(macOS) .padding() #else .padding([.horizontal, .bottom]) #endif .frame(maxWidth: .infinity, alignment: alignment) .background(activity.tint.opacity(0.1).ignoresSafeArea()) } private var alignment: Alignment { #if os(macOS) .leading #else .center #endif } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
30:03 - Text formatting: List
struct ContentView: View { var activity: Activity = .sample var body: some View { Text(activity.people.map(\.nameComponents).formatted(.list(memberStyle: .name(style: .short), type: .and))) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
30:43 - Text field formatting
struct ContentView: View { @State private var newAttendee = PersonNameComponents() var body: some View { TextField("New Person", value: $newAttendee, format: .name(style: .medium)) } }
-
31:09 - Text field prompts and labels
struct ContentView: View { @State var activity: Activity = .sample var body: some View { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) TextField("Location:", text: $activity.location) DatePicker("Date:", selection: $activity.date) } .frame(minWidth: 250) .padding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
31:39 - Text field submission
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() var body: some View { TextField("New Person", value: $newAttendee, format: .name(style: .medium) ) .onSubmit { activity.append(Person(newAttendee)) newAttendee = PersonNameComponents() } } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
31:59 - Text field submission: submit label
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() var body: some View { TextField("New Person", value: $newAttendee, format: .name(style: .medium) ) .onSubmit { activity.append(Person(newAttendee)) newAttendee = PersonNameComponents() } .submitLabel(.done) } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
32:07 - Keyboard toolbar
struct ContentView: View { @State private var activity: Activity = .sample @FocusState private var focusedField: Field? var body: some View { Form { TextField("Name", text: $activity.name, prompt: Text("New Activity")) TextField("Location", text: $activity.location) DatePicker("Date", selection: $activity.date) } .toolbar { ToolbarItemGroup(placement: .keyboard) { Button(action: selectPreviousField) { Label("Previous", systemImage: "chevron.up") } .disabled(!hasPreviousField) Button(action: selectNextField) { Label("Next", systemImage: "chevron.down") } .disabled(!hasNextField) } } } private func selectPreviousField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue - 1)! } } private var hasPreviousField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue > 0 } else { return false } } private func selectNextField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue + 1)! } } private var hasNextField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue < Field.allCases.count } else { return false } } } private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
33:05 - Focus state
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() @FocusState private var addAttendeeIsFocused: Bool var body: some View { VStack { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) TextField("Location:", text: $activity.location) DatePicker("Date:", selection: $activity.date) } TextField("New Person", value: $newAttendee, format: .name(style: .medium)) .focused($addAttendeeIsFocused) } .frame(minWidth: 250) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
33:16 - Focus state: setting focus
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() @FocusState private var addAttendeeIsFocused: Bool var body: some View { VStack { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) TextField("Location:", text: $activity.location) DatePicker("Date:", selection: $activity.date) } VStack(alignment: .leading) { TextField("New Person", value: $newAttendee, format: .name(style: .medium)) .focused($addAttendeeIsFocused) ControlGroup { Button { addAttendeeIsFocused = true } label: { Label("Add Attendee", systemImage: "plus") } } .fixedSize() } } .frame(minWidth: 250) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
33:30 - Focus state: Hashable value
private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() @FocusState private var focusedField: Field? var body: some View { VStack { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) .focused($focusedField, equals: .name) TextField("Location:", text: $activity.location) .focused($focusedField, equals: .location) DatePicker("Date:", selection: $activity.date) .focused($focusedField, equals: .date) } VStack(alignment: .leading) { TextField("New Person", value: $newAttendee, format: .name(style: .medium)) .focused($focusedField, equals: .addAttendee) ControlGroup { Button { focusedField = .addAttendee } label: { Label("Add Attendee", systemImage: "plus") } } .fixedSize() } } .frame(minWidth: 250) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
34:03 - Focus state: back/forward controls
private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct ContentView: View { @State private var activity: Activity = .sample @FocusState private var focusedField: Field? var body: some View { Form { TextField("Name", text: $activity.name, prompt: Text("New Activity")) TextField("Location", text: $activity.location) DatePicker("Date", selection: $activity.date) } .toolbar { ToolbarItemGroup(placement: .keyboard) { Button(action: selectPreviousField) { Label("Previous", systemImage: "chevron.up") } .disabled(!canSelectPreviousField) Button(action: selectNextField) { Label("Next", systemImage: "chevron.down") } .disabled(!canSelectNextField) } } } private func selectPreviousField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue - 1)! } } private var canSelectPreviousField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue > 0 } else { return false } } private func selectNextField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue + 1)! } } private var canSelectNextField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue < Field.allCases.count } else { return false } } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
34:13 - Focus state: keyboard dismissal
private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct ContentView: View { @State private var activity: Activity = .sample @FocusState private var focusedField: Field? var body: some View { Form { TextField("Name", text: $activity.name, prompt: Text("New Activity")) TextField("Location", text: $activity.location) DatePicker("Date", selection: $activity.date) } } func endEditing() { focusedField = nil } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
34:55 - Bordered buttons
Button("Add") { // ... } .buttonStyle(.bordered)
-
35:03 - Bordered buttons: view hierarchy
struct ContentView: View { var body: some View { ScrollView { LazyVStack { ForEach(0..<10) { _ in Button("Add") { //... } } } } .buttonStyle(.bordered) } }
-
35:09 - Bordered buttons: tinting
struct ContentView: View { var body: some View { ScrollView { LazyVStack { ForEach(0..<10) { _ in Button("Add") { //... } } } } .buttonStyle(.bordered) .tint(.green) } }
-
35:16 - Control size and prominence
struct ContentView: View { var entry: ButtonEntry = .sample var body: some View { HStack { ForEach(entry.tags) { tag in Button(tag.name) { // ... } .tint(tag.color) } } .buttonStyle(.bordered) .controlSize(.small) .controlProminence(.increased) } } struct ButtonEntry { struct Tag: Identifiable { var name: String var color: Color var id: String { name } } var name: String var tags: [Tag] static let sample = ButtonEntry(name: "Stroopwafel", tags: [Tag(name: "1960s", color: .purple), Tag(name: "bronze", color: .yellow)]) }
-
35:34 - Large buttons
struct ContentView: View { var body: some View { VStack { Button(action: addToJar) { Text("Add to Jar").frame(maxWidth: 300) } .controlProminence(.increased) .keyboardShortcut(.defaultAction) Button(action: addToWatchlist) { Text("Add to Watchlist").frame(maxWidth: 300) } .tint(.accentColor) } .buttonStyle(.bordered) .controlSize(.large) } private func addToJar() {} private func addToWatchlist() {} }
-
37:14 - Destructive buttons
struct ContentView: View { var entry: ButtonEntry = .sample var body: some View { ButtonEntryCell(entry) .contextMenu { Section { Button("Open") { // ... } Button("Delete...", role: .destructive) { // ... } } Section { Button("Archive") {} Menu("Move to") { ForEach(Jar.allJars) { jar in Button("\(jar.name)") { //addTo(jar) } } } } } } } struct ButtonEntryCell: View { var entry: ButtonEntry = .sample init(_ entry: ButtonEntry) { self.entry = entry } var body: some View { Text(entry.name) .padding() } } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { struct Tag: Identifiable { var name: String var color: Color var id: String { name } } var name: String var tags: [Tag] static let sample = ButtonEntry(name: "Stroopwafel", tags: [Tag(name: "1960s", color: .purple), Tag(name: "bronze", color: .yellow)]) }
-
37:25 - Confirmation dialogs
struct ContentView: View { var entry: ButtonEntry = .sample @State private var showConfirmation: Bool = false var body: some View { ButtonEntryCell(entry) .contextMenu { Section { Button("Open") { // ... } Button("Delete...", role: .destructive) { showConfirmation = true // ... } } Section { Button("Archive") {} Menu("Move to") { ForEach(Jar.allJars) { jar in Button("\(jar.name)") { //addTo(jar) } } } } } .confirmationDialog( "Are you sure you want to delete \(entry.name)?", isPresented: $showConfirmation ) { Button("Delete", role: .destructive) { // delete the entry } } message: { Text("Deleting \(entry.name) will remove it from all of your jars.") } } } struct ButtonEntryCell: View { var entry: ButtonEntry = .sample init(_ entry: ButtonEntry) { self.entry = entry } var body: some View { Text(entry.name) .padding() } } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { struct Tag: Identifiable { var name: String var color: Color var id: String { name } } var name: String var tags: [Tag] static let sample = ButtonEntry(name: "Stroopwafel", tags: [Tag(name: "1960s", color: .purple), Tag(name: "bronze", color: .yellow)]) }
-
37:59 - Menu buttons
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } .menuStyle(BorderedButtonMenuStyle()) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
38:10 - Menu buttons: hidden indicator
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } .menuStyle(BorderedButtonMenuStyle()) .menuIndicator(.hidden) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
38:31 - Menu buttons: primary action
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } primaryAction: { jarStore.addToDefaultJar(buttonEntry) } .menuStyle(BorderedButtonMenuStyle()) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} func addToDefaultJar(_ entry: ButtonEntry) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
38:42 - Menu buttons: primary action, indicator hidden
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } primaryAction: { jarStore.addToDefaultJar(buttonEntry) } .menuStyle(BorderedButtonMenuStyle()) .menuIndicator(.hidden) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} func addToDefaultJar(_ entry: ButtonEntry) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
39:01 - Toggle buttons
Toggle(isOn: $showOnlyNew) { Label("Show New Buttons", systemImage: "sparkles") } .toggleStyle(.button)
-
39:13 - Control group
ControlGroup { Button(action: archive) { Label("Archive", systemImage: "archiveBox") } Button(action: delete) { Label("Delete", systemName: "trash") } }
-
39:26 - Control group: back/forward control
struct ContentView: View { @State var current: String = "More buttons" @State var history: [String] = ["Text and keyboard", "Advanced graphics", "Beyond lists", "Better lists"] @State var forwardHistory: [String] = [] var body: some View { Color.clear .toolbar{ ToolbarItem(placement: .navigation) { ControlGroup { Menu { ForEach(history, id: \.self) { previousSection in Button(previousSection) { goBack(to: previousSection) } } } label: { Label("Back", systemImage: "chevron.backward") } primaryAction: { goBack(to: history[0]) } .disabled(history.isEmpty) Menu { ForEach(forwardHistory, id: \.self) { nextSection in Button(nextSection) { goForward(to: nextSection) } } } label: { Label("Forward", systemImage: "chevron.forward") } primaryAction: { goForward(to: forwardHistory[0]) } .disabled(forwardHistory.isEmpty) } .controlGroupStyle(.navigation) } } .navigationTitle(current) } private func goBack(to section: String) { guard let index = history.firstIndex(of: section) else { return } forwardHistory.insert(current, at: 0) forwardHistory.insert(contentsOf: history[...history.index(before: index)].reversed(), at: 0) history.removeSubrange(...index) current = section } private func goForward(to section: String) { guard let index = forwardHistory.firstIndex(of: section) else { return } history.insert(current, at: 0) history.insert(contentsOf: forwardHistory[...forwardHistory.index(before: index)].reversed(), at: 0) forwardHistory.removeSubrange(...index) current = section } }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。