ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
Apple Watch用の仕事効率化Appの構築
手首ががかつてないほど生産的になります。SwiftUIやシステム機能を使用して、優れた生産性を持つAppをApple Watchに構築する方法をご確認ください。手首に快適な作業エクスペリエンスをもたらす方法をはじめ、テキスト入力を得たり、基本的なグラフを表示したり、友人にコンテンツを共有したりする方法について解説します。
リソース
関連ビデオ
WWDC22
-
ダウンロード
こんにちは ようこそ! Anne Hitchcockです 私は watchOSソフトウェアの エンジニアです 今日は watchOSで 生産性向上Appを 作る方法を 紹介したいと思います watchOS 6でSwiftUIと Independent Watch Appが 導入されて以来 より多くのことが できるようになりました 毎年 watchOS上のSwiftUIは 多くの機能を追加しています 同時に watchOSはキーボードのような 新機能も追加しました 全く新しいAppを 作ることができます 今回は その機能を結合して To-Doリストを 記録するAppを 作る方法をご紹介します 新しいWatch Appを作成し 表示するアイテムの 簡単なリストを追加し 人々がそのリストに アイテムを 追加できるようにします これらの機能を 追加していくと 今回はWatch Appの一般的な Appのナビゲーション戦略と その正しい選択方法を 紹介します
友人とアイテムをシェアし 負担を分担します
次に Appにチャートを 追加します 生産性の傾向を把握し モチベーションを維持します
そして デジタルクラウンを使って チャートをスクロールさせ より広いデータ範囲を 表示させることができます
では さっそく 新しいAppを作りましょう
Xcodeで新規プロジェクトを 作ります watchOSタブで Appを選択 「次へ」をクリックします
プロダクト名を選択した後 いくつかの選択肢があります 最も重要なものことは Watch専用Appを 作るかどうかです Watch Appを 作成するために 優れたWatch Appの条件と コンパニオン用のiOS Appが どんな時に必要なのか 説明します
Watchの優れたAppは インターフェイスのように 素早いインタラクションを 可能にします お気に入りのワークアウトを 素早く開始できます 誰も立ち止まり 腕を上げ タップして何かを 探そうとはしません 重要な情報に 簡単にアクセスできるという 特徴があります
優れたWatch Appは本質的な 目的にフォーカスしています 例えば 天気予報Appでは 今日の天気予報を表示します 現在の状況です そして 10日間のシンプルな予報です
必要な情報を簡単に 見つけられるように Appの本質に 焦点を当てましょう 必要な情報や行動を 簡単に見つけられます
優れたWatch Appは ペアとなるiPhoneとは 独立して使用できるように 設計されています 例えば連絡先Appは iPhoneと同期していますが iPhoneが近くになくても Apple Watch上で 連絡先情報にアクセス することができます
また Apple Watchで 取得したデータの 履歴を提供したり フィットネスAppのように トレンドの詳細分析で Watch Appのペアとなる iOS Appが必要な理由は たくさんあります
私たちのAppは 機能を絞っているので 迅速なインタラクション そして限られたデータで Watch専用Appを作成します
この時点で 私は作成する ターゲットの話を 数分かけてします
過去にWatch Appを 作ったことがある方 プロジェクトには Watchのターゲットが2つあります ストーリーボード アセット ローカライズ ファイルを含むWatchKit Appターゲットと Appの全コードを含むWatchKit Extensionのターゲットです このデュアルターゲットは watchOSの初期から 引き継いできたものでしたが 複数のWatchターゲットが 必要な理由はもうありません Xcode14から 新しいWatch Appは 1つのWatch Appの ターゲットとなり コード アセット ローカライズのすべて Siri IntentとWidget Extension Watch Appに関連する全てが 1つのターゲットに属しています
シングルターゲットの Watch AppがwatchOS 7まで サポートされたことは ビックニュースです! プロジェクト構造を シンプルにし 混乱や重複を減らしつつ 最新のwatchOSを 実行していない
お客様もサポートする ことができます 既存のWatchKit Extensionの ターゲットのAppがある場合 それも引き続き動作します Xcodeを使用して Appを更新でき App Storeから Appを公開できます
すでに SwiftUIの ライフサイクルを使った Watch Appを使用している場合 シングルターゲットへの移行は Xcode14のツールを使えば 簡単にできます ターゲットの設定をし 「Validate Settings」を Editorメニューから選択します デプロイメント対象が はwatchOS 7以降の場合です ターゲットを閉じるオプションが 提供されます
まだそうしていないなら 今こそ みなさんのAppを SwiftUIのライフサイクルを 使用するように変換するよう プロセスを開始する 良い機会です シングルターゲットの Watch Appのシンプルさと SwiftUIの機能のすべてを 楽しむことができます
Xcode 14で簡略化したのは ターゲットではありません 1024x1024ピクセルの画像を 1枚用意するだけで Appのアイコンを 簡単に追加できるように なりました
Appのアイコン画像は 全てのWatchデバイス用に 拡大縮小されます
ホーム画面 通知 iPhoneのWatch Appの Appの設定など アイコンを表示して テストしてください
必要に応じて 特定の小さいサイズ用 カスタム画像を 追加することもできます 例えば Appの アイコンを小さくすると 画像の詳細が 消えてしまう場合 画像の詳細を削除した 特定のアイコン画像を 追加することができます では Appに いくつかの機能を 追加してみましょう データモデルを 作成することから始めます ListItem構造体は IdentifiableとHashableとして 表示用の説明文をセットします
次に データを格納するため 簡単なモデルを作成し リストアイテムの 配列をパブリッシュします
そして最後に モデルをEnvironment オブジェクトとして追加し ビューがモデルに アクセスできるようにします
では SwiftUIでリストを 作ってみましょう まだタスクがないため プレビューすると 空のリストが表示されます
何とかしないとですね タスクを追加する方法を 提供すべきです
そこでボタンを 追加したいと思います 入力すると 新しいアイテムが追加されます watchOS 9の新機能 テキストフィールドのリンク ボタンから 呼び出すことができます いくつかのスタイリング オプションがあります これを使えば Appに違和感なく 溶け込めるはずです
シンプルな文字列で基本的な テキストを作成したり ラベルを使用してカスタム ボタンを作成できます
ボタンの見た目を foregroundColor foregroundStyle buttonStyleなどの ビューモディファイアで変更します
AddItemLinkビューを 作成して スタイルと Appで使っている TextFieldLinkの挙動を カプセル化します
ボタンにはカスタム ラベルを使用し 誰かがテキストを 入力すると 新しいアイテムがリストに 追加されます
TextFieldLinkを使って 追加ボタンを 追加することにしました TextFieldLinkをどこに置くかを 考える必要があります
Watch Appでリストに アクションを追加する場合 いくつかの選択肢が あります 短いリストのメイン アクションには リストの末尾に TextFieldLinkを使用します アクションをリストの 末尾に追加するのは 世界時計の 都市リストのように 短いリストの中の メインアクションに 適しています しかし 長いリストが予想される場合 アクションを 実行するたびにリストの 最後までスクロール し続けなければなりません よく使うアクションに対し リストが長い場合は ツールバーアイテムを 使用します
ツールバーアイテムを 追加するには リストにtoolbarモディファイアを 追加します これは ツールバー アイテムの自動配置で リストにツールバー アイテムを1つ追加します 常にTo-Doリストを 短く保っていると 思いたいのですが そうでない場合が あるのは確かです テキストフィールドリンクを ツールバーに配置し 簡単に アクセスできるようにします
私たちが達成したことを 少し振り返ってみましょう リストアイテムの モデルを作成し Envrionmentオブジェクトとして 保存し アイテムを表示する リストを作成し テキストフィールドリンク を追加しました
説明文だけのアイテムを 作成することは 簡単ですが あまり便利とは言えません 優先順位を設定したり 作業量の見積もりを 追加する方法が 必要かもしれません そのために 詳細ビューを追加します その前に Watch上のSwiftUIにおける Appのナビゲーション構造の オプションについて 復習しておきたいと思います 階層型ナビゲーションは リストとディテールの関係性を持つ ビューに使用されます watchOS 9 からはSwiftUIの NavigationStackを使って このタイプのインターフェースを 作成します
ページベースの ナビゲーションは フラットな構造の ビューに使用されます すべてのビューが 同列であること
ページベースの ナビゲーションとして良い例が ワークアウトAppの ワークアウト中のビューで スワイプしやすい場所に ワークアウトコントロール メトリクス 再生コントロールがあります
フルスクリーンAppは 1つのビューを持ち ディスプレイ全体を 使ってそれを表示します これは一般的に ゲームなどのAppに 使われます
フルスクリーン表示の場合 ディスプレイの端まで コンテンツを拡張するために ignoresSafeAreaモディファイアを 使用し ナビゲーション バーを隠すために visibility値をhiddenにした toolbarモディファイアを使用します
モーダルシートは 現在のビューの上に スライドして表示される 全画面のビューです 重要なタスクに 使用されるべきです ワークフローの一部として 完了する必要があるものなどです
差別化することが大切です 階層的なフローと モーダルなシートを
使い分けることが重要です メールでは階層型を採用しており 各メッセージやスレッドを 詳細ビューで表示します メッセージの詳細から できるアクションがあります ただし リストに戻る前に しなければならないことは 何もありません
リストに戻り新規メッセージを タップした場合 メールではモーダルシートを 使って表示します モーダルなシートは 正しい選択です 続けるには 新しいメッセージの 詳細を入力したり キャンセルしたりする 必要があるからです
モーダルシートを 表示させるには シートのプレゼンテーション状態を 制御するプロパティを作成します ユーザーインターフェースの 操作に基づき プロパティを設定し sheetモディファイアを使用して プレゼンテーション状態 プロパティがtrueのときに カスタムモーダルシートの コンテンツを表示します
モーダルシートにカスタム ツールバーを加えるには ツールバーにアイテムを追加します ツールバーのアイテムは confirmationAction cancelActionなどの モーダルプレースメントを 使用する必要があることに 注意してください
アイテムを編集しているため この1つのタスクに 集中したいので 詳細ビューには モーダルシートを使い 終了して完了を タップします
NavigationStackと プログラムの詳細を含む SwiftUIのナビゲーションに ついてもっと学ぶには "ナビゲーションの SwiftUIレシピ" を チェックしてください
これで 詳細ビューへの ナビゲート方法が決まりました リストアイテムの 構造体を更新します 作成日 完了日を 新しいプロパティとして 追加しました
これらの詳細を表示 編集できるようにしましょう
説明文を編集するための 詳細ビューを作成します タスクが完了したかどうかを マークするトグルがあります しかし どうすれば いいのでしょうか? 値はすべて数字に なることが分かっています 有効な値の範囲を 指定することができます
watchOS 9から ステッパーを 使えばいいんです ステッパーは 素晴らしいオプションです きめ細かな制御を 提供したい場合
値の範囲を 指定することができます
ステッパーは 以下のような編集も可能です 必ずしも論理的に連続した 数値でなくてもよいのです 例えば 次のようなことを 書きたいかもしれません あるアイテムの 推定ストレスレベル
ストレスレベルを示す 絵文字の配列を作成し ステッパーを作成して レベルの絵文字配列の 選択されたインデックスに 値をバインドし 絵文字インデックスの 範囲に対し 範囲設定することができます 数値の段階的な 増加または減少 ストレスレベルを 推定しています
WWDCのセッションを 準備するのも楽しいですが Watch App開発を共有するのは とても楽しいことです ストレスになるアイテムが あるとき あるいはリストにたくさんの アイテムがありストレスを 感じているとき リストのアイテムを友人に シェアして助けを 求めたいと思うのです
詳細ビューにボタンを追加して シートを使って アイテムを 共有できるようにします 詳細ビューでボタンを タップしてアイテムを共有し リストから友達を選び 助けを求め メッセージを編集して 送信できるようにしたいです
これを実現するために 新しいツールが watchOS 9のSwiftUIで 利用できるようになりました ShareLinkです リストアイテムを 共有するには オプションで メッセージの初期テキストを カスタマイズすることが できます そして 共有シートで プレビューを提供します 誰かがそのアイテムを シェアしたとき ShareLinkを使って SwiftUI Appから iOS macOS watchOSで 共有することができます
ShareLinkの詳細と オプションについては "Transferableの紹介"を ぜひご覧ください いつアイテムを完了したかを 記録したり 物事を終わらせるために 助けを呼んだりすることが できますが 自分の生産性を見るための チャートも追加したいと 思っています 棒グラフを選んだのは データ系列が1つで データ値が明確だからです 異なるデータ値を 持っているからです 棒グラフは一度に表示する データ量を制限すれば Watchのディスプレイで データを明確に 表示することができます チャートビューを Appの ナビゲーション構造に 追加しました ページベースの ナビゲーション戦略を 選択したのは アイテムリストとチャートの間に リストと詳細の関係がない からです 誰かがリストの間を スワイプすると いつでもチャートが 表示されます
リストとグラフに ページベースの ナビゲーションを追加します まず ItemList構造体を 作成し リストビューを カプセル化します
コンテンツビューの コンテンツ全体を この新しいアイテム リストに追加しました ここでアイテムリストを カプセル化することで コンテンツビューの中に シンプルで 読みやすいタブビューの コードを持てます
一時的にプレース ホルダを入れ チャートを作成する前に ナビゲーション構造に 集中します
コンテンツビューを 設定します アイテムリストとチャートという 2つのタブを持つ ページスタイルの タブビューで表示しています
ナビゲーション構造を 設定したので このチャートをどのように 構築するか説明します SwiftUI Canvasを 使ってチャートを 描くことができるのは 分かりましたが watchOS 9から もっと簡単な答えがあります Swift Chartsです Swift ChartsはiOSでも macOS tvOSでも利用できます これでSwiftUIを使っているところで チャートを再利用できます
チャート化したいデータを 集計してみます Swift Chartsに それを表示させます
完了したアイテムの数を 日付ごとに表示します グラフの集計データを 格納するために
構造体を作り リストアイテムのデータを 集計し チャートの データ要素に変換するための 小さなメソッドを書きます
表示するデータを指定して 簡単なグラフを表示します そして データから系列を 定義します 日付をx値として使い 完了したアイテムの数を Y値とします
Watchに表示させるために ChartのchartXAxis モディファイアで X軸をカスタマイズしています 軸の値ラベルの書式を 指定しています また 縦方向の グリッドラインもいらないので AxisGridLineマークを 省略しました また Y軸もchartYAxisモディファイアで カスタマイズします グリッドラインスタイルを Watchに合うよう指定しています 軸の値ラベルを整数値で フォーマットし 一番上のラベルを 省略しています グラフの上端で 切り取れないよう削除しています Swift Chartsで実現できる 素晴らしいことを もっと知るには "Swift Chartsの紹介" と "Swift Charts: より高い レベルへ" をご覧ください
我々のチャートは かなり良さそうです もう少しデータを 表示させたいと思います 素晴らしいWatch体験は そのままに スクロール できるようにします これを実現するために デジタルクラウンイベントの コールバックを設定できる 新しいdigitalCrownRotation モディファイアを使用し デジタルクラウン イベントのため チャートの カスタムスクロールの 動作を実装するつもりです
digitalCrownRotation モディファイアを 追加するために グラフをスクロール時の 状態を保存するプロパティを 追加して準備しましょう highlightedDateIndexは データポイントの インデックスを 現在のスクロール位置とし
クラウンオフセットを 格納します 現在のクラウンの位置に スクロールさせながら 表示します データポイント上 またはデータポイント間の 中間値であり クラウンが動いている間です
誰かがクロールしているか どうかを記録するために アイドル状態を保存します この情報を利用して クラウンのスクロールが 止まったり始まったりした時の アニメーションを追加する
値を格納するための プロパティを作成しました digitalCrownRotation モディファイアを追加しました
highlightedDateIndex プロパティにバインドします
機械用語で 戻り止めとは あるものを動かすのに 十分な力が加わるまで その位置を保持する 機構のことです 例えば 車のドアを 開けるとき 停止位置というものが あります もうちょっと頑張って ドアを大きく開けて もう一つの停止位置へ 閉じるには 強く引っ張る必要があります 抵抗に打ち勝つために 引き出すことができます そうしないと 元の位置に 戻ってしまいます それが戻り止めです まさに 車のドアの停止位置が APIでの戻り止めを 理解することに役立ちます 戻り止めとは ビュー上のクラウンの 静止しているノッチ位置です
onChangeコールバックの ハンドラで isCrownIdleの値を falseにします スクロールして いることがわかるので crownOffset値を を現在の値に設定します スクロール中にチャート上の 現在位置を知らせます
onIdleコールバックの ハンドラで isCrownIdleの値を trueにします
クラウンの位置を チャートのスクロールに従って 表示することができます Swift Chartsの RuleMarkを使用します RuleMarkは チャート上の直線です 水平または垂直な線を 表示するために使用します 閾値を表示させることが できます 傾斜した線を表示させたり できます
クラウンオフセットの 日付の値で RuleMarkを作成します 現在の位置が表示されます
少しだけ 見栄えを良くするために クラウン位置の 線が薄くなるように クラウンが動かなくなった時 線がフェードするようにしたいです 追加したisCrownIdle プロパティを使用することで 簡単にアニメーションできます
使用する色の不透明度を 格納するプロパティを追加し それをRuleMarkのforeground Styleに追加しています
チャートにonChange モディファイアを追加し isCrownIdleの値が変わった時の crownPositionOpacityの値の変更の アニメーションを行います
そしてRuleMarkのforegroundStyleを opacityを使って更新強います
チャートの棒の横に 値を表示するために BarMarkにアノテーションを 追加することができます 最後のバーになる時に アノテーションのバーを 先頭側に配置します それ以外の場合は 上部の後ろ側に 配置します
digitalCrownRotation モディファイアと Swift ChartsのRuleMark SwiftUIのアニメーションだけで どんなことができたのかを 見てみましょう
スクロール可能なカスタム チャートを作成するための 最後のステップの データ範囲を調整します 可視範囲を格納する プロパティを作成します
データを提供するために chartData変数を作成します highlightedDateIndexが 変化したとき chartDataRangeを 確認するメソッドを呼び出し 必要であれば 更新してください
デジタルクラウンで チャートをスクロールさせると チャートはスクロールし データが表示されます 予定していた機能の 実装がすべて終了です
watchOS 9で 利用ができる SwiftUIの新機能について もっと詳しく知りたい方は "SwiftUIの最新情報"を チェックしてみてください Watch Appや 新しい機能などの 企画を進めるときには 素晴らしいWatch App体験 とは何か について考えてみてください Appをデザインするときに Appのナビゲーション戦略を 考え Appが簡単で直感的で になるようにします よりシンプルで豊富な開発オプション としてSwiftUIを活用します 今後も素晴らしいWatch Appを作り続けてください そして思い出してください あなたのおかげで Appがあるのです!
-
-
6:12 - Initial ListItem struct
struct ListItem: Identifiable, Hashable { let id = UUID() var description: String init(_ description: String) { self.description = description } }
-
6:24 - ItemListModel
class ItemListModel: NSObject, ObservableObject { @Published var items = [ListItem]() }
-
6:30 - Add the ItemListModel as an EnvironmentObject
@main struct WatchTaskListSampleApp: App { @StateObject var itemListModel = ItemListModel() @SceneBuilder var body: some Scene { WindowGroup { ContentView() .environmentObject(itemListModel) } } }
-
6:37 - Create a simple SwiftUI List
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { List { ForEach($model.items) { $item in ItemRow(item: $item) } if model.items.isEmpty { Text("No items to do!") .foregroundStyle(.gray) } } .navigationTitle("Tasks") } }
-
7:11 - TextFieldLink with a simple String
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink("Add") { model.items.append(ListItem($0)) } } .navigationTitle("Tasks") } }
-
7:16 - TextFieldLink with a Label
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink { Label( "Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } } .navigationTitle("Tasks") } }
-
7:20 - TextFieldLink with foregroundStyle modifier
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink { Label( "Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } .foregroundStyle(.tint) } .navigationTitle("Tasks") } }
-
7:27 - TextFieldLink with buttonStyle modifier
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink { Label( "Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } .buttonStyle(.borderedProminent) } .navigationTitle("Tasks") } }
-
struct AddItemLink: View { @EnvironmentObject private var model: ItemListModel var body: some View { TextFieldLink(prompt: Text("New Item")) { Label("Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } } }
-
8:38 - Add a toolbar item to allow people to add new list items
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { List { ForEach($model.items) { $item in ItemRow(item: $item) } if model.items.isEmpty { Text("No items to do!") .foregroundStyle(.gray) } } .toolbar { AddItemLink() } .navigationTitle("Tasks") } }
-
11:40 - Display a modal sheet
struct ItemRow: View { @EnvironmentObject private var model: ItemListModel @Binding var item: ListItem @State private var showDetail = false var body: some View { Button { showDetail = true } label: { HStack { Text(item.description) .strikethrough(item.isComplete) Spacer() Image(systemName: "checkmark").opacity(item.isComplete ? 100 : 0) } } .sheet(isPresented: $showDetail) { ItemDetail(item: $item) } } }
-
11:58 - Display a modal sheet with custom toolbar items
struct ItemRow: View { @EnvironmentObject private var model: ItemListModel @Binding var item: ListItem @State private var showDetail = false var body: some View { Button { showDetail = true } label: { HStack { Text(item.description) .strikethrough(item.isComplete) Spacer() Image(systemName: "checkmark").opacity(item.isComplete ? 100 : 0) } } .sheet(isPresented: $showDetail) { ItemDetail(item: $item) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showDetail = false } } } } } }
-
12:36 - Add more properties to the ListItem
struct ListItem: Identifiable, Hashable { let id = UUID() var description: String var estimatedWork: Double = 1.0 var creationDate = Date() var completionDate: Date? init(_ description: String) { self.description = description } var isComplete: Bool { get { completionDate != nil } set { if newValue { guard completionDate == nil else { return } completionDate = Date() } else { completionDate = nil } } } }
-
12:48 - Create the ItemDetail View with the Stepper
struct ItemDetail: View { @Binding var item: ListItem var body: some View { Form { Section("List Item") { TextField("Item", text: $item.description, prompt: Text("List Item")) } Section("Estimated Work") { Stepper(value: $item.estimatedWork, in: (0.0...14.0), step: 0.5, format: .number) { Text("\(item.estimatedWork, specifier: "%.1f") days") } } Toggle(isOn: $item.isComplete) { Text("Completed") } } } }
-
13:29 - A Stepper with Emoji
// Use a Stepper to edit the stress level of an item struct StressStepper: View { private let stressLevels = [ "😱", "😡", "😳", "🙁", "🫤", "🙂", "🥳" ] @State private var stressLevelIndex = 5 var body: some View { VStack { Text("Stress Level") .font(.system(.footnote, weight: .bold)) .foregroundStyle(.tint) Stepper(value: $stressLevelIndex, in: (0...stressLevels.count-1)) { Text(stressLevels[stressLevelIndex]) } } } }
-
14:43 - Add a ShareLink to the ItemDetail View
struct ItemDetail: View { @Binding var item: ListItem var body: some View { Form { Section("List Item") { TextField("Item", text: $item.description, prompt: Text("List Item")) } Section("Estimated Work") { Stepper(value: $item.estimatedWork, in: (0.0...14.0), step: 0.5, format: .number) { Text("\(item.estimatedWork, specifier: "%.1f") days") } } Toggle(isOn: $item.isComplete) { Text("Completed") } ShareLink(item: item.description, subject: Text("Please help!"), message: Text("(I need some help finishing this.)"), preview: SharePreview("\(item.description)")) .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle) .listRowInsets( EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) ) } } }
-
16:39 - Page-style TabView with navigation titles for each page
struct ContentView: View { var body: some View { TabView { NavigationStack { ItemList() } NavigationStack { ProductivityChart() } }.tabViewStyle(.page) } }
-
17:20 - ChartData struct for aggregate data
/// Aggregate data for charting productivity. struct ChartData { struct DataElement: Identifiable { var id: Date { return date } let date: Date let itemsComplete: Double } /// Create aggregate chart data from list items. /// - Parameter items: An array of list items to aggregate for charting. /// - Returns: The chart data source. static func createData(_ items: [ListItem]) -> [DataElement] { return Dictionary(grouping: items, by: \.completionDate) .compactMap { guard let date = $0 else { return nil } return DataElement(date: date, itemsComplete: Double($1.count)) } .sorted { $0.date < $1.date } } }
-
17:36 - Static sample data for chart and basic bar chart
extension ChartData { /// Some static sample data for displaying a `Chart`. static var chartSampleData: [DataElement] { let calendar = Calendar.autoupdatingCurrent var startDateComponents = calendar.dateComponents( [.year, .month, .day], from: Date()) startDateComponents.setValue(22, for: .day) startDateComponents.setValue(5, for: .month) startDateComponents.setValue(2022, for: .year) startDateComponents.setValue(0, for: .hour) startDateComponents.setValue(0, for: .minute) startDateComponents.setValue(0, for: .second) let startDate = calendar.date(from: startDateComponents)! let itemsToAdd = [ 6, 3, 1, 4, 1, 2, 7, 5, 2, 0, 5, 2, 3, 9 ] var items = [DataElement]() for dayOffset in (0..<itemsToAdd.count) { items.append(DataElement( date: calendar.date(byAdding: .day, value: dayOffset, to: startDate)!, itemsComplete: Double(itemsToAdd[dayOffset]))) } return items } } struct ProductivityChart: View { let data = ChartData.createData( ListItem.chartSampleData) var body: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } }
-
17:50 - Chart with chartXAxis modifier
struct ProductivityChart: View { let data = ChartData.createData( ListItem.chartSampleData) private var shortDateFormatStyle = DateFormatStyle(dateFormatTemplate: "Md") var body: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } } /// `ProductivityChart` uses this type to format the dates on the x-axis. struct DateFormatStyle: FormatStyle { enum CodingKeys: CodingKey { case dateFormatTemplate } private var dateFormatTemplate: String private var formatter: DateFormatter init(dateFormatTemplate: String) { self.dateFormatTemplate = dateFormatTemplate formatter = DateFormatter() formatter.locale = Locale.autoupdatingCurrent formatter.setLocalizedDateFormatFromTemplate(dateFormatTemplate) } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) dateFormatTemplate = try container.decode(String.self, forKey: .dateFormatTemplate) formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate(dateFormatTemplate) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(dateFormatTemplate, forKey: .dateFormatTemplate) } func format(_ value: Date) -> String { formatter.string(from: value) } }
-
19:05 - Add the digitalCrownRotation modifier
struct ProductivityChart: View { let data = ChartData.createData( ListItem.chartSampleData) /// The index of the highlighted chart value. This is for crown scrolling. @State private var highlightedDateIndex: Int = 0 /// The current offset of the crown while it's rotating. This sample sets the offset with /// the value in the DigitalCrownEvent and uses it to show an intermediate /// (between detents) chart value in the view. @State private var crownOffset: Double = 0.0 @State private var isCrownIdle = true private var chart: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } var body: some View { chart .focusable() .digitalCrownRotation( detent: $highlightedDateIndex, from: 0, through: data.count - 1, by: 1, sensitivity: .medium ) { crownEvent in isCrownIdle = false crownOffset = crownEvent.offset } onIdle: { isCrownIdle = true } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } }
-
21:07 - Add a RuleMark to the Chart to show the current Digital Crown position
/// The date value that corresponds to the crown offset. private var crownOffsetDate: Date { let dateDistance = data[0].date.distance( to: data[data.count - 1].date) * (crownOffset / Double(data.count - 1)) return data[0].date.addingTimeInterval(dateDistance) } private var chart: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( "Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) RuleMark(x: .value("Date", crownOffsetDate)) .foregroundStyle(Color.appYellow) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } }
-
21:37 - Add animation to dim the crown position line when the scrolling idle state changes
struct ProductivityChart: View { let data = ChartData.createData( ListItem.chartSampleData) /// The index of the highlighted chart value. This is for crown scrolling. @State private var highlightedDateIndex: Int = 0 /// The current offset of the crown while it's rotating. This sample sets the offset with /// the value in the DigitalCrownEvent and uses it to show an intermediate /// (between detents) chart value in the view. @State private var crownOffset: Double = 0.0 @State private var isCrownIdle = true @State var crownPositionOpacity: CGFloat = 0.2 private var chart: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) RuleMark(x: .value("Date", crownOffsetDate)) .foregroundStyle(Color.appYellow.opacity(crownPositionOpacity)) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } var body: some View { chart .focusable() .digitalCrownRotation( detent: $highlightedDateIndex, from: 0, through: data.count - 1, by: 1, sensitivity: .medium ) { crownEvent in isCrownIdle = false crownOffset = crownEvent.offset } onIdle: { isCrownIdle = true } .onChange(of: isCrownIdle) { newValue in withAnimation(newValue ? .easeOut : .easeIn) { crownPositionOpacity = newValue ? 0.2 : 1.0 } } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } }
-
22:14 - Add an annotation to the bar chart to display the current value
private func isLastDataPoint(_ dataPoint: ChartData.DataElement) -> Bool { data[chartDataRange.upperBound].id == dataPoint.id } private var chart: some View { Chart(chartData) { dataPoint in BarMark(x: .value("Date", dataPoint.date, unit: .day), y: .value("Completed", dataPoint.itemsComplete)) .foregroundStyle(Color.accentColor) .annotation( position: isLastDataPoint(dataPoint) ? .topLeading : .topTrailing, spacing: 0 ) { Text("\(dataPoint.itemsComplete, format: .number)") .foregroundStyle(dataPoint.date == crownOffsetDate ? Color.appYellow : Color.clear) } RuleMark(x: .value("Date", crownOffsetDate, unit: .day)) .foregroundStyle(Color.appYellow.opacity(crownPositionOpacity)) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } }
-
22:44 - Make the chart data range scrollable
@State var chartDataRange = (0...6) private func updateChartDataRange() { if (highlightedDateIndex - chartDataRange.lowerBound) < 2, chartDataRange.lowerBound > 0 { let newLowerBound = max(0, chartDataRange.lowerBound - 1) let newUpperBound = min(newLowerBound + 6, data.count - 1) chartDataRange = (newLowerBound...newUpperBound) return } if (chartDataRange.upperBound - highlightedDateIndex) < 2, chartDataRange.upperBound < data.count - 1 { let newUpperBound = min(chartDataRange.upperBound + 1, data.count - 1) let newLowerBound = max(0, newUpperBound - 6) chartDataRange = (newLowerBound...newUpperBound) return } } private var chartData: [ChartData.DataElement] { Array(data[chartDataRange.clamped(to: (0...data.count - 1))]) } private var chart: some View { Chart(chartData) { dataPoint in BarMark(x: .value("Date", dataPoint.date, unit: .day), y: .value("Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) .annotation( position: isLastDataPoint(dataPoint) ? .topLeading : .topTrailing, spacing: 0 ) { Text("\(dataPoint.itemsComplete, format: .number)") .foregroundStyle(dataPoint.date == crownOffsetDate ? Color.appYellow : Color.clear) } RuleMark(x: .value("Date", crownOffsetDate, unit: .day)) .foregroundStyle(Color.appYellow.opacity(crownPositionOpacity)) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } var body: some View { chart .focusable() .digitalCrownRotation( detent: $highlightedDateIndex, from: 0, through: data.count - 1, by: 1, sensitivity: .medium ) { crownEvent in isCrownIdle = false crownOffset = crownEvent.offset } onIdle: { isCrownIdle = true } .onChange(of: isCrownIdle) { newValue in withAnimation(newValue ? .easeOut : .easeIn) { crownPositionOpacity = newValue ? 0.2 : 1.0 } } .onChange(of: highlightedDateIndex) { newValue in withAnimation { updateChartDataRange() } } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。