Rillieux
Rillieux

Reputation: 707

How (or should?) I replace MVVM in this CoreData SwiftUI app with a Nested managedObjectContext?

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.

Upvotes: 1

Views: 1058

Answers (1)

Rillieux
Rillieux

Reputation: 707

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

Upvotes: 1

Related Questions