1

Problem

When my model housed inside a store class is updated on the main thread asynchronously, a SwiftUI view does not automatically render the slice of the model provisioned by the ViewModel.

Assumed Solution

A custom publisher / promise is needed to link the View/ViewModel/Factory/Store together, as a reference doesn't fire updates on asynchronous updates.

Block

How do I write that? I've tried adding a promise to the Store class, but Xcode warns the Result of call to sink(receiveValue:) is unused. I clearly don't understand promises / publishers and most tutorials use URLSession and its dataTaskPublisher (not my case).

I tried the example in this Apple Dev Forums thread on the factory and store classes, but no dice. I clearly don't understand it. In this answer, @Asperi suggested the View listen to a publisher and update an @State variable, but since my model is private I lack a target for that approach.

Abbreviated Code

  • A factory instantiates a ContactStore class with dependencies; a reference is passed to ViewModels.
  • The VMs gate access to the private store with computed variables. The view calls functions in the ViewModel that modify state, which works well if synchronous.

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)
            }
        }

5
  • Can you show where you tried to add a subscriber. It sounds like you are just missing the call to store(in:) apeth.com/UnderstandingCombine/subscribers/… and a subject in your model for the view model to subscribe to. Commented Aug 22, 2020 at 23:54
  • I'll start reading the link you sent and take a step back. Here's what I wrote earlier that's obviously wrong. imgur.com/a/8HaAzoY Commented Aug 23, 2020 at 1:03
  • @Beginner, in the solution you have in your attached image, you have to store the value produced by .sink, which is AnyCancellable. If you don't, it will immediately cancel the subscription. This is typically done with something like: var c = Set<AnyCancellable>() property, which you then use sink { ... }.store(in: &c). But that just seems like a convoluted way of implementing a delegate pattern Commented Aug 23, 2020 at 1:25
  • Why do you need @Published if you made them private... (actually it is rhetorical)? The purpose of @Published to be interface of ObservableObject so others could observe them. So either remove private and use published as pattern suppose, or remove published (as they don't do anything anyway) and invent manual notification. Commented Aug 23, 2020 at 5:11
  • A ViewModel with @ Published private var + calculated vars exposing specific data in the private published model was something the Stanford CS193p course taught. The publisher still causes the calculated variables to update the view. Maybe it's not a standard pattern? It prevents my view from calling a function in the model or seeing data in a different format than it needs. The @NewDev solution of another completion handler + objectWillChange.send() in the VM works. Commented Aug 23, 2020 at 6:28

1 Answer 1

2

I assume that what you mean by not working is that the ForEach doesn't display the imported contacts.

The problem is that while you assigned new values to ContactStore.contacts, this isn't detected as a change in OnboardingVM. The @Published property contactStore hasn't changed because it's a class - a reference-type.

What you need to do is to write code to react to this change manually.

One thing you could do is to have another handler that would be called when new contacts were added, and upon receiving the news, invoke objectWillChange.send, which would let the observing view know that this object will change (and would then recompute its body)

func processAddressBookAndGoToNextScreen() {
   contactStore.requestAccessAndImportContacts(
      onAccessResponse: { didAllow in
        ...
      }, 
      onContactsImported: { 
         self.objectWillChange.send() // <- manually notify of change
      }
   )
}

(You would obviously need to make some modifications to requestAccessAndImportPhoneContacts and importContacts to actually invoke the onContactsImported handler that I added)

There are other approaches to notify (e.g. using a delegate).

You could use a publisher to notify, but it doesn't seem useful here, since this is a one-time import, and a publisher/subscriber seems like an overkill.

Sign up to request clarification or add additional context in comments.

1 Comment

Your advice makes sense (adding another completion handler). One other function (saving/modifying a photo) will be async, so that's a simpler approach.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.