Comment mettre à jour @FetchRequest, lorsqu'une entité liée change dans SwiftUI?

13

Dans un SwiftUI Viewj'ai un Listbasé sur l' @FetchRequestaffichage des données d'une Primaryentité et l' Secondaryentité connectée via la relation via . Le Viewet son Listest mis à jour correctement lorsque j'ajoute une nouvelle Primaryentité avec une nouvelle entité secondaire associée.

Le problème est que lorsque je mets à jour l' Secondaryélément connecté dans une vue détaillée, la base de données est mise à jour, mais les modifications ne sont pas reflétées dans la Primaryliste. Évidemment, le @FetchRequestn'est pas déclenché par les changements dans une autre vue.

Par la suite, lorsque j'ajoute un nouvel élément dans la vue principale, l'élément précédemment modifié est enfin mis à jour.

Pour contourner ce problème, je mets également à jour un attribut de l' Primaryentité dans la vue détaillée et les modifications se propagent correctement dans la Primaryvue.

Ma question est: comment puis-je forcer une mise à jour sur tous les éléments liés @FetchRequestsà SwiftUI Core Data? Surtout, quand je n'ai pas d'accès direct aux entités liées / @Fetchrequests?

Structure de données

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()
    }
}
Björn B.
la source
Pas utile, désolé. Mais je rencontre ce même problème. Ma vue détaillée fait référence à l'objet principal sélectionné. Il affiche une liste d'objets secondaires. Toutes les fonctions CRUD fonctionnent correctement dans Core Data mais ne sont pas reflétées dans l'interface utilisateur. J'adorerais avoir plus d'informations à ce sujet.
PJayRushton
Avez-vous essayé d'utiliser ObservableObject?
kdion4891
J'ai essayé d'utiliser @ObservedObject var primary: Primary dans la vue détaillée. Mais les modifications ne se propagent pas dans la vue principale.
Björn B.

Réponses:

15

Vous avez besoin d'un éditeur qui générerait un événement sur les changements de contexte et une variable d'état dans la vue principale pour forcer la reconstruction de la vue lors de l'événement de réception de cet éditeur.
Important: la variable d'état doit être utilisée dans le code du générateur de vue, sinon le moteur de rendu ne saurait pas que quelque chose a changé.

Voici une simple modification de la partie affectée de votre code, qui donne le comportement dont vous avez besoin.

@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")
        }
    )
}
Asperi
la source
Nous espérons qu'Apple améliorera à l'avenir l'intégration Core Data <-> SwiftUI. Remise de la prime à la meilleure réponse fournie. Merci Asperi.
Darrell Root
Merci pour votre réponse! Mais @FetchRequest devrait réagir aux changements dans la base de données. Avec votre solution, la vue sera mise à jour à chaque sauvegarde de la base de données, quels que soient les éléments impliqués. Ma question était de savoir comment obtenir @FetchRequest de réagir aux changements impliquant des relations de base de données. Votre solution a besoin d'un deuxième abonné (le NotificationCenter) en parallèle à @FetchRequest. Il faut également utiliser un faux déclencheur supplémentaire `+ (self.refreshing?" ":" ")`. Peut-être qu'une @Fetchrequest n'est pas une solution appropriée en soi?
Björn B.
Oui, vous avez raison, mais la demande de récupération telle qu'elle est créée dans l'exemple n'est pas affectée par les modifications apportées récemment, c'est pourquoi elle n'est pas mise à jour / récupérée. Il peut y avoir une raison de considérer différents critères de demande de récupération, mais c'est une question différente.
Asperi
2
@Asperi J'accepte votre réponse. Comme vous l'avez dit, le problème réside en quelque sorte avec le moteur de rendu pour reconnaître les modifications. L'utilisation d'une référence à un objet modifié ne suffit pas. Une variable modifiée doit être utilisée dans une vue. Dans n'importe quelle partie du corps. Même utilisé sur un arrière-plan de la liste fonctionnera. J'utilise un RefreshView(toggle: Bool)avec un seul EmptyView dans son corps. L'utilisation List {...}.background(RefreshView(toggle: self.refreshing))fonctionnera.
Björn B.
J'ai trouvé un meilleur moyen de forcer l'actualisation / la récupération de la liste, il est fourni dans SwiftUI: la liste ne se met pas à jour automatiquement après la suppression de toutes les entrées de l'entité de données principale . Au cas où.
Asperi
1

J'ai essayé de toucher l'objet principal dans la vue de détail comme ceci:

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

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

Ensuite, la liste principale sera mise à jour. Mais la vue de détail doit connaître l'objet parent. Cela fonctionnera, mais ce n'est probablement pas la manière SwiftUI ou Combine ...

Éditer:

Sur la base de la solution de contournement ci-dessus, j'ai modifié mon projet avec une fonction de sauvegarde globale (managedObject :). Cela touchera toutes les entités liées, mettant ainsi à jour toutes les @ FetchRequest pertinentes.

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.
la source