Como atualizar o @FetchRequest, quando uma Entidade relacionada é alterada no SwiftUI?

13

Em um SwiftUI, Viewtenho como Listbase a @FetchRequestexibição de dados de uma Primaryentidade e a Secondaryentidade conectada via relacionamento . O Viewe seu Listsão atualizados corretamente quando adiciono uma nova Primaryentidade a uma nova entidade secundária relacionada.

O problema é que, quando atualizo o Secondaryitem conectado em uma exibição detalhada, o banco de dados é atualizado, mas as alterações não são refletidas na PrimaryLista. Obviamente, o @FetchRequestnão é acionado pelas alterações em outra Visualização.

Quando adiciono um novo item na exibição principal a partir de então, o item alterado anteriormente é finalmente atualizado.

Como solução alternativa, atualizo adicionalmente um atributo da Primaryentidade na visualização de detalhes e as alterações são propagadas corretamente para a PrimaryVisualização.

Minha pergunta é: Como forçar uma atualização em todos os @FetchRequestsitens relacionados no SwiftUI Core Data? Especialmente quando não tenho acesso direto às entidades relacionadas / @Fetchrequests?

Estrutura de dados

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.
fonte
Não é útil, desculpe. Mas estou enfrentando esse mesmo problema. Minha exibição de detalhes tem uma referência ao objeto principal selecionado. Mostra uma lista de objetos secundários. Todas as funções CRUD funcionam corretamente no Core Data, mas não são refletidas na interface do usuário. Gostaria de obter mais informações sobre isso.
PJayRushton
Você já tentou usar ObservableObject?
precisa saber é o seguinte
Tentei usar @ObservedObject var primary: Primary na exibição de detalhes. Mas as alterações não se propagam de volta para a exibição principal.
Björn B.

Respostas:

15

Você precisa de um Publicador que gere eventos sobre alterações no contexto e alguma variável de estado na exibição principal para forçar a reconstrução da exibição no evento de recebimento desse publicador.
Importante: a variável de estado deve ser usada no código do construtor de visualizações, caso contrário, o mecanismo de renderização não saberia que algo mudou.

Aqui está uma modificação simples da parte afetada do seu código, que fornece o comportamento necessário.

@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
fonte
Esperamos que a Apple melhore a integração do Core Data <-> SwiftUI no futuro. Conceder a recompensa à melhor resposta fornecida. Obrigado Asperi.
Darrell Root
Obrigado pela sua resposta! Mas o @FetchRequest deve reagir às alterações no banco de dados. Com sua solução, o modo de exibição será atualizado a cada salvamento no banco de dados, independentemente dos itens envolvidos. Minha pergunta era como fazer o @FetchRequest reagir às alterações que envolvem as relações com o banco de dados. Sua solução precisa de um segundo assinante (o NotificationCenter) em paralelo ao @FetchRequest. Também é necessário usar um gatilho falso adicional `+ (self.refreshing?" ":" ")`. Talvez um @Fetchrequest não seja uma solução adequada?
Björn B.
Sim, você está certo, mas a solicitação de busca criada no exemplo não é afetada pelas alterações feitas ultimamente, por isso não é atualizada / recuperada. Pode haver um motivo para considerar diferentes critérios de solicitação de busca, mas essa é uma pergunta diferente.
Asperi 13/11/19
2
@Asperi Eu aceito sua resposta. Como você declarou, o problema está de alguma forma com o mecanismo de renderização para reconhecer quaisquer alterações. Usar uma referência a um Objeto alterado não é suficiente. Uma variável alterada deve ser usada em uma Visualização. Em qualquer parte do corpo. Mesmo usado em segundo plano na lista funcionará. Eu uso um RefreshView(toggle: Bool)com um único EmptyView em seu corpo. Usar List {...}.background(RefreshView(toggle: self.refreshing))funcionará.
Björn B.
Encontrei uma maneira melhor de forçar a atualização / busca de lista, pois ela é fornecida no SwiftUI: A lista não é atualizada automaticamente após a exclusão de todas as entradas da Entidade de Dados Principais . Apenas no caso de.
Asperi 17/02
1

Tentei tocar no objeto principal na exibição de detalhes assim:

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

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

Em seguida, a lista principal será atualizada. Mas a visualização de detalhes precisa saber sobre o objeto pai. Isso funcionará, mas provavelmente não é a maneira SwiftUI ou Combine ...

Editar:

Com base na solução alternativa acima, modifiquei meu projeto com uma função global save (managedObject :). Isso tocará todas as entidades relacionadas, atualizando todos os @ 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.
fonte