【SwiftUI】InteractiveWidgetでアプリ側のCoreData(SwiftData)が更新されない(iOS17)

SwiftUI

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へ連絡頂くのが一番反応が早いと思います。

https://twitter.com/thwork_app/

コメント

タイトルとURLをコピーしました