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

6

71

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()
    }
}
Stadium answered 31/10, 2019 at 12:10 Comment(3)
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.Shiver
Have you tried using ObservableObject?Kunkle
I tried using @ObservedObject var primary: Primary in the detail view. But the changes do not propagate back into the primary view.Vitebsk
J
110

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()
        }
    }
}
Jackstay answered 21/8, 2020 at 14:15 Comment(3)
If am using exactly this but if WineRow changes its height due to the change, the list seems not to update the cell correctly. :(Churchyard
Praise the swiftUI godsLenzi
Would upvote this 100 more times if I could.Schnitzel
K
35

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")
        }
    )
}
Kauffman answered 9/11, 2019 at 8:27 Comment(7)
Here's hoping Apple improves the Core Data <-> SwiftUI integration in the future. Awarding the bounty to the best answer provided. Thanks Asperi.Yardage
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?Vitebsk
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.Kauffman
@Kauffman 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.Vitebsk
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.Kauffman
@g-marc answer is the correkt one linkCollette
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.Barsac
F
13

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.

Fallal answered 20/5, 2021 at 21:37 Comment(1)
this worked very easily, however had to change private var to let cause you would have to implant custom init for struct.Cathycathyleen
I
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()
    }
}
Intra answered 13/2, 2021 at 14:25 Comment(0)
S
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()
    }
}
Stadium answered 31/10, 2019 at 13:35 Comment(0)
W
-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.

Woolsey answered 21/3, 2022 at 22:12 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.