【iOS】ウィジェットとCoreDataを連携する入門〜ユーザーが構成可能なウィジェットを作成する〜

2023.03.25

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

以前、ウィジェットとCoreDataを連携する入門という記事を書いたのですが、今回は続編としてユーザーが構成可能なウィジェットの実装について記事にしたいと思います。

はじめに

今回は下記の記事の続編として記載させていただきますので、すでにウィジェットとCoreDataは連携済みの状態から始めていきます。一度下記の記事を見ていただいた方が初めての方は理解しやすいかもしれません。

【iOS】ウィジェットとCoreDataを連携する入門

作ったもの

widget intent demo

環境

  • Xcode 14.2
  • iOS 16.2

ConfigurationIntentに対応

すでにWidget Extensionをターゲットとして追加済みなのですが、作成時(下記画像参照)にInclude Configuration Intentのチェックを外していました。

なので、ConfigurationIntentの対応を進めていきます。

intentDefinitionファイルを追加

File > New > File でSiriKit Intent Definition Fileを選択し、追加します。

空のファイルが追加後に、ファイル下部の+ボタンを押して、New Intentを選択します。

CategoryをView、TitleをConfigurationに変更し、WidgetsのIntent is eligible for widgetsにチェックを入れます。

また、Target MembershipのWidget Extensionにもチェックを入れます。

Configurationにパラメーターを追加します。Parameters下部の+ボタンを押します。

パラメーターが追加されました。パラメータのTypeを今回はStringから変更したいので、Typeフォームを選択します。

下部のAdd Typesを選択します。

Typeの表示に切り替わるので、Typeの名前を任意の名前に変更します。今回はIntentItemと言う名前にしました。

ConfigurationのパラメータのDisplay Nameを変更してなかったので、こちらも任意の名前に変更します。また、ウィジェット上でユーザー構成可能にする為に、下記にチェックを入れます。

  • Configurable
  • Dynamic OptionsOptions are provided dynamically

WidgetファイルをIntentに対応

変更前

ウィジェットに関する処理が記載されているCoreDataWidget.swiftのコードです。

import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
}

struct CoreDataWidgetEntryView : View {

    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>

    var body: some View {

        VStack {
            Rectangle()
                .fill(.blue.gradient)
                .frame(height: 32)
            Divider()

            Spacer()

            ForEach(items) { item in
                Text(item.timestamp ?? Date(), style: .time)
            }

            Spacer()
        }
    }
}

struct CoreDataWidget: Widget {
    let persistenceController = PersistenceController.shared

    let kind: String = "CoreDataWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { _ in
            CoreDataWidgetEntryView()
                .environment(\.managedObjectContext,
                              persistenceController.container.viewContext)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

変更後

import WidgetKit
import SwiftUI

// IntentTimeProviderに変更
struct Provider: IntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(),
                    configuration: ConfigurationIntent())
    }

    // IntentTimeProviderのメソッドに変更
    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(),
                                configuration: configuration)
        completion(entry)
    }

    // IntentTimeProviderのメソッドに変更
    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate,
                                    configuration: configuration)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

// ConfigurationIntentをプロパティに追加
struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationIntent
}

struct CoreDataWidgetEntryView : View {

    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>

    // entryを追加
    let entry: Provider.Entry

    var body: some View {

        VStack {
            Rectangle()
                .fill(.blue.gradient)
                .frame(height: 32)
            Divider()

            Spacer()

            ForEach(items) { item in
                Text(item.timestamp ?? Date(), style: .time)
            }

            Spacer()
        }
    }
}

struct CoreDataWidget: Widget {
    let persistenceController = PersistenceController.shared
    let kind: String = "CoreDataWidget"

    var body: some WidgetConfiguration {
        // IntentConfigurationに変更
        IntentConfiguration(kind: kind,
                            intent: ConfigurationIntent.self,
                            provider: Provider()) { entry in
            CoreDataWidgetEntryView(entry: entry)
                .environment(\.managedObjectContext,
                              persistenceController.container.viewContext)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

ここまででウィジェットを編集の項目を選択できるようになりました。しかし、まだアイテムを選択を押してもエラーが表示される状態です。

Core Data Intent Widget demo

Intents Extensionを追加

メニューバー File > New > Target を押し、Intents Extensionを選択し、Nextを押します。

Product Nameには任意の名前を入れ、Starting PointはNoneにします。

追加後に、アクティベートするか問われるのでアクティベートを行います。

Intents Extensionが新しいターゲットとして追加されました。

新しく追加されたIntents ExtensionでGeneralを選択し、Supported Intentsの項目の+ボタンを押します。Class Nameは既に入力済みの値と合わせる為にConfigurationIntentにします。

Intents Extensionでも共有のデータ領域を使用するので、Capabilityを選択し、+ボタンを押し、App Groupsを追加します。

その他のターゲットで使用しているApp Groupsをチェックします。

.intentdefinitionのターゲットに追加したIntent Extensionを追加

.intentdefinitionのTarget Membershipに先ほど作成したIntent Extensionを追加する為にチェックを入れます。

CoreData関連ファイルをIntentに対応

.xcdatamodeldの対応

CoreDataの.xcdatamodeldファイルのAttributesに該当の値を取得する為idを追加します。

Target MembershipにIntent Extensionを追加する為、チェックを入れます。

ContentViewのコード変更

Attributesにidを追加した為、addItem実行時に、idの値も設定するように変更します。

private func addItem() {
    withAnimation {
        let newItem = Item(context: viewContext)
        // idを付与
        newItem.id = UUID()
        newItem.timestamp = Date()

        do {
            try viewContext.save()
            WidgetCenter.shared.reloadAllTimelines()

        } catch {
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }
    }
}

Persistence.swiftの対応

Target MembershipにIntent Extensionを追加する為、チェックを入れます。

IntentHandlerでCoreDataに対応

IntentHandler.swiftのコードもCoreDataに対応する為に変更します。

変更前

import Intents

class IntentHandler: INExtension {

    override func handler(for intent: INIntent) -> Any {
        // This is the default implementation.  If you want different objects to handle different intents,
        // you can override this and return the handler you want for that particular intent.

        return self
    }

}

変更後

import Intents
// 追加
import CoreData

// ConfigurationIntentHandlingを追加
class IntentHandler: INExtension, ConfigurationIntentHandling {

    // 追加
    private let context = PersistenceController.shared.container.viewContext

    // Intentの項目を表示する為のメソッドを追加
    func provideParameterOptionsCollection(for intent: ConfigurationIntent) async throws -> INObjectCollection<IntentItem> {

        let request = NSFetchRequest<Item>(entityName: "Item")
        var items: [IntentItem] = []

        let result = try context.fetch(request)

        result.forEach {
            items.append($0.intentItem)
        }

        return INObjectCollection(items: items)
    }

    override func handler(for intent: INIntent) -> Any {
        return self
    }
}

// CoreDataのItemをIntent用のItemに変更する変数を追加
private extension Item {

    var intentItem: IntentItem {
        return IntentItem(identifier: self.id?.uuidString,
                          display: self.timestamp?.formatted() ?? "")
    }
}

WidgetのEntryViewをIntentに対応

ConfigurationIntentIntentItemの値を持っている場合は、その値をウィジェットに表示するようにしました。

struct CoreDataWidgetEntryView : View {

    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>

    let entry: Provider.Entry

    var body: some View {

        // 選択されたアイテムがある場合は表示
        if let intentItem = entry.configuration.parameter {

            VStack {
                Text("選択中")
                Text(intentItem.displayString)
            }

        } else {
            VStack {
                Rectangle()
                    .fill(.blue.gradient)
                    .frame(height: 32)
                Divider()

                Spacer()

                ForEach(items) { item in
                    Text(item.timestamp ?? Date(), style: .time)
                }

                Spacer()
            }
        }
    }
}

これで選択したものがウィジェット上に表示されるようになりました。

おわりに

今回はウィジェットの切り替えまではしましたが、そこからの特定の画面に遷移する処理までは行いませんでした。次回はその辺りにも触れることが出来ればと思います。

お疲れ様でした。

参考