0

I'm writing a simple password manager as a learning exercise to get familiar with SwiftUI. So far so good, however I'm running into an issue where my View is not being updated as expected after editing a value. There are 2 Views that I'm dealing with right now: one for displaying a secret, and another for editing its properties. I'm using the MVVM pattern for my underlying data model, with Core Data/Combine being used for accessing the datastore. My "display" View Model looks like this:

class SecretDisplayViewModel: BaseViewModel, ObservableObject {
    
    private let secretIdentifier: String
    
    @Published var secret: Secret? = nil
    
    private var cancellables = [AnyCancellable]()

    init(managedObjectContext: NSManagedObjectContext, secretIdentifier: String) {
        self.secretIdentifier = secretIdentifier
        super.init(managedObjectContext: managedObjectContext)
    }
    
    init(managedObjectContext: NSManagedObjectContext, secret: Secret) {
        self.secretIdentifier = secret.id
        self.secret = secret
        super.init(managedObjectContext: managedObjectContext)
    }
    
    func loadSecret() {
        let _ = secretsService.loadSecret(secretIdentifier: self.secretIdentifier)
            .map({ (entities: [SecretEntity]) -> [Secret] in
                entities.compactMap { $0.toSecret() }
            })
            .replaceError(with: []) // FIXME: How should I handle errors?
            .sink { completion in
                if case .failure(let error) = completion {
                    // TODO: Handle error
                    print("ERROR: \(error)")
                }
            } receiveValue: { [weak self] secrets in
                guard let strongSelf = self else {
                    return
                }
                
                if secrets.count > 0 {
                    strongSelf.secret = secrets[0]
                    print("strongSelf.secret=\(String(describing: strongSelf.secret))")
                }
            }.store(in: &cancellables)
    }
}

...and the display View is set to call loadSecret() through onAppear like this:

struct SecretDisplayView: View {

    <snip>

    @ObservedObject var viewModel: SecretDisplayViewModel
    
    @State private var isEditing: Bool = false
    
    var body: some View {
        buildViewForSecret(secret: viewModel.secret)
            .listStyle(GroupedListStyle())
            .navigationTitle("Secret")
            .onAppear {
                if isPreview {
                    // Do nothing
                } else {
                    self.viewModel.loadSecret()
                }
            }
            .toolbar {
                Button("Edit", action: { self.isEditing.toggle() })
            }
            .sheet(isPresented: $isEditing, onDismiss: {
                // FIXME: Secret not refreshed after dismiss
                self.viewModel.loadSecret()
            }, content: {
                if let secret = self.viewModel.secret {
                    SecretCreateEditView(editViewModel: SecretEditViewModel(managedObjectContext: self.viewContext,
                                                                            secret: secret))
                } else {
                    EmptyView()
                }
            })
    }

@ViewBuilder
private func buildViewForSecret(secret: Secret?) -> some View {
    List {
        if let secretImpl = secret {
            SecretDisplayHeaderView(name: secretImpl.name, category: secretImpl.category.title)
        } else {
            EmptyView()
        }
        
        if let secretImpl = secret as? LoginSecret {
            LoginSecretDisplayForm(secret: secretImpl)
        } else if let secretImpl = secret as? BankAccountSecret {
            BankAccountSecretDisplayForm(secret: secretImpl)
        } else if let secretImpl = secret as? CreditCardSecret {
            CreditCardSecretDisplayForm(secret: secretImpl)
        } else if let secretImpl = secret as? WifiNetworkSecret {
            WifiNetworkSecretDisplayForm(secret: secretImpl)
        } else if let secretImpl = secret as? RewardProgramSecret {
            RewardProgramSecretDisplayForm(secret: secretImpl)
        } else {
            // TODO: Display a loading view or something
            EmptyView()
        }
        
        if let notes = secret?.notes {
            Section(header: Text("Notes")) {
                TextEditor(text: Binding.constant(notes))
            }
            .isHidden(notes.isEmpty)
        }
        
        // TODO: Secret actions (favorite/delete/share/etc.)
    }
}
    <snip>
}

This works just fine, and my secret is displayed as expected. As you see, the edit View is presented as a sheet when the user hits the Edit button. The edit view model looks like this:

class SecretEditViewModel: SecretCreateViewModel {
    
    @Published var secret: Secret
    
    init(managedObjectContext viewContext: NSManagedObjectContext, secret: Secret) {
        self.secret = secret
        super.init(managedObjectContext: viewContext)
        
        self.name = secret.name
        self.notes = secret.notes
        
        <snip>
    }
    
    func updateSecret() {
        let updatedSecret = self.buildSecret(secretCategory: secret.category,
                                             secretIdentifier: secret.id,
                                             createdDate: secret.createdDate)
        
        secretsService.updateSecret(secret: updatedSecret)
    }
    
}

...and my edit View looks like this:

struct SecretCreateEditView: View {
    
    <snip>

    @ObservedObject var viewModel: SecretCreateViewModel
    
    @State var secretCategory: SecretCategory?
    
    private let editMode: Bool
    
    init(viewModel: SecretCreateViewModel) {
        self.viewModel = viewModel
        self.editMode = false
    }
    
    init(viewModel: SecretCreateViewModel, secretCategory: SecretCategory) {
        self.viewModel = viewModel
        _secretCategory = State(initialValue: secretCategory)
        self.editMode = false
        self.viewModel.secretCategory = secretCategory
    }
    
    init(editViewModel: SecretEditViewModel) {
        self.viewModel = editViewModel
        _secretCategory = State(initialValue: editViewModel.secret.category)
        self.editMode = true
    }
    
    var body: some View {
        NavigationView {
            List {
                buildViewForSecretCategory(secretCategory: self.secretCategory)
                
                Section(header: Text("Notes")) {
                    TextEditor(text: $viewModel.notes)
                        .lineLimit(10)
                }
                .isHidden(secretCategory == nil)
            }
            .listStyle(GroupedListStyle())
            .navigationTitle("Edit Secret")
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    Button("Cancel") {
                        self.presentationMode.wrappedValue.dismiss()
                    }
                }
                
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: saveSecret) {
                        Text(self.editMode ? "Done" : "Save")
                    }
                    .disabled(self.secretCategory == nil || self.viewModel.name.isEmpty)
                }
            }
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }
    
    // MARK: - Private methods
    
    private func saveSecret() {
        if let model = self.viewModel as? SecretEditViewModel {
            model.updateSecret()
            self.presentationMode.wrappedValue.dismiss()
        } else {
            if let _ = self.secretCategory {
                self.viewModel.saveSecret()
                self.presentationMode.wrappedValue.dismiss()
            } else {
                // TODO: Handle error
            }
        }
    }
    
    <snip>
}

As you can see in the 2 Views, when my edit View is dismissed the display View will call self.viewModel.loadSecret() to refresh the View Model with the value from Core Data. If I set a breakpoint or print out a description of the View Model I see the values being updated as expected in the underlying data model. However, my View is not updated! For example, if I change the name of the secret or the username, the data in the View Model is shown to be correct after the edit View is dismissed, but the display View itself is not updated with the appropriate new values. If I go back in my NavigationView stack to the list of secrets (code not shown because it's not necessary) and then view the secret in question again the values are updated as expected. Just not immediately following an edit, even though the data itself is updated.

Am I missing something here? I am new to @State, @Published, @ObservedObject, etc. so I very well could be misunderstanding something. Any help you can give is greatly appreciated!

Shadowman
  • 11,150
  • 19
  • 100
  • 198
  • Maybe this will be useful for you: https://stackoverflow.com/a/62919526/4423545 – Andrew_STOP_RU_WAR_IN_UA Apr 06 '21 at 20:22
  • @Andrew what in particular is "wrong"? My view model is a class, the fields in my view model (particularly the secret) are structs, and I'm using Published correctly. – Shadowman Apr 06 '21 at 20:26
  • Your view shouldn't "pull" the updated value. Your model should "push" it. When you save or update the secret in your model, you should call `send` on the published value. Also, your view model shouldn't be performing – Paulw11 Apr 06 '21 at 20:28
  • @Paulw11 that certainly makes sense. However, I'm dealing with 2 separate view models, one for display and one for edit. How would such a setup then `send` the published value after an update? – Shadowman Apr 06 '21 at 20:30
  • Your view models shouldn't be performing the core data. You should have a model that actually does the work. Your view model should act as an "adapter" between the model and what the view needs from the model. In this case I don't think you really need a view model, but you could create them for completeness. Your view model would use `Combine` to subscribe to updates from the model and then use `@Published` to expose change to the view. A call to `updateSecret` in your view model would result in your view model calling `updateSecret` on the model. – Paulw11 Apr 06 '21 at 20:32
  • @Paulw11 so my view model is calling a separate service that actually interacts with core data. I'm following the MVVM example found here: https://nalexn.github.io/clean-architecture-swiftui/. Is there a better way or example for accomplishing this? – Shadowman Apr 06 '21 at 20:35
  • Ok, following that approach, the `SecretService` is what should know about core data and perform the operations (which is kind of what you have). You should pass an instance of the SecretService, not the managed object context to your view model. The secret service should update the appstate. Your view models subscribes to this and exposes it to the views via `@Published`. If you do it right the only thing that should know about CoreData is the SecretService. You should be able to create an `ArraySecretService`, pass that to your view models and everything else works – Paulw11 Apr 06 '21 at 20:41
  • The AppState object is what you are missing – Paulw11 Apr 06 '21 at 20:43

0 Answers0