How to update @FetchRequest, when a related Entity changes in SwiftUI?

13,457

Solution 1

I also struggled with this and found a very nice and clean solution:

You have to wrap the row in a separate view and use @ObservedObject in that row view on the entity.

Here's my code:

WineList:

struct WineList: View {
    @FetchRequest(entity: Wine.entity(), sortDescriptors: [
        NSSortDescriptor(keyPath: \Wine.name, ascending: true)
        ]
    ) var wines: FetchedResults<Wine>

    var body: some View {
        List(wines, id: \.id) { wine in
            NavigationLink(destination: WineDetail(wine: wine)) {
                WineRow(wine: wine)
            }
        }
        .navigationBarTitle("Wines")
    }
}

WineRow:

struct WineRow: View {
    @ObservedObject var wine: Wine   // !! @ObserveObject is the key!!!

    var body: some View {
        HStack {
            Text(wine.name ?? "")
            Spacer()
        }
    }
}

Solution 2

You need a Publisher which would generate event about changes in context and some state variable in primary view to force view rebuild on receive event from that publisher.
Important: state variable must be used in view builder code, otherwise rendering engine would not know that something changed.

Here is simple modification of affected part of your code, that gives behaviour that you need.

@State private var refreshing = false
private var didSave =  NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)

var body: some View {
    List {
        ForEach(fetchedResults) { primary in
            NavigationLink(destination: SecondaryView(primary: primary)) {
                VStack(alignment: .leading) {
                    // below use of .refreshing is just as demo,
                    // it can be use for anything
                    Text("\(primary.primaryName ?? "nil")" + (self.refreshing ? "" : ""))
                    Text("\(primary.secondary?.secondaryName ?? "nil")").font(.footnote).foregroundColor(.secondary)
                }
            }
            // here is the listener for published context event
            .onReceive(self.didSave) { _ in
                self.refreshing.toggle()
            }
        }
    }
    .navigationBarTitle("Primary List")
    .navigationBarItems(trailing:
        Button(action: {self.addNewPrimary()} ) {
            Image(systemName: "plus")
        }
    )
}

Solution 3

An alternative method: using a Publisher and List.id():

struct ContentView: View {
  /*
    @FetchRequest...
  */

  private var didSave =  NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)  //the publisher
  @State private var refreshID = UUID()

  var body: some View {
      List {
        ...
      }
      .id(refreshID)
      .onReceive(self.didSave) { _ in   //the listener
          self.refreshID = UUID()
          print("generated a new UUID")
      }    
  }
}

Every time you call save() of NSManagedObjects in a context, it genertates a new UUID for the List view, and it forces the List view to refresh.

Solution 4

To fix that you have to add @ObservedObject to var primary: Primary in SecondaryView to work List properly. Primary belong to NSManagedObject class, which already conforms to @ObservableObject protocol. This way the changes in instances of Primary are observed.

import SwiftUI

extension Primary: Identifiable {}

// Primary View

struct PrimaryListView: View {
    @Environment(\.managedObjectContext) var context

    @FetchRequest(
        entity: Primary.entity(),
        sortDescriptors: [NSSortDescriptor(key: "primaryName", ascending: true)]
    )
    var fetchedResults: FetchedResults<Primary>

    var body: some View {
        List {
            ForEach(fetchedResults) { primary in
                NavigationLink(destination: SecondaryView(primary: primary)) {
                VStack(alignment: .leading) {
                    Text("\(primary.primaryName ?? "nil")")
                    Text("\(primary.secondary?.secondaryName ?? "nil")").font(.footnote).foregroundColor(.secondary)
                }
                }
            }
        }
        .navigationBarTitle("Primary List")
        .navigationBarItems(trailing:
            Button(action: {self.addNewPrimary()} ) {
                Image(systemName: "plus")
            }
        )
    }

    private func addNewPrimary() {
        let newPrimary = Primary(context: context)
        newPrimary.primaryName = "Primary created at \(Date())"
        let newSecondary = Secondary(context: context)
        newSecondary.secondaryName = "Secondary built at \(Date())"
        newPrimary.secondary = newSecondary
        try? context.save()
    }
}

struct PrimaryListView_Previews: PreviewProvider {
    static var previews: some View {
        let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext

        return NavigationView {
            PrimaryListView().environment(\.managedObjectContext, context)
        }
    }
}

// Detail View

struct SecondaryView: View {
    @Environment(\.presentationMode) var presentationMode

    @ObservedObject var primary: Primary

    @State private var newSecondaryName = ""

    var body: some View {
        VStack {
            TextField("Secondary name:", text: $newSecondaryName)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
                .onAppear {self.newSecondaryName = self.primary.secondary?.secondaryName ?? "no name"}
            Button(action: {self.saveChanges()}) {
                Text("Save")
            }
            .padding()
        }
    }

    private func saveChanges() {
        primary.secondary?.secondaryName = newSecondaryName

        try? primary.managedObjectContext?.save()
        presentationMode.wrappedValue.dismiss()
    }
}

Solution 5

I tried to touch the primary object in the detail view like this:

// TODO: ❌ workaround to trigger update on primary @FetchRequest

if let primary = secondary.primary {
   secondary.managedObjectContext?.refresh(primary, mergeChanges: true)
}

Then the primary list will update. But the detail view has to know about the parent object. This will work, but this is probably not the SwiftUI or Combine way...

Edit:

Based on the above workaround, I modified my project with a global save(managedObject:) function. This will touch all related Entities, thus updating all relevant @FetchRequest's.

import SwiftUI
import CoreData

extension Primary: Identifiable {}

// MARK: - Primary View

struct PrimaryListView: View {
    @Environment(\.managedObjectContext) var context

    @FetchRequest(
        sortDescriptors: [
            NSSortDescriptor(keyPath: \Primary.primaryName, ascending: true)]
    )
    var fetchedResults: FetchedResults<Primary>

    var body: some View {
        print("body PrimaryListView"); return
        List {
            ForEach(fetchedResults) { primary in
                NavigationLink(destination: SecondaryView(secondary: primary.secondary!)) {
                    VStack(alignment: .leading) {
                        Text("\(primary.primaryName ?? "nil")")
                        Text("\(primary.secondary?.secondaryName ?? "nil")")
                            .font(.footnote).foregroundColor(.secondary)
                    }
                }
            }
        }
        .navigationBarTitle("Primary List")
        .navigationBarItems(trailing:
            Button(action: {self.addNewPrimary()} ) {
                Image(systemName: "plus")
            }
        )
    }

    private func addNewPrimary() {
        let newPrimary = Primary(context: context)
        newPrimary.primaryName = "Primary created at \(Date())"
        let newSecondary = Secondary(context: context)
        newSecondary.secondaryName = "Secondary built at \(Date())"
        newPrimary.secondary = newSecondary
        try? context.save()
    }
}

struct PrimaryListView_Previews: PreviewProvider {
    static var previews: some View {
        let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext

        return NavigationView {
            PrimaryListView().environment(\.managedObjectContext, context)
        }
    }
}

// MARK: - Detail View

struct SecondaryView: View {
    @Environment(\.presentationMode) var presentationMode

    var secondary: Secondary

    @State private var newSecondaryName = ""

    var body: some View {
        print("SecondaryView: \(secondary.secondaryName ?? "")"); return
        VStack {
            TextField("Secondary name:", text: $newSecondaryName)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
                .onAppear {self.newSecondaryName = self.secondary.secondaryName ?? "no name"}
            Button(action: {self.saveChanges()}) {
                Text("Save")
            }
            .padding()
        }
    }

    private func saveChanges() {
        secondary.secondaryName = newSecondaryName

        // save Secondary and touch Primary
        (UIApplication.shared.delegate as! AppDelegate).save(managedObject: secondary)

        presentationMode.wrappedValue.dismiss()
    }
}

extension AppDelegate {
    /// save and touch related objects
    func save(managedObject: NSManagedObject) {

        let context = persistentContainer.viewContext

        // if this object has an impact on related objects, touch these related objects
        if let secondary = managedObject as? Secondary,
            let primary = secondary.primary {
            context.refresh(primary, mergeChanges: true)
            print("Primary touched: \(primary.primaryName ?? "no name")")
        }

        saveContext()
    }
}
Share:
13,457

Related videos on Youtube

Björn B.
Author by

Björn B.

Updated on June 04, 2022

Comments

  • Björn B.
    Björn B. almost 2 years

    In a SwiftUI View i have a List based on @FetchRequest showing data of a Primary entity and the via relationship connected Secondary entity. The View and its List is updated correctly, when I add a new Primary entity with a new related secondary entity.

    The problem is, when I update the connected Secondary item in a detail view, the database gets updated, but the changes are not reflected in the Primary List. Obviously, the @FetchRequest does not get triggered by the changes in another View.

    When I add a new item in the primary view thereafter, the previously changed item gets finally updated.

    As a workaround, i additionally update an attribute of the Primary entity in the detail view and the changes propagate correctly to the Primary View.

    My question is: How can I force an update on all related @FetchRequests in SwiftUI Core Data? Especially, when I have no direct access to the related entities/@Fetchrequests?

    Data Structure

    import SwiftUI
    
    extension Primary: Identifiable {}
    
    // Primary View
    
    struct PrimaryListView: View {
        @Environment(\.managedObjectContext) var context
    
        @FetchRequest(
            entity: Primary.entity(),
            sortDescriptors: [NSSortDescriptor(key: "primaryName", ascending: true)]
        )
        var fetchedResults: FetchedResults<Primary>
    
        var body: some View {
            List {
                ForEach(fetchedResults) { primary in
                    NavigationLink(destination: SecondaryView(primary: primary)) {
                    VStack(alignment: .leading) {
                        Text("\(primary.primaryName ?? "nil")")
                        Text("\(primary.secondary?.secondaryName ?? "nil")").font(.footnote).foregroundColor(.secondary)
                    }
                    }
                }
            }
            .navigationBarTitle("Primary List")
            .navigationBarItems(trailing:
                Button(action: {self.addNewPrimary()} ) {
                    Image(systemName: "plus")
                }
            )
        }
    
        private func addNewPrimary() {
            let newPrimary = Primary(context: context)
            newPrimary.primaryName = "Primary created at \(Date())"
            let newSecondary = Secondary(context: context)
            newSecondary.secondaryName = "Secondary built at \(Date())"
            newPrimary.secondary = newSecondary
            try? context.save()
        }
    }
    
    struct PrimaryListView_Previews: PreviewProvider {
        static var previews: some View {
            let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    
            return NavigationView {
                PrimaryListView().environment(\.managedObjectContext, context)
            }
        }
    }
    
    // Detail View
    
    struct SecondaryView: View {
        @Environment(\.presentationMode) var presentationMode
    
        var primary: Primary
    
        @State private var newSecondaryName = ""
    
        var body: some View {
            VStack {
                TextField("Secondary name:", text: $newSecondaryName)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .padding()
                    .onAppear {self.newSecondaryName = self.primary.secondary?.secondaryName ?? "no name"}
                Button(action: {self.saveChanges()}) {
                    Text("Save")
                }
                .padding()
            }
        }
    
        private func saveChanges() {
            primary.secondary?.secondaryName = newSecondaryName
    
            // TODO: ❌ workaround to trigger update on primary @FetchRequest
            primary.managedObjectContext.refresh(primary, mergeChanges: true)
            // primary.primaryName = primary.primaryName
    
            try? primary.managedObjectContext?.save()
            presentationMode.wrappedValue.dismiss()
        }
    }
    
  • Darrell Root
    Darrell Root over 4 years
    Here's hoping Apple improves the Core Data <-> SwiftUI integration in the future. Awarding the bounty to the best answer provided. Thanks Asperi.
  • Björn B.
    Björn B. over 4 years
    Thank You for Your answer! But @FetchRequest should react to changes in the database. With Your solution, the View will be updated with every save on the database, regardless of the items involved. My question was how to get @FetchRequest to react on changes involving database relations. Your solution needs a second subscriber (the NotificationCenter) in parallel to the @FetchRequest. Also one has to use an additional fake trigger ` + (self.refreshing ? "" : "")`. Maybe a @Fetchrequest is not a suitable solution itself?
  • Asperi
    Asperi over 4 years
    Yes, you're right, but the fetch request as it is created in example is not affected by the changes that are made lately, that is why it is not updated/refetched. May be there is a reason to consider different fetch request criteria, but that is different question.
  • Björn B.
    Björn B. over 4 years
    @Asperi I accept Your answer. As You stated, the problem lies somehow with the rendering engine to recognise any changes. Using a reference to a changed Object does not suffice. A changed variable must be used in a View. In any portion of the body. Even used on a background on the List will work. I use a RefreshView(toggle: Bool) with a single EmptyView in its body. Using List {...}.background(RefreshView(toggle: self.refreshing)) will work.
  • Asperi
    Asperi about 4 years
    I've found better way to force List refresh/refetch, it is provided in SwiftUI: List does not update automatically after deleting all Core Data Entity entries. Just in case.
  • davidev
    davidev about 4 years
    @Asperi What a brilliant answer sir! Awesome job... thank you so much for your answers to SwiftUI :) You help me a lot
  • Steven Wilber
    Steven Wilber over 3 years
    This worked perfectly for me. Thanks. It's also obvious in hindsight which is nice as it means that it fits the SwiftUI way of things. Thanks.
  • Peter Schorn
    Peter Schorn over 3 years
    This should be the accepted answer. It's much simpler and aligns more with the SwiftUI philosophy.
  • Niall Kehoe
    Niall Kehoe over 3 years
    What an elegant solution!
  • mikemike396
    mikemike396 over 3 years
    Key is the "@ObserveObject" You rocked! Works perfect!
  • thisIsTheFoxe
    thisIsTheFoxe over 3 years
    OMFG!! How? How is that not the accepted answer? This is soo clean, easy, and works perfectly.... Thanks, marc <3
  • MatzeLoCal
    MatzeLoCal over 3 years
    @g-marc answer is the correkt one link
  • alex
    alex about 3 years
    Awesome, thanks for this one. Usually people leave out too much from their example code, but yours had enough to help me understand how it fits into my code!
  • Nicolas
    Nicolas over 2 years
    If am using exactly this but if WineRow changes its height due to the change, the list seems not to update the cell correctly. :(
  • Maddocks
    Maddocks about 2 years
    this worked very easily, however had to change private var to let cause you would have to implant custom init for struct.