Lab 6: SimpleContacts
Due Date: October 13
Objectives
- teach students to save data via SwiftData
- teach students to use the iOS imagePicker for photo integration
Due Date: October 13
Objectives
Open Xcode and create a new Single View App
. Name the app SimpleContacts
, use Swift UI
as the User Interface, but do NOT click any storage option. We will add in what we need for SwiftData in the course of the lab.
We covered this in class, but the idea for this app is very simple: a List view of all your contacts. When you click on a row, you can see details for that contact and can edit that contact information or add a photo. The finished app will look something like this:
Create a folder/group in Xcode called Models
so our code stays organized. Withing that folder, create a Person.swift
file. Open that file and add import SwiftData
.
Add the @Model
macro as shown in lecture and right after, create a class called Person
. For now, this class should have attributes for name, email, and details and all of these are strings.
After creating the attributes, you need an init
method. Xcode makes this super easy -- just type init
and it will suggest the appropriate method with all the proper arguments; select it and you should be set.
In class, we mentioned that there were three parts to the CoreData stack and that SwiftData makes this even easier. Using the macro makes this first step even easier than before. (Right-click on the macro and choose Expand Macro
to see all it's doing behind the scenes.)
As mentioned in class, we need to set up a container that will handle our persistence operations, but once we set it up, we pretty much let it run on its own. To do that, we will open up the SimpleContactsApp.swift
file and again import SwiftData
. Within this file, we see a WindowGroup
within the body
; append to the WindowGroup
the following: .modelContainer(for: Person.self)
We are now done with the second part -- this container get initialized when the app does and it will (among other things) set up our persistence layer. Again, the SwiftData library makes this very easy for us, with considerably less code to write than previously with CoreData.
As mentioned in lecture, the bulk of our work with SwiftData is actually with the model context. We will do this in three views. Create a Views
folder/group and within it, add three SwiftUI files: AppView
, PeopleView
, and EditPersonView
. Get rid of all the preview code at the bottom of each file (SwiftData makes this functionality more challenging to use, so we'll ignore it for now). Also be sure that in addition to import SwiftUI
we also import SwiftData
in all three files.
Going to the AppView
, remember in class we said the primary purpose of this view is to set up our main navigation; previously AppView set up of tabs and initial screen, but here we'll use it to set up our NavigationStack()
. Before we do that, however, we need to add the following attributes:
@Environment(\.modelContext) var modelContext
@State private var path = [Person]()
@State private var searchText = ""
Within body
, add in NavigationStack(path: $path) { }
and put some line breaks within the curly braces. This gives us the basic navigation. We'll come back to that in a moment (much more to do).
After we set up the basic navigation, let's take a moment to create a function that will handle adding a new person. The code below will create a blank person and insert them using our model context:
func addPerson() {
let person = Person(name: "", email: "", details: "")
modelContext.insert(person)
path.append(person)
}
Within the NavigationStack
, add in the line PeopleView(searchString: searchText)
On the next line, we will add in the .navigationTitle("My Contacts")
and then add in underneath that:
.navigationDestination(for: Person.self) { person in
EditPersonView(person: person)
}
After that, we will add a toolbar and a search bar with:
.toolbar {
Button("Add Person", systemImage: "plus", action: addPerson)
}
.searchable(text: $searchText)
You can run this, but really all we see that's useful right now is the nav title on the left and in the upper right, a +
button that will take me to the EditPersonView
We now need to fix PeopleView
so it's not just a generic SwiftUI screen. Start by setting up the model context with the line: @Environment(\.modelContext) var modelContext
.
After that, we will query our data store and get all the people object stored there with the line: @Query var people: [Person]
Once that's done, go into the body
and much like we did for BookManager, create a List
for each person that has a NavigationLink(value: person)
and shows the name of the person in the list. The compiler will complain because EditPersonView
doesn't take a person parameter; to shut it up, go to EditPersonView
, add in the model context to this view, and then add the line @Bindable var person: Person
.
Before we go further, we need an init
method for this struct that will help us with search and filtering. Within the PeopleView
struct, but after the body
is complete, add the following:
init(searchString: String = "") {
}
Now within this, we want to modify our people variable so that it filters with a new query. We don't want $people
here, but we need a different prefix to access the variable itself (not just its value); we are not giving this to you because it was a point of emphasis in lecture. Set this equal to:
Query(filter: #Predicate { person in
if searchString.isEmpty {
true
} else {
person.name.localizedStandardContains(searchString)
}
})
If you don't remember what .localizedStandardContains()
is, be sure to refresh yourself with the option
keyboard helper.
Unfortunately, not much to test yet because I can't actually add any contact data to the system. To rectify this, open EditPersonView
and confirm that SwiftData is imported, the model context is set up and that there is a bindable variable for person within the view.
Within the body
we will create a form, similar to what we did in the BookManager lab. We could just add three text fields for name, email, and details, but to make the form a little nicer, let's break this into two sections.
Create the first section with:
Section("Information") {
}
and the second section with:
Section("Details") {
}
Within each section, create the appropriate text fields as you did in BookManager. This is optional, but there is a method that will make this a little more polished. The method textContentType()
sets the text content type for this view, which the system uses to offer suggestions while the user enters text on an iOS device. To utilize this, at the end of the TextField()
if I add .textContentType()
and within the parens, type .
then a whole host of options appear for different types of text imput. For name, the .name
option is best while email has a .emailAddress
option. Again, not required, but a nice touch that it would make it a little more polished.
Don't forget that at the end of the Form
closure, you want to add:
.navigationTitle("Edit Person")
.navigationBarTitleDisplayMode(.inline)
Now we can do a basic build and add some data to the contacts.
One problem with all this is that contacts are in the order I enter them, rather than the expected alphabetical ordering we're accustomed to.
To fix this, first go back to AppView
. Add a state variable sortOrder = [SortDescriptor(\Person.name)]
so that the default sorting will be alphabetical.
Now within the .toolbar
(I would do this before the add button, but your call), add the following:
Menu("Sort", systemImage: "arrow.up.arrow.down") {
Picker("Sort", selection: $sortOrder) {
Text("Name (A-Z)")
.tag([SortDescriptor(\Person.name)])
Text("Name (Z-A)")
.tag([SortDescriptor(\Person.name, order: .reverse)])
}
}
This will give us the option of ascending or descending sort by name.
Go to the PeopleView
file and in the init
function, we need to add an additional argument to handle the sort order. Add to the arguments of init
: sortOrder: [SortDescriptor<Person>] = []
In the same file, option-click on the Query
method, and see the arguemtns it can take. Then add to the Query
arguments, sort: sortOrder
.
Go to the EditPersonView
and see that nothing here needs to be sorted and relax for a moment. 😉
Okay, break over. Sorting is great, but also need to remove records if I want. This is actually pretty easy. First, go to PeopleView
. Much like we added addPerson()
to AppView
, we are going to add the following delete function to this view:
func deletePeople(at offsets: IndexSet) {
for offset in offsets {
let person = people[offset]
modelContext.delete(person)
}
}
Now that I have that, I can go to the closure associated with ForEach(people)
and add to the end of closure .onDelete(perform: deletePeople)
(this is done after the closing curly brace). Now if I left-swipe any list row cell, I will get an option to remove and it will tell the model context to remove the contact from the system.
The final step is to add photos to our contacts. This seems hard, but actually is quite easy, thanks to Swift libraries that will do the heavy lifting. Also recall what we said in class -- if you want more than the standard generic landscape and flower photos in the simulator, you can just open the simulator's Photos app and drag additional photos into the library. (It will make this more interesting and personal if you do.)
Go to the Person
model and add an optional attribute called photo
that is of type Data
. Because we don't want to save this in the sqlite database and would prefer to do this with external storage, we will prefix this with @Attribute(.externalStorage)
Go to the EditPersonView
and start off by importing PhotosUI
. Within the struct, add a variable to hold the selected picture with the line:
@State private var selectedItem: PhotosPickerItem?
Add another Section
to the form. I suggest that it preceeds the basic info section, because the photo is probably the first thing you want to see.
Within this section, the first thing we need to do is unwrap the optional photo data; we can do that with the following:
if let imageData = person.photo, let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
}
Finally, we want to add the opportunity to pick a new photo. We can do that by adding to this section the following:
PhotosPicker(selection: $selectedItem, matching: .images) {
Label("Select a photo", systemImage: "person")
}
We want the image to be displayed when we actually select a photo; to do this, we need two parts. First, at the end of the Form
, after the navigation title directives, we need to add .onChange(of: selectedItem, loadPhoto)
. Of course, this will raise a compiler error right away as there is no loadPhoto
function. We can fix this by adding to the struct the following:
func loadPhoto() {
Task { @MainActor in
person.photo = try await selectedItem?.loadTransferable(type: Data.self)
}
}
At this point, we have a basic contacts app that saves data locally on our device. In class we extended this further by adding an Event
model and a EditEventView
. If you have the time and are up to the challenge, I'd encourage you to extend this app to add this functionality. Qapla'