¿Cómo actualizar @FetchRequest, cuando una entidad relacionada cambia en SwiftUI?

13

En un SwiftUI, Viewtengo una Listbase para @FetchRequestmostrar los datos de una Primaryentidad y la Secondaryentidad conectada a través de la relación . El Viewy Listse actualiza correctamente cuando agrego una nueva Primaryentidad con una nueva entidad secundaria relacionada.

El problema es que cuando actualizo el Secondaryelemento conectado en una vista detallada, la base de datos se actualiza, pero los cambios no se reflejan en la PrimaryLista. Obviamente, el @FetchRequestno se activa por los cambios en otra Vista.

Cuando agrego un nuevo elemento en la vista principal a partir de entonces, el elemento previamente modificado finalmente se actualiza.

Como solución alternativa, actualizo adicionalmente un atributo de la Primaryentidad en la vista de detalles y los cambios se propagan correctamente a la PrimaryVista.

Mi pregunta es: ¿Cómo puedo forzar una actualización en todo lo relacionado @FetchRequestscon SwiftUI Core Data? Especialmente, cuando no tengo acceso directo a las entidades relacionadas / @Fetchrequests?

Estructura de datos

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.
fuente
No es útil, lo siento. Pero me encuentro con este mismo problema. Mi vista detallada tiene una referencia al objeto primario seleccionado. Muestra una lista de objetos secundarios. Todas las funciones CRUD funcionan correctamente en Core Data pero no se reflejan en la IU. Me encantaría obtener más información sobre esto.
PJayRushton
¿Has intentado usar ObservableObject?
kdion4891
Intenté usar @ObservedObject var primary: Primary en la vista de detalles. Pero los cambios no se propagan de nuevo a la vista principal.
Björn B.

Respuestas:

15

Necesita un publicador que genere eventos sobre cambios en el contexto y alguna variable de estado en la vista principal para forzar la reconstrucción de la vista en el evento de recepción de ese editor.
Importante: la variable de estado debe usarse en el código del generador de vistas, de lo contrario el motor de representación no sabría que algo ha cambiado.

Aquí hay una modificación simple de la parte afectada de su código, que proporciona el comportamiento que necesita.

@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
fuente
Esperamos que Apple mejore la integración de SwiftUI de Core Data <-> en el futuro. Otorgando la recompensa a la mejor respuesta proporcionada. Gracias asperi
Darrell Root
¡Gracias por su respuesta! Pero @FetchRequest debería reaccionar a los cambios en la base de datos. Con Su solución, la Vista se actualizará con cada guardado en la base de datos, independientemente de los elementos involucrados. Mi pregunta era cómo hacer que @FetchRequest reaccionara ante los cambios relacionados con las relaciones de la base de datos. Su solución necesita un segundo suscriptor (NotificationCenter) en paralelo a @FetchRequest. También hay que usar un activador falso adicional `+ (self.refreshing?" ":" ")`. ¿Quizás una @Fetchrequest no es una solución adecuada en sí misma?
Björn B.
Sí, tiene razón, pero la solicitud de recuperación tal como se crea, por ejemplo, no se ve afectada por los cambios realizados últimamente, es por eso que no se actualiza / recupera. Puede haber una razón para considerar diferentes criterios de solicitud de búsqueda, pero esa es una pregunta diferente.
Asperi
2
@Asperi Acepto tu respuesta. Como indicó, el problema radica de alguna manera en que el motor de renderizado reconoce cualquier cambio. Usar una referencia a un Objeto modificado no es suficiente. Una variable modificada debe usarse en una Vista. En cualquier parte del cuerpo. Incluso usado en un fondo en la Lista funcionará. Yo uso un RefreshView(toggle: Bool)con un solo EmptyView en su cuerpo. Usar List {...}.background(RefreshView(toggle: self.refreshing))funcionará.
Björn B.
He encontrado una mejor manera de forzar la actualización / reincorporación de la lista, se proporciona en SwiftUI: la lista no se actualiza automáticamente después de eliminar todas las entradas de la entidad de datos principales . Por si acaso.
Asperi
1

Traté de tocar el objeto primario en la vista de detalle de esta manera:

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

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

Entonces la lista primaria se actualizará. Pero la vista de detalle tiene que saber sobre el objeto padre. Esto funcionará, pero probablemente esta no sea la forma SwiftUI o Combine ...

Editar:

Basado en la solución anterior, modifiqué mi proyecto con una función de guardar global (managedObject :). Esto tocará todas las Entidades relacionadas, actualizando así todas las @ FetchRequest relevantes.

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.
fuente