every Tech Blog

株式会社エブリーのTech Blogです。

iOS版デリッシュキッチンにウィジェット機能を追加しました

はじめに

こんにちは。開発部でiOSエンジニアをしている野口です。

今回は挑戦WEEKにてiOS版デリッシュキッチンにウィジェット機能を実装した際の実装方法や、実装中に直面した課題とその解決方法についてお話しします。(※本記事ではiOS版について解説しますが、Android版にも同様の機能を追加しています)

弊社の挑戦WEEKの取り組みについては以下の記事をご覧ください! tech.every.tv

ウィジェットについては以前まとめている記事があるので以下の記事もご覧ください! tech.every.tv

今回実装したウィジェットについて

今回実装したウィジェットの要件はこちらです。以下をゴールとして作成しました。

  • おすすめレシピを表示する
  • レシピは15分毎に更新する
  • ウィジェットをタップするとアプリのレシピ詳細で動画を再生(※タップ後のレシピ詳細の実装は本記事では省略します)

完成したウィジェットはこちらです。

実装の流れ

今回はXcode16.0を使用し、WidgetKit フレームワークを用いてウィジェットを作成します。 実装の流れとしては以下のようになります。

  1. ウィジェットターゲットの導入
  2. レシピデータの取得とタイムライン管理
  3. ウィジェット画面の作成

1. ウィジェットターゲットの導入

まず、既存のアプリプロジェクトにウィジェット機能を追加するためのターゲットを導入します。 Appleのドキュメント に従い、以下の手順で進めます。

File > New > Target を選択し、Widget Extension を選択して Next を押します。

プロダクト名を指定します。今回はライブアクティビティやコントロール機能は使用しないため、Include Live ActivityInclude ControlInclude Configuration App Intent のチェックは外して Finish を押します。

注意点: 新たにTargetを追加するため、ウィジェットは既存アプリとは別のアプリとして扱われます。そのため、リリース時にはApple Developerサイトでウィジェット用のIdentifiers(App ID)を登録する必要があります。

2. レシピデータの取得とタイムライン管理

ウィジェットに表示するデータを取得し、いつ更新するかを管理するのが TimelineProvider の役割です。 ターゲット作成時に、基本的な TimelineProvider のテンプレートコードが生成されます。

// 初期のテンプレートコード(抜粋)
struct Provider: TimelineProvider {
    // データ取得不可時のプレースホルダー表示用データを定義
    func placeholder(in context: Context) -> SimpleEntry { ... }
    // ウィジェットギャラリーでのスナップショット表示用データを定義
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { ... }
    // ウィジェットの表示更新タイミングとデータ(タイムライン)を定義
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { ... }
}

// ウィジェットに表示するデータの単位 (Entry)
struct SimpleEntry: TimelineEntry {
    let date: Date // このデータが表示されるべき日時
    let emoji: String // 表示するデータ(初期テンプレート)
}

今回実装するにあたり、TimelineProvider で以下の処理を行います。

  • placeholder: レシピデータがない場合の表示内容を定義します。(実装詳細は省略)
  • getSnapshot: ウィジェット追加時のプレビュー用に、最初のレシピ情報を取得して表示します。
  • getTimeline: APIからレシピ情報を取得し、15分ごとに表示内容が切り替わるようなタイムラインを作成します。

以下が TimelineProvider 周りの実装コード全体像です。(API通信処理の実装は省略しています)

import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    // プレビュー用データ取得
    func getSnapshot(in context: Context, completion: @escaping @Sendable (RecipeTimelineEntry) -> Void) {
        Task {
            let result = await fetchRecipes() // レシピ情報取得
            switch result {
            case .success(let response):
                if let firstRecipe = response.recipes.first {
                    // 最初のレシピ画像を取得
                    let fetchedImage = await loadImage(url: firstRecipe.imageURL)
                    let recipeEntry = RecipeEntry(
                        id: firstRecipe.id,
                        title: firstRecipe.title,
                        image: fetchedImage
                        // 他のレシピ情報も設定
                    )
                    completion(RecipeTimelineEntry(date: Date(), recipe: recipeEntry)) // 最初のレシピ情報を反映
                } else {
                    // レシピがない場合
                    completion(RecipeTimelineEntry(date: Date(), recipe: nil))
                }
            case .failure:
                // APIエラーの場合
                completion(RecipeTimelineEntry(date: Date(), recipe: nil))
            }
        }
    }

    // タイムライン用データ取得・生成
    func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<RecipeTimelineEntry>) -> Void) {
        Task {
            let result = await fetchRecipes() // レシピ情報取得
            switch result {
            case .success(let response):
                if response.recipes.isEmpty {
                    // レシピがない場合は空のタイムラインを返す
                    completion(Timeline(entries: [RecipeTimelineEntry(date: Date(), recipe: nil)], policy: .atEnd))
                    return
                }

                let interval: Int = 15 // 更新間隔(分)
                let currentDate = Date()

                // --- レシピ表示の循環性を保つための計算 ---
                let calendar = Calendar(identifier: .gregorian)
                let midnight = calendar.startOfDay(for: currentDate) // 今日の午前0時を取得
                // 今日の0時から現在時刻までに経過した分数を計算し、15分間隔で割る
                // これにより、「今日が始まってから何番目の15分区間か」がわかる
                let currentTimelineOffset: Int = (calendar.dateComponents([.minute], from: midnight, to: currentDate).minute ?? 0) / interval
                // --- ここまで ---

                var entries: [RecipeTimelineEntry] = []
                // 現在の区間から8つ先まで(2時間分)のエントリーを作成
                // ループの開始点を currentTimelineOffset にすることで、getTimeline がいつ呼ばれても
                // その時点からの適切なレシピが表示されるようにする
                for timelineOffset in currentTimelineOffset ..< currentTimelineOffset + 8 {
                    // 表示するレシピのインデックスを計算 (リストの末尾まで行ったら先頭に戻る)
                    let recipeIndex = timelineOffset % response.recipes.count
                    let recipe = response.recipes[recipeIndex]

                    // レシピ画像を取得
                    let fetchedImage = await loadImage(url: recipe.imageURL)
                    let recipeEntry = RecipeEntry(
                        id: recipe.id,
                        title: recipe.title,
                        image: fetchedImage
                        // 他のレシピ情報も設定
                    )

                    // このレシピを表示する日時を計算 (今日の0時から timelineOffset * 15 分後)
                    if let entryDate = calendar.date(byAdding: .minute, value: timelineOffset * interval, to: midnight) {
                        entries.append(RecipeTimelineEntry(date: entryDate, recipe: recipeEntry))
                    }
                }
                // 生成したエントリーリストと、最後の表示が終わった後に更新するポリシーでタイムラインを作成
                completion(Timeline(entries: entries, policy: .atEnd))
            case .failure:
                // APIエラーの場合、とりあえず現在のデータで終了
                completion(Timeline(entries: [RecipeTimelineEntry(date: Date(), recipe: nil)], policy: .atEnd))
            }
        }
    }

    // 非同期で画像を読み込む
    private func loadImage(url: URL) async -> Image? {
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            if let uiImage = UIImage(data: data) {
                return Image(uiImage: uiImage)
            }
        } catch {
            print("Image loading error: \(error)")
        }
        return nil
    }
}

// 1つのタイムラインエントリーが持つデータ構造
struct RecipeTimelineEntry: TimelineEntry {
    let date: Date // このデータが表示される日時
    let recipe: RecipeEntry? // 表示するレシピ情報
}

// レシピ情報の詳細を保持する構造体
struct RecipeEntry {
    let id: Int64 // レシピID
    let title: String // レシピタイトル
    let image: Image? // レシピ画像 (SwiftUI用)
    // 以下、必要に応じて他のレシピ情報
}
getSnapshot の解説

getSnapshot は、ユーザーがウィジェットを追加しようとする際に表示されるプレビュー画面のためのものです。ここではレシピ情報の最初の1件 (response.recipes.first) を使ってプレビュー用の SimpleEntry を作成し、completion ハンドラで返しています。

ちなみにプレビュー表示はこんな感じになります。

getTimeline の解説

getTimeline はウィジェットの表示内容とその更新タイミングを定義します。

課題: レシピの循環表示 要件は「15分ごとにレシピを更新する」ことなので、単純に15分間隔のエントリーをリストの先頭から順に生成することが考えられます。しかし、WidgetKitのタイムラインの更新タイミングはOSによって最適化されており、必ずしも作成した全エントリー(今回は2時間分)が表示された後に次の getTimeline が呼ばれるとは限らず、OSによる更新が頻繁に起こった場合に、最初の数個のレシピばかりが表示され続ける、という問題が発生し得ます。

解決策: 現在時刻に応じた開始位置の計算 この問題を回避し、レシピを循環的に表示させるため、以下の計算を行っています。

let calendar = Calendar(identifier: .gregorian)
let midnight = calendar.startOfDay(for: currentDate) // 今日の午前0時 (基準点)
// 今日の0時から現在時刻までに「15分間の区画」が何回経過したかを計算
let currentTimelineOffset: Int = (calendar.dateComponents([.minute], from: midnight, to: currentDate).minute ?? 0) / interval
  1. Calendar を使って「今日の午前0時 (midnight)」を取得します。これを1日の基準点とします。
  2. dateComponents で、午前0時から現在時刻 (currentDate) までの経過時間を「分」で計算します。
  3. その経過分数を interval (15) で割ります。この結果が currentTimelineOffset となり、「今日が始まってから現在時刻までに、15分の区切りが何回あったか」を示します。

タイムラインエントリーの生成 この currentTimelineOffset を使って、for ループでタイムラインエントリーを生成します。

// 現在の区間 (currentTimelineOffset) から8つ先まで (2時間分) のエントリーを作成
for timelineOffset in currentTimelineOffset ..< currentTimelineOffset + 8 {
    // レシピリスト内で循環するようにインデックスを計算
    let recipeIndex = timelineOffset % response.recipes.count
    let recipe = response.recipes[recipeIndex]
    // ... (画像取得、RecipeEntry作成) ...
    // エントリーが表示されるべき日時を計算
    if let entryDate = calendar.date(byAdding: .minute, value: timelineOffset * interval, to: midnight) {
        entries.append(SimpleEntry(date: entryDate, recipe: recipeEntry))
    }
}
// 生成したエントリーリストでタイムラインを作成。.atEnd はリストの最後を表示後に更新を促す
completion(Timeline(entries: entries, policy: .atEnd))

ループ開始点を currentTimelineOffset にすることで、OSがいつウィジェットを更新しても、その時刻に応じたレシピから表示が始まるように調整されます。これにより、常にリストの先頭からスケジュールが作られてしまうのを防いでいます。 さらに、ループ内で使うレシピ番号は let recipeIndex = timelineOffset % response.recipes.count で計算しています。これによって、レシピリストの最後まで表示したら次は先頭のレシピに戻るように循環的に表示できます。

画像の扱い 画像を表示する際にAsyncImageを使用することができませんでした。なので、TimelineProvider 側で Image に変換してから SimpleEntry に含めるようにしています。

// 非同期で画像を読み込む
private func loadImage(url: URL) async -> Image? {
    do {
        let (data, _) = try await URLSession.shared.data(from: url)
        if let uiImage = UIImage(data: data) {
            return Image(uiImage: uiImage)
        }
    } catch {
        print("Image loading error: \(error)")
    }
    return nil
}

ウィジェット画面作成

ウィジェットのUIは SwiftUI を使って構築します。 まず、Widget プロトコルに準拠した構造体 (例: RecipeWidget) でウィジェット全体の設定を行います。 ここで、.supportedFamilies モディファイアを使って、サポートするウィジェットのサイズ(今回は .systemSmall.systemMedium)を指定します。 また、.contentMarginsDisabled()でデフォルトもマージンを無効して画面いっぱいに画像が表示できるようにしています。

struct RecipeWidget: Widget {
    let kind: String = "RecipeWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            RecipeWidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .contentMarginsDisabled()  // ウィジェットのデフォルトマージンを無効にする
        .configurationDisplayName("本日のおすすめレシピ")
        .supportedFamilies([.systemSmall, .systemMedium]) // 対応サイズを指定
    }
}

次に、実際にウィジェットの内容を表示する View (例: RecipeWidgetEntryView) を作成します。

  • サイズ取得: @Environment(\.widgetFamily) を使って、現在表示されているウィジェットのサイズ (.systemSmall など) を取得します。
  • データ受け取り: var entry: Provider.Entry のように TimelineProvider から渡されたデータ(SimpleEntry)を受け取るプロパティを定義します。
  • Linkでタップ時の動作を設定: Link を使ってウィジェット全体または一部をラップし、タップされた際に指定した URL スキーム ("アプリのURLスキーム://レシピ詳細パス/(recipe.id)") を使ってアプリ本体の特定画面へ遷移させます。
  • サイズに応じて表示を切り替え: familyに応じてSmall、Mediumウィジェットに切り替える
struct RecipeWidgetEntryView : View {
    @Environment(\.widgetFamily) var family // サイズ取得
    var entry: Provider.Entry // 2. データ受け取り

    var body: some View {
        if let recipe = entry.recipe {
            // 3. Linkでタップ時の動作を設定
            Link(destination: URL(string: "アプリのURLスキーム://レシピ詳細パス/\(recipe.id)")!) {
                // 4. サイズに応じて表示を切り替え
                if family == .systemSmall {
                    SmallWidgetView(recipe: recipe)
                } else {
                    MediumWidgetView(recipe: recipe)
                }
            }
        }
    }
}

struct SmallWidgetView: View {
    let recipe: RecipeEntry
    // ... Smallサイズ用のレイアウト ...
    var body: some View { Text("Medium: \(recipe.title)") } // 省略
}

struct MediumWidgetView: View {
    let recipe: RecipeEntry
    // ... Mediumサイズ用のレイアウト ...
    var body: some View { Text("Medium: \(recipe.title)") } // 省略
}

このように、Widget構造体で全体設定を行い、表示用の View でデータやサイズに応じたUI構築することで、ウィジェット画面を作成します。

終わりに

今回の挑戦WEEKでのウィジェット機能追加により、ユーザーはアプリを起動せずともホーム画面で手軽におすすめレシピをチェックできるようになりました。これによってアプリの起動率の向上に繋がることを期待しています。

ウィジェット開発は、タイムライン管理など通常のアプリ開発とは異なる考慮点がありましたが、この挑戦を通じてWidgetKitの知見を深めることができ、今後の開発にも活かせる貴重な経験となりました。

今回実装したウィジェット機能は既にリリース済みですので、デリッシュキッチンをお使いの方は、ぜひホーム画面に追加して試してみてください!

最後までお読みいただき、ありがとうございました。