67

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()
    }
}
np2314
  • 645
  • 5
  • 14
Björn B.
  • 753
  • 1
  • 7
  • 10
  • Not helpful, sorry. But I'm running into this same issue. My detail view has a reference to the selected primary object. It shows a list of secondary objects. All CRUD functions work properly in Core Data but are not reflected in the UI. Would love to get more info on this. – PJayRushton Nov 06 '19 at 16:53
  • Have you tried using `ObservableObject`? – kdion4891 Nov 07 '19 at 03:08
  • I tried using @ObservedObject var primary: Primary in the detail view. But the changes do not propagate back into the primary view. – Björn B. Nov 07 '19 at 17:27

6 Answers6

103

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()
        }
    }
}
G. Marc
  • 4,987
  • 4
  • 32
  • 49
33

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")
        }
    )
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 1
    Here's hoping Apple improves the Core Data <-> SwiftUI integration in the future. Awarding the bounty to the best answer provided. Thanks Asperi. – Darrell Root Nov 13 '19 at 00:53
  • 2
    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? – Björn B. Nov 13 '19 at 12:03
  • 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. – Asperi Nov 13 '19 at 12:11
  • 3
    @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. – Björn B. Nov 14 '19 at 22:20
  • 3
    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](https://stackoverflow.com/a/60230873/12299030). Just in case. – Asperi Feb 17 '20 at 08:36
  • 2
    @g-marc answer is the correkt one [link](https://stackoverflow.com/a/63524550/478472) – MatzeLoCal Dec 28 '20 at 09:35
  • 1
    Do not use this with background NSManagedObjectContext with automaticallyMergesChangesFromParent, because the notification will be sent in a non-UI thread and therefore call SwiftUI UI code in a non-UI thread. – vomi Nov 17 '22 at 14:18
12

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.

John_Ye
  • 181
  • 1
  • 5
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()
    }
}
Victor Kushnerov
  • 3,706
  • 27
  • 56
1

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()
    }
}
Björn B.
  • 753
  • 1
  • 7
  • 10
-1

If you are here, i don't find the reason why your view isn't updating, i think this will help you:

  1. Always use the @ObservedObject when you declare a core data type.
  2. If you are using MVVM, wrap the view model also with @ObservedObject, and in the VM create the core data type with @Published.

This is an example of creating a VM with @ObservedObject, so when core data receives the update, the instance of the view model recreate itself, and the view is updated.

class ProductTitleValueViewModel: BaseViewModel, ObservableObject {
// MARK: - Properties

@Published var product: Product
var colorSet: [Color]
var currency: Currency

// MARK: - Init

init(product: Product, colorSet: [Color], currency: Currency) {
    self.product = product
    self.colorSet = colorSet
    self.currency = currency
}

}

struct ProductTitleValueView: View {
@ObservedObject var viewModel: ProductTitleValueViewModel

var body: some View {
    VStack(alignment: .leading, spacing: 5) {
        HStack {
            Circle()
                .fill(
                    LinearGradient(colors: viewModel.colorSet, startPoint: .leading, endPoint: .trailing)
                )
                .opacity(0.6)
                .frame(width: 20, height: 20)
            
            Text(viewModel.product.wrappedName)
                .font(.callout.bold())
                .foregroundColor(ThemeColor.lightGray)
        }
        
        
        Text(viewModel.product.balance.toCurrency(with: viewModel.currency))
            .font(.callout.bold())
            .padding(.leading, 28)
        
    }
}
}

If you follow this 2 simple things, you are not going to have problem with core date updating your views.

Alessandro Pace
  • 206
  • 4
  • 8