Reputation: 707
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
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:
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:
import SwiftUI
func ??<T>(lhs: Binding<Optional<T>>, rhs: T) -> Binding<T> {
Binding(
get: { lhs.wrappedValue ?? rhs },
set: { lhs.wrappedValue = $0 }
)
}
Upvotes: 1