1

I've written this small example of MVVM in a SwiftUI app using CoreData, but I wonder if there are better ways to do this such as using a nested viewcontext?

The object of the code is to not touch the CoreData entity until the user has updated all the fields needed and taps "Save". In other words, to not have to undo any fields if the user enters a lot of properties and then "Cancels". But how do I approach this in SwiftUI?

Currently, the viewModel has @Published vars which take their cue from the entity, but are not bound to its properties.

Here is the code:

ContentView

This view is pretty standard, but here is the NavigationLink in the List, and the Fetch:

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Contact.lastName, ascending: true)],
        animation: .default)

    private var contacts: FetchedResults<Contact>

    var body: some View {    List {
            ForEach(contacts) { contact in
                NavigationLink (
                    destination: ContactProfile(contact: contact)) {
                    Text("\(contact.firstName ?? "") \(contact.lastName ?? "")")
                }
                
            }
            .onDelete(perform: deleteItems)
        } ///Etc...the rest of the code is standard

ContactProfile.swift in full:

import SwiftUI

struct ContactProfile: View {
    
    @ObservedObject var contact: Contact
    @ObservedObject var viewModel: ContactProfileViewModel
    
    init(contact: Contact) {
        self.contact = contact
        self._viewModel = ObservedObject(initialValue: ContactProfileViewModel(contact: contact))
    }
    
    @State private var isEditing = false
    
    @State private var errorAlertIsPresented = false
    @State private var errorAlertTitle = ""
    
    var body: some View {
        
        VStack {
            if !isEditing {
                Text("\(contact.firstName ?? "") \(contact.lastName ?? "")")
                    .font(.largeTitle)
                    .padding(.top)
                Spacer()
            } else {
                Form{
                    TextField("First Name", text: $viewModel.firstName)
                    TextField("First Name", text: $viewModel.lastName)
                }
            }
        }
        .navigationBarTitle("", displayMode: .inline)
        .navigationBarBackButtonHidden(isEditing ? true : false)
        .navigationBarItems(leading:
                                Button (action: {
                                    withAnimation {
                                        self.isEditing = false
                                        viewModel.reset()  /// <- Is this necessary? I'm not sure it is, the code works
                                                                    /// with or without it. I don't see a 
                                                                    /// difference in calling viewModel.reset()
                                    }
                                }, label: {
                                    Text(isEditing ? "Cancel" : "")
                                }),
                            trailing:
                                Button (action: {
                                    if isEditing { saveContact() }
                                    withAnimation {
                                        if !errorAlertIsPresented {
                                            self.isEditing.toggle()
                                        }
                                    }
                                }, label: {
                                    Text(!isEditing ? "Edit" : "Done")
                                })
        )
        .alert(
            isPresented: $errorAlertIsPresented,
            content: { Alert(title: Text(errorAlertTitle)) }) }
    
    private func saveContact() {
        do {
            try viewModel.saveContact()
        } catch {
            errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred"
            errorAlertIsPresented = true
        }
    }
}

And the ContactProfileViewModel it uses:

import UIKit
import Combine
import CoreData
/// The view model that validates and saves an edited contact into the database.
///

final class ContactProfileViewModel: ObservableObject {
    /// A validation error that prevents the contact from being 8saved into
    /// the database.
    
    enum ValidationError: LocalizedError {
        case missingFirstName
        case missingLastName
        var errorDescription: String? {
            switch self {
                case .missingFirstName:
                    return "Please enter a first name for this contact."
                case .missingLastName:
                    return "Please enter a last name for this contact."
            }
        }
    }
    
    @Published var firstName: String = ""
    @Published var lastName: String = ""


    /// WHAT ABOUT THIS NEXT LINE?  Should I be making a ref here
    /// or getting it from somewhere else?

    private let moc = PersistenceController.shared.container.viewContext

    var contact: Contact
    
        init(contact: Contact) {
        self.contact = contact
        updateViewFromContact()
    }
    
    // MARK: - Manage the Contact Form
    
    /// Validates and saves the contact into the database.
    func saveContact() throws {
        if firstName.isEmpty {
            throw ValidationError.missingFirstName
        }
        if lastName.isEmpty {
            throw ValidationError.missingLastName
        }
        contact.firstName = firstName
        contact.lastName = lastName
        try moc.save()
    }
    
    /// Resets form values to the original contact values.
    func reset() {
        updateViewFromContact()
    }
            
    // MARK: - Private
    
    private func updateViewFromContact() {
        self.firstName = contact.firstName ?? ""
        self.lastName = contact.lastName ?? ""
    }
}

Most of the viewmodel code is adapted from the GRDB Combine example. So, I wasn't always sure what to exclude. what to include.

Rillieux
  • 587
  • 9
  • 23
  • NSManagedObject is ObservableObject and NSManaged properties are published, so you can observe you CoreData objects in views directly, where needed. – Asperi Dec 27 '20 at 12:39
  • Yes, but if I try to change them, it seems they are immediately saved to the Entity, passing by managedObjectContext entirely. – Rillieux Dec 27 '20 at 15:57

1 Answers1

1

I have opted to avoid a viewModel in this case after discovering:

moc.refresh(contact, mergeChanges: false)

Apple docs: https://developer.apple.com/documentation/coredata/nsmanagedobjectcontext/1506224-refresh

So you can toss aside the ContactViewModel, keep the ContentView as is and use the following:

Contact Profile

The enum havae been made an extension to the ContactProfile view.

import SwiftUI
import CoreData

struct ContactProfile: View {
    
    @Environment(\.managedObjectContext) private var moc
    
    @ObservedObject var contact: Contact
    
    @State private var isEditing = false
    
    @State private var errorAlertIsPresented = false
    @State private var errorAlertTitle = ""
    
    var body: some View {
        VStack {
            if !isEditing {
                Text("\(contact.firstName ?? "") \(contact.lastName ?? "")")
                    .font(.largeTitle)
                    .padding(.top)
                Spacer()
            } else {
                Form{
                    TextField("First Name", text: $contact.firstName ?? "")
                    TextField("First Name", text: $contact.lastName ?? "")
                }
            }
        }
        .navigationBarTitle("", displayMode: .inline)
        .navigationBarBackButtonHidden(isEditing ? true : false)
        .navigationBarItems(leading:
                                Button (action: {

                                   /// This is the key change:

                                    moc.refresh(contact, mergeChanges: false)
                                    withAnimation {
                                        self.isEditing = false
                                    }
                                }, label: {
                                    Text(isEditing ? "Cancel" : "")
                                }),
                            trailing:
                                Button (action: {
                                    if isEditing { saveContact() }
                                    withAnimation {
                                        if !errorAlertIsPresented {
                                            self.isEditing.toggle()
                                        }
                                    }
                                }, label: {
                                    Text(!isEditing ? "Edit" : "Done")
                                })
        )
        .alert(
            isPresented: $errorAlertIsPresented,
            content: { Alert(title: Text(errorAlertTitle)) }) }
    
    private func saveContact() {
        do {
            if contact.firstName!.isEmpty {
                throw ValidationError.missingFirstName
            }
            if contact.lastName!.isEmpty {
                throw ValidationError.missingLastName
            }
            try moc.save()
        } catch {
            errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred"
            errorAlertIsPresented = true
        }
    }
}

extension ContactProfile {
    enum ValidationError: LocalizedError {
        case missingFirstName
        case missingLastName
        var errorDescription: String? {
            switch self {
                case .missingFirstName:
                    return "Please enter a first name for this contact."
                case .missingLastName:
                    return "Please enter a last name for this contact."
            }
        }
    }
}

This also requires the code below that can be found at this link:

SwiftUI Optional TextField

import SwiftUI

func ??<T>(lhs: Binding<Optional<T>>, rhs: T) -> Binding<T> {
    Binding(
        get: { lhs.wrappedValue ?? rhs },
        set: { lhs.wrappedValue = $0 }
    )
}
Rillieux
  • 587
  • 9
  • 23
  • In my opinion, this is the correct choice because the viewModel is the Contact Class (the Core Data Managed Object). A lot of times, people think of the Contact class as the model, but the model is the database. This is why the Managed Object class is an observable object. The Contact Class is a viewModel because it interprets the model (the database) for the view, and responds to the intents of the view (saving and updating the contact into the database). – Chris Slade Jul 08 '21 at 23:59