Example Project

Local Directory Example

I have rewritten the first version of the Directory program as a SwiftUI app.

The first view in the app displays a list of people.

Clicking the + button in the navigation bar takes the user to a second view where they can enter details for a new person.

Model classes

This app will feature the use of a model class that will be responsible for storing the app's data.

Individual entries in the directory are represented by Person objects.

class Person : Codable, Identifiable {
    var id = UUID()
    var name : String
    var office : String

    init(name n : String, office o : String) {
        name = n
        office = o
    }
}

To make it possible to display Person objects in a SwiftUI list, we need to meet the requirement that each object have a unique id. We meet this requirement by having our Person class implement the Identifiable protocol and by giving our Person class an id property. The UUID() function generates a unique identifier for each person on demand.

The data model class for our application is the Directory class. A Directory stores a list of Person objects and includes methods for reading and writing the app's data to a property list file.

class Directory : ObservableObject {
    @Published var people : [Person]
    @Published var newPerson : Person = Person(name:"",office:"")

    let itemArchiveURL: URL = {
        let documentsDirectories =
            FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        let documentDirectory = documentsDirectories.first!
        return documentDirectory.appendingPathComponent("directory.plist")
    }()

    init() {
        do {
            let data = try Data(contentsOf: itemArchiveURL)
            let unarchiver = PropertyListDecoder()
            let loc = try unarchiver.decode(Array<Person>.self, from: data)
            people =  loc
        } catch {
            people =
            [Person(name:"Acacia Ackles",office:"Steitz 131"),
             Person(name:"Joe Gregg",office:"Briggs 413"),
             Person(name:"Kurt Krebsbach",office:"Briggs 411")
             ]
        }
    }

    func makeNewPerson() {
        newPerson = Person(name:"",office:"")
        people.append(newPerson)
    }

    func delete(at offsets: IndexSet) {
        people.remove(atOffsets: offsets)
    }

    func saveChanges() {
        do {
            let encoder = PropertyListEncoder()
            let data = try encoder.encode(people)
            try data.write(to: itemArchiveURL)
        } catch {
        }
    }
}

Working with the data model class

In examples up to this point we have seen that views make use of @State variables. A special characteristic of @State variables is that they automatically watch for changes. Changing the value of a @State variable causes the view containing the variable to be redrawn.

When we place our Directory data model object in a view we will no longer be using @State with the property that stores this object. Instead, we are going to use an alternative approach:

  1. We start by making the Directory class implement the ObservableObject protocol.
  2. ObservableObject classes include one or more properties tagged with @Published. Like @State properties, @Published properties are observed by SwiftUI. Changes to @Published properties will trigger redraws of views that depend on them.
  3. When we store an ObservableObject as a property in a view, we will tag that property with either the @ObservedObject or @StateObject property wrappers. This tells SwiftUI that the object in question contains @Published properties that the system needs to watch for changes.

The first view

Here is the code for the first view in the app. This view sets up the navigation controller and the first view displayed in that controller. That first view displays the list of people in our directory.

struct ContentView: View {
    @StateObject var directory : Directory
    @State var isShowingDetailView = false

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: PersonView(d:directory), isActive: $isShowingDetailView) { EmptyView() }
                List {
                    ForEach(directory.people) { p in
                        HStack {
                            Text(p.name)
                            Spacer()
                            Text(p.office)
                        }
                    }.onDelete(perform:delete)
                }
            }.navigationBarItems(leading: EditButton(),trailing: Button("+"){
                directory.makeNewPerson()
                isShowingDetailView = true;
            }
            )
        }
    }

    func delete(at offsets: IndexSet) {
        directory.delete(at:offsets)
        directory.saveChanges()
    }
}

To create a new entry in the directory the user will click the + button in the navigation bar. Normally we would use a NavigationLink to implement the + button. In this case we use a button instead. The reason for this is that a NavigationLink simply moves you to a new view: if you need to perform additional actions before doing the navigation you will need a button's action closure to do that.

To implement the navigation action in the + button we use this strategy.

  1. We start by placing the NavigationLink that will navigate us to the second view in the body of our view. The destination: parameter sets up the navigation to the second view.
  2. We set up the body of the NavigationLink to be an EmptyView(). This effectively makes the NavigationLink invisible in the view.
  3. When we set up the NavigationLink we also use the isActive: parameter to control whether or not the link is active. For that parameter we pass a reference to a boolean state variable that is initially set to false.
  4. When we want to trigger the navigation we switch the value of the boolean state variable to true. This causes the navigation link to fire and takes us to the second view.

Another feature that this view demonstrates is the delete gesture for list items. In the code that sets up the list items I have used the onDelete(perform:) modifer. This will call our view's delete() method when the user asks to delete a list item.

The second view

The second view allows the user to enter the details for a new person they have just created.

struct PersonView: View {
    @StateObject var d : Directory

    var body: some View {
        VStack {
            HStack {
                Text("Name:").frame(width:60,height:20,alignment: .trailing)
                TextField("",text:$d.newPerson.name).textFieldStyle(.roundedBorder)
            }
            HStack {
                Text("Office:").frame(width:60,height:20,alignment: .trailing)
                TextField("",text:$d.newPerson.office).textFieldStyle(.roundedBorder)
            }
            Button("Save Changes"){
                d.saveChanges()
            }.buttonStyle(.borderedProminent).padding()
        }
    }
}

The code for the + button in the first view calls the Directory's makeNewPerson() method. This creates a new Person object and stores that new Person in the Directory's newPerson property for easy access. This second view will access that newPerson property and link its name and office properties to the two text fields where the user can enter that data.