以前、ウィジェットとCoreDataを連携する入門という記事を書いたのですが、今回は続編としてユーザーが構成可能なウィジェットの実装について記事にしたいと思います。
はじめに
今回は下記の記事の続編として記載させていただきますので、すでにウィジェットとCoreDataは連携済みの状態から始めていきます。一度下記の記事を見ていただいた方が初めての方は理解しやすいかもしれません。
作ったもの
環境
- 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 Options
のOptions 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.")
}
}
ここまででウィジェットを編集の項目を選択できるようになりました。しかし、まだアイテムを選択を押してもエラーが表示される状態です。
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に対応
ConfigurationIntent
がIntentItem
の値を持っている場合は、その値をウィジェットに表示するようにしました。
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()
}
}
}
}
これで選択したものがウィジェット上に表示されるようになりました。
おわりに
今回はウィジェットの切り替えまではしましたが、そこからの特定の画面に遷移する処理までは行いませんでした。次回はその辺りにも触れることが出来ればと思います。
お疲れ様でした。