Come licenziare un editore dopo l'aggiornamento asincrono in SwiftUI?
Problema
Quando il mio modello ospitato all'interno di una classe negozio viene aggiornato sul thread principale in modo asincrono, una vista SwiftUI non esegue automaticamente il rendering della fetta del modello fornita da ViewModel.
Soluzione presunta
È necessario un publisher / promessa personalizzato per collegare insieme View / ViewModel / Factory / Store, poiché un riferimento non attiva gli aggiornamenti sugli aggiornamenti asincroni.
Bloccare
Come lo scrivo? Ho provato ad aggiungere una promessa alla classe Store, ma Xcode avvisa che il risultato della chiamata a sink (receiveValue :) non è utilizzato. Chiaramente non capisco le promesse / gli editori e la maggior parte dei tutorial utilizza URLSession e il suo dataTaskPublisher (non il mio caso).
Ho provato l'esempio in questo thread di Apple Dev Forum sulle classi factory e store, ma senza dadi. Chiaramente non lo capisco. In questa risposta, @Asperi ha suggerito di ascoltare la vista a un editore e aggiornare una variabile @State, ma poiché il mio modello è privato mi manca un target per quell'approccio.
Codice abbreviato
- Una factory crea un'istanza di una classe ContactStore con dipendenze; un riferimento viene passato a ViewModels.
- Le VM aprono l'accesso all'archivio privato con variabili calcolate. La visualizzazione chiama le funzioni nel ViewModel che modificano lo stato, che funziona bene se sincrono.
Factory.swift
import SwiftUI
import Combine
class MainFactory {
init() {
...
self.contactStore = ContactStore()
}
private var preferences: Preferences
private var contactStore: ContactStore
...
func makeOnboardingVM() -> OnboardingVM {
OnboardingVM(preferences: preferences, contactStore: contactStore)
}
}
ContactStore.swift
final class ContactStore {
private(set) var authorizationStatus: CNAuthorizationStatus = .notDetermined
private(set) var contacts: [Contact] = [Contact]()
private(set) var errors: [Error] = [Error]()
private lazy var initialImporter = CNContactImporterForiOS14(converter: CNContactConverterForiOS14(),
predictor: UnidentifiedSelfContactFavoritesPredictor())
}
// MARK: - IMPORT
extension ContactStore {
/// Full import during app onboarding. Work conducted on background thread.
func requestAccessAndImportPhoneContacts(completion: @escaping (Bool) -> Void) {
CNContactStore().requestAccess(for: .contacts) { [weak self] (didAllow, possibleError) in
guard didAllow else {
DispatchQueue.main.async { completion(didAllow) }
return
}
DispatchQueue.main.async { completion(didAllow) }
self?.importContacts()
}
}
private func importContacts() {
initialImporter.importAllContactsOnUserInitiatedThread { [weak self] importResult in
DispatchQueue.main.async {
switch importResult {
case .success(let importedContacts):
self?.contacts = importedContacts
case .failure(let error):
self?.errors.append(error)
}
}
}
}
}
OnboardingViewModel.swift
import SwiftUI
import Contacts
class OnboardingVM: ObservableObject {
init(preferences: Preferences, contactStore: ContactStore) {
self.preferences = preferences
self.contactStore = contactStore
}
@Published private var preferences: Preferences
@Published private var contactStore: ContactStore
var contactsAllImported: [Contact] { contactStore.contacts }
func processAddressBookAndGoToNextScreen() {
contactStore.requestAccessAndImportContacts() { didAllow in
DispatchQueue.main.async {
if didAllow {
self.go(to: .relevantNewScreen)
else { self.go(to: .relevantOtherScreen) }
}
}
}
...
}
View.swift
struct SelectEasiestToCall: View {
@EnvironmentObject var onboarding: OnboardingVM
var body: some View {
VStack {
ForEach(onboarding.allContactsImported) { contact in
SomeView(for: contact)
}
}
Risposte
Presumo che ciò che intendi per non funzionante è che il ForEach
non visualizza i contatti importati.
Il problema è che mentre hai assegnato nuovi valori a ContactStore.contacts
, questo non viene rilevato come una modifica in OnboardingVM
. La @Published
proprietà contactStore
non è cambiata perché è un class
tipo di riferimento.
Quello che devi fare è scrivere il codice per reagire manualmente a questa modifica.
Una cosa che potresti fare è avere un altro gestore che venga chiamato quando vengono aggiunti nuovi contatti, e dopo aver ricevuto la notizia, invocare objectWillChange.send
, che farebbe sapere alla vista osservante che questo oggetto cambierà (e quindi ricalcolerebbe il suo corpo)
func processAddressBookAndGoToNextScreen() {
contactStore.requestAccessAndImportContacts(
onAccessResponse: { didAllow in
...
},
onContactsImported: {
self.objectWillChange.send() // <- manually notify of change
}
)
}
(Ovviamente dovresti apportare alcune modifiche requestAccessAndImportPhoneContacts
e importContacts
richiamare effettivamente il onContactsImported
gestore che ho aggiunto)
Esistono altri approcci per la notifica (ad esempio utilizzando un delegato).
Si potrebbe usare un editore per notificare, ma non mi sembra utile qui, dal momento che si tratta di un'importazione di una volta, e un editore / sottoscrittore sembra un eccessivo.