ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
Apple Watchのスマートスタック向けウィジェットの構築
最新のSwiftUIとWidgetKit APIを使用して、watchOS 10のスマートスタック向けウィジェットを作成する過程を紹介します。Apple Watchで関連情報を表示するウィジェットの作成に関するヒント、テクニック、ベストプラクティスをご確認ください。
関連する章
- 0:58 - Get started
- 2:03 - Configure the widget
- 3:51 - Set up the timeline
- 10:16 - Build widget views
- 16:27 - Finish building timeline
- 19:58 - Provide relevant intents
リソース
関連ビデオ
WWDC23
-
ダウンロード
♪ ♪
Calvin:こんにちは Calvin Gaisfordです watchOS teamのエンジニアです 今回のcode-alongは Apple Watchの 新しいスマートスタック用 ウィジェットを作成します AppIntent構成を使い ウィジェットを ビルドする全てのプロセスを説明します その過程で WidgetKitとSwiftUIの 最新アップデートを使用します
code-alongのために Backyard Birdsアプリを使用します Backyard Birdsは 野鳥が集まる裏庭を 作成・管理するアプリです ウィジェットは裏庭の状態を表示します 鳥が訪れると 鳥が表示され 裏庭の状態も表示されます ウィジェットは スマートスタックに関連する日付を提供し 最も関連性の高い時に ウィジェットを優先させることができます このセッションで使用するサンプルコードを ダウンロードして Backyard Birdsの Xcodeプロジェクトを開いてみてください すでにプロジェクトに Widget Extensionを追加し BackyardVisitorsWidgetを含む 複数のファイルを生成しました ほとんどの時間 このファイルの更新作業を行います 内容は以下の通りです まず ウィジェットを定義する ウィジェット構造を見ていきます そして ウィジェット構成インテントの 概要を説明します 次に ウィジェットビューの データを保持する TimelineEntry構造をカスタマイズし タイムラインを構築します プレビューを有効にするために 必要なデータがタイムラインにそろったら ウィジェットのビューを構築します ビューが構築されたら タイムラインに戻り 仕上げを行います 最後にRelevant Intent Managerを見て watchOSスマートスタック上で ウィジェットを優先する 日付のインテントを設定します では コードでウィジェット構成を確認し ウィジェットの設定を始めましょう
ウィジェットの設定は ウィジェット構成で定義されます AppIntentConfigurationsが 新たにwatchOSに実装されました AppIntentConfigurationを ウィジェットに使用します 構成インテント プロバイダ ビューはすべて WidgetExtensionの作成時に 無効になっています それぞれを確認して Backyard Birdsウィジェットに実装します ウィジェットの定義は良さそうなので 次に WidgetConfigurationIntentを 見てみましょう ウィジェットは App Intent Configurationを使用して 2つのことができます まずウィジェットは watchOSウィジェットギャラリーで 設定済みのウィジェットの セットを提供できます Backyard Birdsの場合 アプリ内の各庭の設定を提供できます 次に WidgetConfigurationIntentを 使用して ウィジェットに 最も関連のある日付を指定できます スマートスタックはこの情報を使用して スマートスタック内のウィジェットを 優先順位付けします
ウィジェットのConfiguration App Intentを見てみましょう Backyard Birdsの 各庭には固有のIDがあります すでにbackyardIDという パラメータを追加したので これを使用して一連の ウィジェットのインテントを作成し 庭ごとにbackyard IDで識別可能です 今回作成するウィジェットに 必要なパラメータは以上です Appインテントの詳細と WidgetConfigurationIntentの 使い方については Appインテント関連の セッションをご覧ください ウィジェット構成が定義され WidgetConfigurationIntentが backyard IDを保持できるようになりました ウィジェットタイムラインに移動して TimelineEntry構造体を見てみましょう TimelineEntry構造体には ウィジェットビューが特定の日付に レンダリングを行うのに 必要なデータが格納されます BackyardVisitorsWidgetファイルに戻り 生成された SimpleEntry構造体を見てみましょう
このファイルが生成された時に 日付と設定のプロパティが追加されました ウィジェットビューに必要な 追加のプロパティを定義する必要があります ウィジェットは 裏庭の状態 名前 エサ 水の状態を表示します 鳥が訪問している場合は その鳥と鳥の名前が表示されます 鳥がいなければ 訪問した鳥の数を表示します 庭に関する情報を表示するために ある時点での裏庭に関する すべての情報を保持する Backyard Birdsアプリの 構造体を使用します TimelineEntryには 未来の日付が含まれる 可能性があるため これは重要です TimelineEntryに 裏庭のプロパティを追加しましょう
先ほど追加した 裏庭のプロパティに基づいて いくつかの計算プロパティを追加しましょう まず 鳥のプロパティを追加して ウィジェットのビューで 鳥が訪れているかをチェックして 表示可能にします
では 庭の情報を表示するために ビューで使用する プロパティを2つ追加しましょう
waterDurationと foodDurationはビューで使用され 水とエサの使用期限を表示します これは TimelineEntryの dateプロパティから 計算されます
TimelineEntryには relevanceというプロパティもあり 実装すると どのタイムラインエントリが 最も重要かをwatchOS スマートスタックに伝えられます relevanceプロパティを TimelineEntryに追加しましょう
その中で TimelineEntryの日付に裏庭に 鳥がいるかをチェックしましょう
鳥がいれば TimelineEntryRelevance 構造体を返します
TimelineEntryRelevance構造体には scoreとdurationの 2つのパラメータがあります scoreは同じタイムラインの 他のエントリに対して 優先順位をつけるために使われます 鳥がいるエントリのscoreを10にして 鳥がいないエントリより高く設定します この値は任意で タイムラインのエントリのランク付けに 必要な範囲の値を付けられます durationは この関連エントリの 有効期限を スマートスタックに伝えるために使用します ここでは 来訪した鳥のendDateに設定します 鳥がいなければ Relevance構造体は ゼロのscoreを返すようにします
これでスマートスタックに どのタイムライン エントリが最も重要かを伝えられます その時点で起きている事象により watchOSスマートスタック上で ウィジェットの優先順位が上がります
ウィジェットビューが 適切にレンダリングするために 必要なものがすべてそろいました 次に TimelineProviderを用意しましょう TimelineProviderには 定義する必要のある関数が placeholder snapshot timeline recommendationsの4つあります placeholder関数は ウィジェットを初めて表示する際 素早く戻る必要がある時に使用されます TimelineEntryはbackyardの値を取るので 定義が必要です アプリのデータモデルから ランダムな backyardを追加して修正します
良いですね 次に行きます snapshot関数は ウィジェットが 一時的な状況にある時に使われます この関数は 素早く返す必要があるので フェッチするのに 数秒以上掛からなければ サンプルデータを使っても問題ありません placeholder関数の時と同様に ランダムなbackyardを渡します
良いですが もっと良い方法があります snapshot関数には 先ほど追加した backyardIDプロパティを持つ configurationインテントが渡されます すべてのデータはローカルなので ランダムなbackyardを 使う代わりに 適切なbackyardを すぐに調べて返すことができます 設定のbackyardIDから 設定されているbackyardを取得します
次に backyardをチェックして そこから visitorEventを取得できるか確認します
鳥の訪問日で設定されたエントリを返し 鳥がいなければ 現在の日付に設定された backyardを返します
これにより 設定された backyardが表示されるため より良いプレビューとなります timeline関数の前に Xcodeキャンバスプレビューをオンにします SimpleEntryを修正して プレビュー できるようにbackyardを付与します
キャンバスをオンにします
Xcodeで新たに ウィジェットの タイムラインをプレビュー可能になりました キャンバスに長方形のウィジェットの プレビューが表示され 下部にウィジェットの タイムラインを構成する TimelineEntriesが表示されます キャンバスのプレビューは ウィジェット追加時に 生成された デフォルトの ビューを使用しています タイムラインプロバイダを完成させる前に ビューを構築して タイムラインを見ながら構築しましょう BackyardBirdsWidgetEntryViewを探します 各ファミリー専用の ビューを構築できるように widgetFamilyに Environmentプロパティを追加します
bodyをswitch文に置き換えて accessoryWidgetファミリーごとに ビューを実装できるようにします
accessoryRectangularのcaseを作成して エントリをパラメータとして受け取る ビューを作成します このビューを下に実装します
長方形のビューは watchOSスマートスタックに 表示される ウィジェット独自の ビューになります 長方形のビューで一般的なパターンに従い 左側に画像 右側にテキストを3行配置します ファイルの一番下に移動し RectangularBackyardViewを作成します
このビューでは 先程変更した TimelineEntryを使い 裏庭のデータを格納します 続ける前に キャンバスビューを Smart Stack Rectangular ビューに変更します
これによって ウィジェットを ビルドしながらビジュアライズできます ビューのHStackに 画像と3行のテキストを入力します
プレビューをご覧ください ちょっと違いますね テキストの行を VStackに入れます
だいぶ近づきました ビューにエントリの実際のデータを入れます まずBackyard Birdsアプリで鳥を表示する ComposedBirdビューを使います
鳥はオプショナルなので アンラップします ComposedBirdビューとVStackを if-letチェックに入れて 鳥がエントリに存在するか確認します
鳥がいなければ 庭の噴水の画像と 鳥の不在を示すテキストを入れます
タイムラインを確認し 鳥の存在を示す3行のテキストと 鳥の不在を示すエントリを見てみましょう
まず 鳥がいる場合の詳細を入力します 1行目には鳥の名前 2行目には裏庭の名前 3行目には庭のエサと 水に関する情報を追加します
鳥がいない場合は 庭の名前 エサと水の情報 この庭を訪れた 鳥の数を表示します
エントリを見てみましょう
良いですが 少しレイアウトを修正します まず ComposedBirdを更新します ビューをscaledToFit そしてwidgetAccentableにして 色付きの文字盤で表示した時に 色合いが変わるようにします 鳥の名前に.headlineフォントを追加し scaleにします 文字盤に合わせて widgetAccentableにします そして foregroundStyleを使用して テキストを鳥の翼の色にします
長い名前に備えて 他の両方のビューに ScaleFactorを追加します
最後の行のforegroundStyleを secondaryに設定します
最後に 3つのテキストビューを そろえるため スタックを先頭揃えにします
ビューの見た目は良さそうです 鳥がいない時のelse文のビューにも 同じ内容を適用します
ウィジェットの見た目が改善されました
ウィジェットが鳥を表示する時と 庭を表示する時で 間隔が異なっています 鳥のビューと画像ビューに フレームを追加して一致させましょう
VStacksにフレームを追加して 正しく合わせましょう
あと1つオプションを追加すれば watchOSの スマートスタックウィジェットは完成です SwiftUIに containerBackgroundが 新たに追加されました containerBackgroundに 裏庭のグラデーションを使用します containersBackgroundの配置を ウィジェットに設定します
containerBackgroundは システムによって選択的に使用され ここではwatchOSの スマートスタックにのみ表示され 文字盤には表示されません
これでwatchOSスマートスタックの ビューは完成です ビューは完璧です TimelineProviderに戻り タイムラインを完成させます
タイムライン関数は ウィジェットがビューを レンダリングするためのデータを含む 一連のタイムラインエントリを生成します これがウィジェットの主力となる関数です 現在 ランダムな裏庭のデータで 5つのエントリを生成しています これを 鳥でいっぱいの タイムラインに置き換えましょう 関数の先頭に TimelineEntriesの配列があります これを使ってタイムラインを構築します まず 生成された タイムラインのコードを削除します
次にConfigurationAppIntentから backyardIDを使って 設定した庭を取得します
backyardの構造体には その庭のすべての visitorEventsを含むプロパティがあります 取得した庭の visitorEventsを記述しましょう 各イベントについて visitorEventのstartDateを含む TimelineEntryを作成し 設定したbackyardを渡します
タイムラインのプレビューが更新されました 違いを見てみましょう タイプラインエントリを選択すると 鳥が現れます これは予想通りです しかし すべてのエントリに鳥がいます 鳥が去った時のエントリも 追加する必要があります 2つ目のエントリを作成し visitorEventのendDateを使いましょう 同じ裏庭を使い エントリを エントリの配列に追加します
タイムラインを見てみましょう
これで 鳥が来た時と去った時の エントリができました ウィジェットタイムラインの完成です 新しいプレビューは素晴らしいですね ウィジェットやタイムラインの作成が より簡単になります
最後に タイムラインプロバイダの recommendations関数を実装します ここでは backyardIDを保持する WidgetConfigurationIntentを含む AppIntentRecommendationsの 配列を返します デフォルトの実装を削除します
recommendationsを返す配列を作成します
次に アプリの各backyardに対して recommendationを作成したいので すべてのbackyardを繰り返し処理します
各backyardについて ConfigurationAppIntentを作成し backyardIDを設定します
最後に ConfigurationIntentを使用して AppIntentRecommendationを作成し 配列に追加します 裏庭の名前を説明文に使用します
Backyard Birdsウィジェットを選択すると recommendations関数が ウィジェットギャラリー内で 裏庭ごとに1つ ウィジェット設定の 一覧を提供するようになりました おめでとうございます これで 文字盤の コンプリケーションと watchOSスマートスタックとして表示される ウィジェットが出来ました 先ほど TimelineEntryに relevanceプロパティを実装した時に 関連性の話をしましたが さらにできることがあります Backyard Birdsアプリの各庭は 鳥が利用できる 水とエサを記録しています 新しいウィジェットにも その情報が表示されています 水やエサの不足が分かった時に 関連するインテントの一覧を システムに提供することができます これらの状況でウィジェットを 優先的に表示させ 庭に注意を払うよう知らせることができます
コードに戻って 必要なウィジェットに関連する インテントを生成し RelevantIntentManagerに インテントを追加する 新しい関数を作成します updateBackyardRelevantIntentsという 新しい関数を作成します
この関数に relevantIntentsの配列を追加します
その配列で RelevantIntentManagerを更新します
relevantIntents配列に入力するため アプリ内のすべての裏庭をループします 次に 裏庭の configurationIntentを作成し 現在の裏庭にbackyardIDを設定します 日付に基づいて RelevantContextを作成します この場合 将来的に 裏庭のエサが少なくなる日付と 空になる日付を使用します
最後に relevantIntentを作成します ウィジェットのconfigurationIntent ウィジェットの種類 先ほど作成した relevantDateContextを使い 配列に追加します
次に 裏庭のlowWaterとemptyWaterの日付も 同じようにします
良さそうですね これでRelevantIntentManagerに 各ウィジェットがより高い関連性を 持つ可能性のある日付が設定されました この関数を主要な コンポーネントに追加して 適切な時にrelevantIntentsが 更新されるようにします まず タイムラインプロバイダの timeline関数に戻ります timelineを返す直前に関数を呼び出します
これで ウィジェットのタイムラインが 更新されるたびに relevantIntentsを 最新の状態に保つことができます Backyard Birdsアプリを見てみましょう Backyard Birdsアプリには 各庭の詳細が表示され エサと水を補充する ページが用意されています エサと水の供給量は変化するため relevantIntentsの更新に最適な場所です BackyardContentTabに Refillボタンがタップされた時に updateBackyardRelevantIntentsを持つ Taskを追加します エサと水が更新されたことが分かったので WidgetKitにコールを行い ウィジェットの タイムラインをリロードします
関連するインテントが更新されました 庭で水やエサが補充されると ウィジェットのタイムラインが 再読み込みされます
watchOSスマートスタックが ビルドされました RelevantIntentManagerを更新して 最も関連度の高い時にウィジェットの 優先度を上げるdateインテントを追加しました ご視聴ありがとうございました 皆さんの watchOSスマートスタック用 ウィジェットを見るのが楽しみです ウィジェットやスマートスタック Appインテントの詳細については 以下のセッションをご覧ください 今後も冒険心を忘れず コーディングを続けましょう
-
-
4:15 - TimelineEntry
struct SimpleEntry: TimelineEntry { var date: Date var configuration: ConfigurationAppIntent var backyard: Backyard var bird: Bird? { return backyard.visitorEventForDate(date: date)?.bird } var waterDuration: Duration { return Duration.seconds(abs(self.date.distance(to: self.backyard.waterRefillDate))) } var foodDuration: Duration { return Duration.seconds(abs(self.date.distance(to: self.backyard.foodRefillDate))) } var relevance: TimelineEntryRelevance? { if let visitor = backyard.visitorEventForDate(date: date) { return TimelineEntryRelevance(score: 10, duration: visitor.endDate.timeIntervalSince(date)) } return TimelineEntryRelevance(score: 0) } }
-
7:50 - placeholder function
func placeholder(in context: Context) -> SimpleEntry { return SimpleEntry(date: Date(), configuration: ConfigurationAppIntent(), backyard: Backyard.anyBackyard(modelContext: modelContext)) }
-
8:15 - snapshot function
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { if let backyard = Backyard.backyardForID(modelContext: modelContext, backyardID: configuration.backyardID) { if let event = backyard.visitorEvents.first { return SimpleEntry(date: event.startDate, configuration: configuration, backyard: backyard) } else { return SimpleEntry(date: Date(), configuration: configuration, backyard: backyard) } } let yard = Backyard.anyBackyard(modelContext: modelContext) return SimpleEntry(date: Date(), configuration: ConfigurationAppIntent(), backyard: yard) }
-
10:26 - Widget Entry View
struct BackyardBirdsWidgetEntryView: View { @Environment(\.widgetFamily) private var family var entry: SimpleEntry var body: some View { switch family { case .accessoryRectangular: RectangularBackyardView(entry: entry) default: Text(entry.date, style: .time) } } }
-
11:23 - Backyard Rectangular View
struct RectangularBackyardView: View { var entry: SimpleEntry var body: some View { HStack { if let bird = entry.bird { ComposedBird(bird: bird) .scaledToFit() .widgetAccentable() .frame(width: 50, height: 50) VStack(alignment: .leading) { Text(bird.speciesName) .font(.headline) .foregroundStyle(bird.colors.wing.color) .widgetAccentable() .minimumScaleFactor(0.75) Text(entry.backyard.name) .minimumScaleFactor(0.75) HStack { Image(systemName: "drop.fill") Text(entry.waterDuration, format: remainingHoursFormatter) Image(systemName: "fork.knife") Text(entry.foodDuration, format: remainingHoursFormatter) } .imageScale(.small) .minimumScaleFactor(0.75) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) } else { Image(.fountainFill) .foregroundStyle(entry.backyard.backgroundColor) .imageScale(.large) .scaledToFit() .widgetAccentable() .frame(width: 50, height: 50) VStack(alignment: .leading) { Text(entry.backyard.name) .font(.headline) .foregroundStyle(entry.backyard.backgroundColor) .widgetAccentable() .minimumScaleFactor(0.75) HStack { Image(systemName: "drop.fill") Text(entry.waterDuration, format: remainingHoursFormatter) Image(systemName: "fork.knife") Text(entry.foodDuration, format: remainingHoursFormatter) } .imageScale(.small) .minimumScaleFactor(0.75) Text("\(entry.backyard.historicalEvents.count) visitors") .minimumScaleFactor(0.75) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) } } .containerBackground(entry.backyard.backgroundColor.gradient, for: .widget) } }
-
16:30 - Timeline Function
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> { var entries: [SimpleEntry] = [] if let backyard = Backyard.backyardForID(modelContext: modelContext, backyardID: configuration.backyardID) { for event in backyard.visitorEvents { let entry = SimpleEntry(date: event.startDate, configuration: configuration, backyard: backyard) entries.append(entry) let afterEntry = SimpleEntry(date: event.endDate, configuration: configuration, backyard: backyard) entries.append(afterEntry) } } return Timeline(entries: entries, policy: .atEnd) }
-
18:35 - Recommendations Function
func recommendations() -> [AppIntentRecommendation<ConfigurationAppIntent>] { var recs = [AppIntentRecommendation<ConfigurationAppIntent>]() for backyard in Backyard.allBackyards(modelContext: modelContext) { let configIntent = ConfigurationAppIntent() configIntent.backyardID = backyard.id.uuidString let gardenRecommendation = AppIntentRecommendation(intent: configIntent, description: backyard.name) recs.append(gardenRecommendation) } return recs }
-
20:47 - Relevant Intents Function
func updateBackyardRelevantIntents() async { let modelContext = ModelContext(DataGeneration.container) var relevantIntents = [RelevantIntent]() for backyard in Backyard.allBackyards(modelContext: modelContext) { let configIntent = ConfigurationAppIntent() configIntent.backyardID = backyard.id.uuidString let relevantFoodDateContext = RelevantContext.date(from: backyard.lowSuppliesDate(for: .food), to: backyard.expectedEmptyDate(for: .food)) let relevantFoodIntent = RelevantIntent(configIntent, widgetKind: "BackyardVisitorsWidget", relevance: relevantFoodDateContext) relevantIntents.append(relevantFoodIntent) let relevantWaterDateContext = RelevantContext.date(from: backyard.lowSuppliesDate(for: .water), to: backyard.expectedEmptyDate(for: .water)) let relevantWaterIntent = RelevantIntent(configIntent, widgetKind: "BackyardVisitorsWidget", relevance: relevantWaterDateContext) relevantIntents.append(relevantWaterIntent) } do { try await RelevantIntentManager.shared.updateRelevantIntents(relevantIntents) } catch { } }
-
23:00 - Update Relevant Intents
Task { await updateBackyardRelevantIntents() WidgetCenter.shared.reloadTimelines(ofKind: "BackyardVisitorsWidget") }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。