iOS17からInteractiveWidgetとしてWidgetでButtonとToggleが使用可能になりました。
使い方については別途紹介する予定ですが、今回はCoreDataの値が更新されない問題があったので、そちらについて書いていきます。
2023/10/15追記
以下の方法が正しい解決方法です
@Environment(\.managedObjectContext) private var viewContext
.onChange(of: scenePhase) { newValue in
if newValue == .active {
viewContext.stalenessInterval = 0
viewContext.refreshAllObjects()
viewContext.stalenessInterval = -1
}
}
refreshAllObjects()だけだと更新されないのでstalenessIntervalを0にするのがポイントです。
終わったら0の必要がないので-1に戻しておきましょう。
手っ取り早いのでactiveになった時に更新としています。
AppStorageを更新フラグに使って更新時のみrefreshAllObjects()にすると回数が減らせます。
直接描画されていない場合はfetchやsaveに前でも良いですし臨機応変に対応してください。
追記部分はそのうち別記事に分離する予定です。
問題点
WidgetからCountUpするとアプリ側で描画済みCoreDataの値が更新されません。
Widgetからの更新では再描画されないという事です。
アプリを終了した後はCoreDataの値の取得からやり直すので正しい値になっていますね。
※SwiftDataでも似た挙動になります。
なおAppStorageは更新されます。
勘の良い人はこの段階でどうするか検討がつきそうですね・・・
CoreDataとSwiftDataの違いについて
更新が出来ないのは共通です。
ただし、CoreDataはデフォルトだとそのままアプリ側から更新するとconflictしてエラーを吐きます。
対してSwiftDataはエラーを吐かずアプリ側の値で上書きします。
結論
AppStorageをフラグに使い、他のViewを一旦表示してから戻して該当Viewを無理やり再描画します。
何らかの方法で再描画出来れば問題ないので、他の方法でも良いです。
ただ今回はアプリをactiveにするのでスプラッシュ的に何らかの画面を挟んでも気にならないのでこの方法でも良いと思います。
解説
問題となるコード
簡単なカウントアップアプリを作成して検証したのでそれを使って解説していきます。
今回はWidgetとのデータ共有などの方法は割愛します。
以下は主要な部分のコードです。
View
import SwiftUI
import CoreData
import WidgetKit
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
init(){
let viewContext = PersistenceController.shared.container.viewContext
let req = NSFetchRequest<Counter>(entityName: "Counter")
req.sortDescriptors = [NSSortDescriptor(keyPath: \Counter.name, ascending: true)]
do {
let counters = try viewContext.fetch(req)
if counters.count != 0 {
return
}
let counter = Counter(context: viewContext)
counter.name = "Counter"
counter.count = 0
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
var body: some View {
NavigationStack{
Spacer()
NavigationLink("CountView"){
CounterView()
}
Spacer()
}
}
}
struct CounterView:View{
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Counter.name, ascending: true)],
animation: .default)
private var counters: FetchedResults<Counter>
@AppStorage("Count" ,store:UserDefaults(suiteName: "group.thwork.test")) var count = 0
var body: some View{
VStack{
Spacer()
Text("AppStorageCounter:\(count)")
Button("CountUp") {
count += 1
WidgetCenter.shared.reloadAllTimelines()
}
Spacer()
if let counter = counters.first {
CoreDataCounterView(counter: counter)
Spacer()
}
}
}
}
struct CoreDataCounterView:View {
@Environment(\.managedObjectContext) private var viewContext
@ObservedObject var counter:Counter
var body: some View {
Text("CoreDataCounter:\(counter.count)")
Button("CountUp") {
counter.count += 1
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
WidgetCenter.shared.reloadAllTimelines()
}
}
}
Widget
struct InteractiveWidgetWithCoreDataWidgetEntryView : View {
@AppStorage("Count" ,store:UserDefaults(suiteName: "group.thwork.test")) var count = 0
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Counter.name, ascending: true)],
animation: .default)
private var counters: FetchedResults<Counter>
var entry: Provider.Entry
var body: some View {
VStack {
Text("AppStorage:")
HStack{
Text("\(count)")
Spacer()
Button(intent: AppStorageCountUpIntent()) {
Text("+")
}
}
if let counter = counters.first {
Text("CoreData:")
HStack{
Text("\(counter.count)")
Spacer()
Button(intent: CoreDataCountUpIntent()) {
Text("+")
}
}
}
}
}
}
Intent
import AppIntents
import SwiftUI
import CoreData
struct AppStorageCountUpIntent: AppIntent {
static var title: LocalizedStringResource = "InteractiveWidgetWithCoreDataAppIntents"
@AppStorage("Count" ,store:UserDefaults(suiteName: "group.thwork.test")) var count = 0
func perform() async throws -> some IntentResult {
count += 1
return .result()
}
}
struct CoreDataCountUpIntent: AppIntent {
static var title: LocalizedStringResource = "InteractiveWidgetWithCoreDataAppIntents"
@MainActor
func perform() async throws -> some IntentResult {
let req = NSFetchRequest<Counter>(entityName: "Counter")
req.sortDescriptors = [NSSortDescriptor(keyPath: \Counter.name, ascending: true)]
do {
let counters = try PersistenceController.shared.container.viewContext.fetch(req)
guard let counter = counters.first else {
return .result()
}
counter.count += 1
try PersistenceController.shared.container.viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return .result()
}
}
解決法
AppStorageは正常に更新される為フラグに利用します。
CoreDataやSwiftDataを更新したらupdateフラグをtrueにします。
Intent
struct CoreDataCountUpIntent: AppIntent {
static var title: LocalizedStringResource = "InteractiveWidgetWithCoreDataAppIntents"
//追加
@AppStorage("Update" ,store:UserDefaults(suiteName: "group.thwork.test")) var update = false
@MainActor
func perform() async throws -> some IntentResult {
let req = NSFetchRequest<Counter>(entityName: "Counter")
req.sortDescriptors = [NSSortDescriptor(keyPath: \Counter.name, ascending: true)]
do {
let counters = try PersistenceController.shared.container.viewContext.fetch(req)
guard let counter = counters.first else {
return .result()
}
counter.count += 1
try PersistenceController.shared.container.viewContext.save()
//追加
update = true
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return .result()
}
}
View側はフラグが立っている時は該当Viewの代わりに適当なViewを表示します。
onAppearで若干のwait入れてからTask{ @MainActor in ~ }でフラグをfalseにして元のViewに戻します。
この辺はサンプルなので適当にやってるので適切な形に調整して使ってください。
View
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
//追加
@AppStorage("Update" ,store:UserDefaults(suiteName: "group.thwork.test")) var update = false
init(){
//略
}
var body: some View {
NavigationStack{
Spacer()
NavigationLink("CountView"){
//追加
if update {
Text("Updating")
.onAppear{
Task{ @MainActor in
try await Task.sleep(nanoseconds: 500 * 1000 * 1000)
update = false
}
}
} else {
CounterView()
}
}
Spacer()
}
}
}
これで以下の動画の様になります。※先に掲載したものと同じ動画です。
所感
WidgetでButtonやToggleが使える様になったのは嬉しいですが、
CoreDataやSwiftDataが上手く更新されないのは少し痛いですね。
特にSwiftDataは同じくiOS17から追加なので上手く使える様にしておいて欲しかったです。
私の調査不足や更新で変わる可能性もあると思うので、
何かあったら遠慮なく連絡してください。
以下のXへ連絡頂くのが一番反応が早いと思います。
コメント