UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

HWS+ Ultimate Portfolio App - adding an item using the edit view

Forums > SwiftUI

I'm working on a project using the UPA as my framework and would like to change the user flow a little.

My main app screen displays a list of objects (Patients in this instance). I want to allow the user to add a new patient by tapping a toolbar button which allows them to add the relevant details.

Implementing an edit view was easy enough and editing existing patients works without issue. It is also easy enough to add a new patient in the list view without any automatic modal display of, or navigation to, the edit view. The downside of course is that the user will need to manually go through an additional sequence of steps to edit the newly added object (as was the case in the UPA) which, for my purposes at least, isn't very user friendly.

It's not a problem if I use MVVM but I'm trying to do it using Apple's own "recommended" approach with property wrappers around the MOC, etc. As I can't access the MOC until initialisation of the view is complete, the simplest approach (creating a new Patient if the view isn't provided with one) isn't possible. The current tactic (creating the new object in the parent view) leads to state inconsistency and some very weird behaviour with the NavigationLink being triggered whenever you try to edit the fields in the child.

I'm sure I'm making this unnecessarily complex and am still stuck in an imperative mindset but some guidance would be helpful. Although writing a separate view to handle new patients specifically, I'd like to reuse the editing view to avoid duplication it at all possible.

(BTW the Core Data model is not mature - I know that there should be a one-to-many relationship between owner and patient ;-) )

struct PatientsView: View {
    @EnvironmentObject var dataController: DataController
    @Environment(\.managedObjectContext) var managedObjectContext
    static let tag = "patients"

    @State private var navLinkTag: Int? = 0

    let patients: FetchRequest<Patient>

    init() {
        patients = FetchRequest(entity: Patient.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Patient.creationDate, ascending: false)])
    }

    var body: some View {
        NavigationView {
            List {
                ForEach(patients.wrappedValue) { patient in
                    PatientRow(patient: patient)
                }
                .onDelete { offsets in
                    for offset in offsets {
                        let item = patients.wrappedValue[offset]
                        dataController.delete(item)
                    }
                }
            }
            .navigationTitle("Patients")
            .toolbar {
                ToolbarItem {
                    Button(action: {
                        navLinkTag = 1
                    }, label: {
                            Image(systemName: "plus")
                    })
                    .background(
                        NavigationLink(
                            destination: EditPatientView(patient: newPatient()),
                            tag: 1,
                            selection: $navLinkTag,
                            label: { EmptyView() })
                    )
                }
            }
        }
    }

    private func newPatient() -> Patient {
        let patient = Patient(context: managedObjectContext)
        patient.creationDate = Date()
        patient.name = "New patient"

        dataController.save()

        return patient
    }
}

struct PatientRow: View {
    @ObservedObject var patient: Patient

    var body: some View {
        HStack {
            NavigationLink(destination: PatientActivityView(patient: patient)) {
                VStack {
                    HStack {
                        Text(patient.patientName)
                        Text(patient.patientOwnerName)
                            .bold()
                        Spacer()
                    }

                    HStack {
                        Text(patient.patientSpecies)
                        Text(patient.patientSex)
                        Text(patient.age)
                        Spacer()
                    }
                    .font(.caption)
                }
            }
        }
    }
}

struct EditPatientView: View {
    @EnvironmentObject var dataController: DataController
    @Environment(\.managedObjectContext) var managedObjectContext

    @State var owner: String
    @State var name: String
    @State var species: String
    @State var dob: Date
    @State var sex: String

    let patient: Patient

    init(patient: Patient) {
        self.patient = patient

        _owner = State(wrappedValue: self.patient.patientOwnerName)
        _name = State(wrappedValue: self.patient.patientName)
        _species = State(wrappedValue: self.patient.patientSpecies)
        _sex = State(wrappedValue: self.patient.patientSex)
        _dob = State(wrappedValue: self.patient.patientDateOfBirth)
    }

    var body: some View {
        Form {
            Section(header: Text("Identity")) {
                TextField("Name", text: $name.onChange(update))
                TextField("Owner", text: $owner.onChange(update))
            }

            Section(header: Text("Signalment")) {
                TextField("Species", text: $species.onChange(update))
                TextField("Sex", text: $sex.onChange(update))
                DatePicker("Date of birth",
                           selection: $dob.onChange(update),
                           in: ...Date(),
                           displayedComponents: .date)
                    .datePickerStyle(CompactDatePickerStyle())                }
        }
        .navigationTitle("Edit Patient")
    }

    private func update() {
        patient.name = name
        patient.owner = owner
        patient.species = species
        patient.sex = sex
        patient.dateOfBirth = dob
    }
}

3      

Not sure how much help this could be, as I'm not 100% sure I understand what you want to achive. So, for what it's worth, here goes:

Currently, when we add a new item, it automatically gets populated with some defaults, then the user will have to tap that item to edit it, then tap the navigation button to return to the list. This is most definitely not user friendly, and I do suspect we should be changing that at some point.

What you would like to do is to streamline it for the patients. So that tapping add new patient, as a flow, does not require such tapping.

I would improve the UX by changing 2 things:

1- add new button now creates a new empty patient. blank defaults

2- presents a modal sheet (Edit view) allowing the user to now populate it properly, which when dismissed (or maybe have a save button) automatically updates what needs updating.

This will probably need some tweeking to ensure none of the empty fields have spaces or anything that might be faulty.

I haven't done this yet in the UP app as I don't want to skip ahead and then we end up working on it anyway. But basically tapping the add new patient button should automatically trigger the modal and create a new patient. This feels a lot more natural and like what you would expect the app to do. At least from my point of view.

You could also add an edit button to PatientActivityView which depending on a condition either displays what you currently have there, or the edit view so the user can edit in place.

Hope that helps  😉

3      

Hi @MarcusKay - you've described exactly what I'm after. Tapping the 'plus' in the toolbar creates a new NSManagedObject (Patient) with blank fields (courtesy of a helper extension on Patient) and presents a modal version of the EditPatient view. Dismissing the modal view persists the changes to the Core Data store. As you note, Paul is almost certainly going to address this later but I'm impatient and wanted to see if I could figure it out for myself ;-)

My original approach was as follows:

  1. Changing EditPatient to use Patient? as a state property. PatientsView presents EditPatient without providing a Patient and the idea was to create a new instance in the initialiser. However you don't have access to the managedObjectContext or data controller environment properties inside the initialiser so this isn't possible and you can't intialise the rest of the State without a concrete Patient to work with. I could inject the moc in the initialiser avoiding the need for the environment property but this an inconsistent approach relative to the rest of the app and so I've parked this for the moment
  2. Tapping the button in the PatientsView-> create new Patient instance -> set flag for modal presentation. This kind of works but I get warnings about amending state during a view and some really freaky behaviour where every edit performed in the EditView seems to create separate Patient instances with incremental changes. I'm guessing that when PatientsView is refreshed as the Patient is edited, the cycle of creating a new object and injecting it into the EditView repeats ad infinitum. I tried to store the new Patient instance as an @State property but this didn't seem to resolve it
  3. My next attempt was to use a custom binding associated with the isPresented state variable but, of course, Button doesn't accept a Binding so I didn't get very far with this

3      

You could try to use the modifier .onAppear{} on your modal and set the values there. I believe you can use an EnvironmentObject in there but I'm not sure.

3      

I'm a bit wary of .onAppear (and .onDisappear) as it's called unpredictably and often many times during the View lifecycle. When I've been forced to use it, checks are required to stop repeated execution of code. It's a fair suggestion but it still leaves me with the issue of initialising the other state properties which has to take place in init()

3      

OK, think I've got it. It seems a little hacky but I'm having to dance around the SwiftUI lifecycle. Adding a new Patient instance to the persistent store will invalidate PatientsView causing a refresh of the hierachy and this was the source of my problems before.

I have to say, the time I've spent on this bending my head around the issue has been an interesting exercise but does make me think that data / business logic should definitely be kept completely separate from the view layer when using SwiftUI. MVVM and/or Redux-like architectures would avoid this issue entirely.

All the changes were made within PatientsView so I've been able to leave PatientEditView untouched - on balance I'm reasonably happy but am very open to suggestions on how this could be improved.

//
//  PatientsView.swift
//  AthenaV
//
//  Created by Adrian Ward on 10/11/2020.
//

import CoreData
import SwiftUI

struct PatientsView: View {
    @EnvironmentObject var dataController: DataController
    @Environment(\.managedObjectContext) var managedObjectContext

    @State private var showEditPatient = false
    @State private var newPatient: Patient? = nil /// need to use @State as the PatientView FetchedResult will change when Patient is added or mutated causing the hierachy to be discarded and refreshed. A "normal" property will be re-created leading to multiple new Patient instances and warnings about state amendment during a view update

    private let patients: FetchRequest<Patient>

    init() {
        patients = FetchRequest(entity: Patient.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Patient.creationDate, ascending: false)])
    }

    var body: some View {
        let createNewPatient = Binding<Bool>( /// A custom binding seems to be the best way of creating new Patient instances only when required
            get: { return showEditPatient },
            set: { newValue in
                if !newValue {
                    newPatient = nil /// EditPatient view has been dismissed so discard the Patient
                } else {
                    if newPatient == nil { newPatient = createPatient() } /// New Patient required 
                }
                showEditPatient = newValue
            }
        )

        return NavigationView {
            List {
                ForEach(patients.wrappedValue) { patient in
                    PatientRow(patient: patient)
                }
                .onDelete { offsets in
                    for offset in offsets {
                        let item = patients.wrappedValue[offset]
                        dataController.delete(item)
                    }
                }
            }
            .navigationTitle("Patients")
            .toolbar {
                ToolbarItem {
                    Button(action: {
                        createNewPatient.wrappedValue = true
                    }, label: {
                        Image(systemName: "plus")
                    })
                }
            }
            .sheet(isPresented: $showEditPatient,
                   onDismiss: { createNewPatient.wrappedValue = false }) {
                EditPatientView(patient: newPatient!) /// Patient should never be nil - force unwrap will catch unexpected code path
            }
        }
    }

    private func createPatient() -> Patient {
        let patient = Patient(context: managedObjectContext)
        patient.creationDate = Date()
        patient.name = "New patient"

        dataController.save()

        return patient
    }
}

struct PatientsView_Previews: PreviewProvider {
    static let dataController = DataController.preview

    static var previews: some View {
        PatientsView()
            .environment(\.managedObjectContext, dataController.container.viewContext)
            .environmentObject(dataController)
    }
}

3      

@rustproofFish. Thanks for sharing this very interesting challenge. I've got the same issue. I'm new to Swift, SwiftUI and CoreData so I apologise in advance if any of this is obvious or just plain silly.

Adding a new project to the UTA using withAnimation and the default sort order slides the project into the top of the list. If the list of projects is longer than one screen though, pressing "+" will add a new project but the user would never know because they will never see it slide in. As you've also pointed out, adding the project as a line in a list then having to click on it to edit doesn't make for nice UX.

Your custom binding solution is very interesting and points to a key issue with the way we are using CoreData. We seem to have no visibility/control over the records/objects we create, request or pass as arguments. By this I mean we don't know the ids for the records we create, delete, pass as arguments nor even the number of records we retrieve in a fetch request.

If there were a way to access the SQlite UUID from the MOC when a new record is created and to use that UUID in a fetch request we would be able to create the new record in the main NavigationView and navigationBarItem "+" Button and link to the modal edit view with a fetch request for that UUID. I've started searching for a core data tutorial that might explain how to do this but so far without success.

A brutal and ugly solution would be to have two modal edit views: one to edit existing records and another with almost duplicate code for new records. The new edit view could use .onAppear to create the new record MOC. Ugly....

Another way might be to add a boolean parameter to the modal edit view to ask it to create create a new record/MOC when required. I'm going to try this approach. It's a little less ugly.

Thanks again for your very interesting custom binding solution. Perhaps someone on HWS+ can show us how to access and use the underlying record UUIDs..

3      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.