Lab 6: MyContacts
Due Date: October 15
Objectives
- teach students to save data via CoreData
- teach students to use the iOS imagePicker for photo integration
Due Date: October 15
Objectives
NOTE: Apple has changed the CoreData setup using the new App
protocol. This version of the lab assumes that we are using CoreData, but instead of having a MyContactsApp
which adopts the App
protocol, we have an AppDelegate.swift
file and a SceneDelegate
(both which are now replaced by the new protocol, but still work fine with SwiftUI). To help with this, we are giving you below the complete code for these two delegates; be sure to delete MyContactsApp
file if you use the AppDelegate
and SceneDelegate
.
In this lab, you'll write an app using Core Data. By the end of this lab, you will know how to:
Open Xcode and create a new Single View App
. Name the app MyContacts
, use Swift UI
as the User Interface, use UIKit App Delegate
for Lifecycle, and make sure Use Core Data
is checked.
Checking the Use Core Data
box will cause Xcode to generate boilerplate code for what's known as an NSPersistentContainer
in AppDelegate.swift
. The NSPersistentContainer
consists of a set of objects that facilitate saving and retrieving information from Core Data. You can read more about it here.
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. The finished app will look something like this:
Go into the AppDelegate.swift
file and notice a new module imported: CoreData
. If we scroll down, we see the creation of our NSPersistentContainer
and the saveContext()
method, which we will use to help us save data. If you recently moved to the new Xcode version, you will not have this file. Create AppDelegate.swift
and paste in the following contents:
import UIKit
import CoreData
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
// MARK: - Core Data stack
lazy var persistentContainer: NSPersistentContainer = {
/*
The persistent container for the application. This implementation
creates and returns a container, having loaded the store for the
application to it. This property is optional since there are legitimate
error conditions that could cause the creation of the store to fail.
*/
let container = NSPersistentContainer(name: "Contacts")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
// MARK: - Core Data Saving support
func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}
You will also need to add @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
as a variable to YourAppName.swift
to configure your new AppDelegate.swift
file.
import SwiftUI
import UIKit
extension SceneDelegate: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Get the managed object context from the shared persistent container.
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
// Create the SwiftUI view and set the context as the value for the managedObjectContext environment keyPath.
// Add `@Environment(\.managedObjectContext)` in the views that will need the context.
let contentView = ContentView().environment(\.managedObjectContext, context)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
let tapGesture = AnyGestureRecognizer(target: window, action:#selector(UIView.endEditing))
tapGesture.requiresExclusiveTouchType = false
tapGesture.cancelsTouchesInView = false
tapGesture.delegate = self
window.addGestureRecognizer(tapGesture)
self.window = window
window.makeKeyAndVisible()
}
}
func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
}
func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call).
}
func sceneWillEnterForeground(_ scene: UIScene) {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
}
func sceneDidEnterBackground(_ scene: UIScene) {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
// Save changes in the application's managed object context when the application transitions to the background.
(UIApplication.shared.delegate as? AppDelegate)?.saveContext()
}
}
By selecting the Use Core Data Option
, we invoked the creation of a new file in our project: MyContacts.xcdatamodeld
. Click on this file (it has a little database icon) to see the UI for managing entities within Core Data. We will come back to this file to set up out Contact entity.
Create a file called Contact.swift
.
import SwiftUI
class Contact: Identifiable {
// MARK: Properties
var name: String = "Batman" // let's be honest, we all want Batman as a contact
var email: String?
var phone: String?
var picture: Image?
init(name: String = "Batman", email: String? = nil, phone: String? = nil, imagePath: String? = nil) {
self.name = name
self.email = email
self.phone = phone
if let image = imagePath { // Pass the name of an xcasset image here (use this for your previews!)
self.picture = Image(image)
}
}
func displayEmail() -> String {
return email ?? "n/a"
}
func displayPhone() -> String {
return phone ?? "n/a"
}
}
This is very simple class to save basic attributes of contacts.
Now, let's create the View Model. Create a new file called ViewModel.swift
. Fill in saveContact()
with code that will add a new contact with those fields to the array.
import Photos
import SwiftUI
import UIKit
class ViewModel: ObservableObject {
@Published var contacts = [Contact]()
func saveContact(name: String, phone: String?, email: String?, picture: UIImage?) {
// create a new Contact object
// make sure you convert the UIImage to an Image
// add it to the `contacts` array
}
}
First, create the view for a single contact's row. To do this, create a new SwiftUI View called ContactRow.swift
. Create a very simple view that takes a Contact
object and displays the contact's image (if availible) and the contact's name.
Next, create the view to see more information/details on the contact (ContactDetail.swift
). It should take a Contact
object, display their name, their picture (if availible), and their email and phone number. Make sure to use the displayEmail()
and displayPhone()
methods from the model to handle cases where these fields are nil.
Here is an example detail view, but feel free to modify this as you see fit:
import SwiftUI
struct ContactDetail: View {
var contact: Contact
let width = UIScreen.main.bounds.width * 0.75
var body: some View {
VStack {
contact.picture?
.resizable()
.scaledToFit()
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color.white, lineWidth: 4)
.shadow(radius: 10)
)
.frame(width: width, height: width, alignment: .center)
.padding()
HStack {
Text("phone:")
.fontWeight(.bold)
.padding(.leading)
Spacer()
Text(contact.displayPhone())
.padding(.trailing)
}.padding()
HStack {
Text("email:")
.fontWeight(.bold)
.padding(.leading)
Spacer()
Text(contact.displayEmail())
.padding(.trailing)
}.padding()
Spacer()
}.navigationBarTitle(contact.name)
}
}
Next, create the view to add a new Contact (AddContact.swift
).
Here is an example of a page to add a new Contact. Notice that it takes a ViewModel
object and calls the saveContact()
function we wrote earlier.
import SwiftUI
import UIKit
struct AddContact: View {
var viewModel: ViewModel
@State var name: String = ""
@State var phone: String = ""
@State var email: String = ""
@State var showImagePicker: Bool = false
@State var image: UIImage? = nil
var displayImage: Image? {
if let picture = image {
return Image(uiImage: picture)
} else {
return nil
}
}
var body: some View {
VStack {
HStack {
Text("name:")
.fontWeight(.bold)
.padding(.leading)
TextField("full name", text: $name)
.padding(.trailing)
}.padding()
HStack {
Text("phone:")
.fontWeight(.bold)
.padding(.leading)
TextField("phone number", text: $phone)
.keyboardType(.phonePad)
.padding(.trailing)
}.padding()
HStack {
Text("email:")
.fontWeight(.bold)
.padding(.leading)
TextField("email address", text: $email)
.keyboardType(.emailAddress)
.padding(.trailing)
}.padding()
displayImage?.resizable().scaledToFit().padding()
Button(action: {
self.showImagePicker = true
}) {
Text(buttonText())
}.padding()
Spacer()
}
.sheet(isPresented: $showImagePicker) {
PhotoCaptureView(showImagePicker: self.$showImagePicker, image: self.$image)
}
.navigationBarTitle("New Contact")
.navigationBarItems(trailing:
Button(action: {
self.viewModel.saveContact(name: self.name, phone: self.phone, email: self.email, picture: self.image)
}) {
Text("Done")
}
)
}
// MARK: View Helper Functions
func buttonText() -> String {
return image == nil ? "Add Contact Picture" : "Change Contact Picture"
}
}
Notice the different keyboard types being used.
Now, Xcode probably is not happy with the PhotoCaptureView
since it does not yet exist. Add in following files; they will allow us to use the photo picker (like in Combinestagram):
PhotoCaptureView.swift
import SwiftUI
struct PhotoCaptureView: View {
@Binding var showImagePicker: Bool
@Binding var image: UIImage?
var body: some View {
ImagePicker(isShown: $showImagePicker, image: $image)
}
}
ImagePicker.swift
import UIKit
import SwiftUI
struct ImagePicker: UIViewControllerRepresentable {
@Binding var isShown: Bool
@Binding var image: UIImage?
func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
}
func makeCoordinator() -> ImagePickerCordinator {
return ImagePickerCordinator(isShown: $isShown, image: $image)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
return picker
}
}
ImagePickerCoordinator.swift
import UIKit
import SwiftUI
class ImagePickerCordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
@Binding var isShown: Bool
@Binding var image: UIImage?
init(isShown: Binding<Bool>, image: Binding<UIImage?>) {
_isShown = isShown
_image = image
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage
isShown = false
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
isShown = false
}
}
Now, let's bring all these views together in the ContentView.swift
. The code for the List view is really quite simple. Take a look at it below:
import SwiftUI
struct ContentView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
NavigationView {
List {
ForEach(viewModel.contacts) { contact in
NavigationLink(destination: ContactDetail(contact: contact)) {
ContactRow(contact: contact)
}
}
}
.navigationBarTitle("Contacts")
.navigationBarItems(trailing:
NavigationLink(destination: AddContact(viewModel: viewModel)) {
Image(systemName: "plus")
}
)
}
}
}
Now try out your app! You should be able to add Contacts, see them in the list, and see their detail pages. Note that there are a few issues with the Add Contact view:
Done
button in the Add Contact view does not bring you back to the list viewBoth of are inconsistent with users' mental models, so let's fix them. To fix the first, add this variable to your AddContact.swift
:
@Environment(\.presentationMode) var mode: Binding<PresentationMode>
And right after you call self.viewModel.saveContact()
, call:
self.mode.wrappedValue.dismiss()
This will bring you back to the List view when you press the Done
button.
To fix the second issue with the text fields, create a new file called AnyGestureRecognizer.swift
and add in the following code:
import UIKit
class AnyGestureRecognizer: UIGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
//To prevent keyboard hide and show when switching from one textfield to another
if let textField = touches.first?.view, textField is UITextField {
state = .failed
} else {
state = .began
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
state = .ended
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
state = .cancelled
}
}
Navigate to SceneDelegate.swift
. If you are using a newer version of Xcode and do not have this file, skip this part.
Now, in the SceneDelegate.swift
, add in the following code between window.rootViewController = UIHostingController(rootView: contentView)
and self.window = window
in the scene()
method.
let tapGesture = AnyGestureRecognizer(target: window, action:#selector(UIView.endEditing))
tapGesture.requiresExclusiveTouchType = false
tapGesture.cancelsTouchesInView = false
tapGesture.delegate = self
window.addGestureRecognizer(tapGesture)
Also, add the UIGestureRecognizerDelegate
extension outside of the SceneDelegate
class.
extension SceneDelegate: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
Try out these changes and notice how much better the UX is! But notice that if you kill the app, all your data is lost :( Let's fix this using Core Data!!
Here, after the main UI functionality has been built, we will walk through the process of setting up entities in CoreData, saving data, and loading data.
Xcode conveniently sets up a GUI by which we can work with to set up entities for our application. This is located in the Contacts.xcdatamodeld
file.
Create a new entity named Person
with five attributes (note: this cannot be called Contact
):
Here is a picture of what your entity should look like.
First add import CoreData
to the top of your ViewModel.swift
file.
Then, at the top of the ViewModel class add let appDelegate: AppDelegate = AppDelegate()
.
Augment your saveContact()
method by replacing the current code with the following:
let newContact = Contact()
newContact.name = name
if let phoneTemp = phone {
if phoneTemp != "" {
newContact.phone = phoneTemp
} else {
newContact.phone = nil
}
}
if let emailTemp = email {
if emailTemp != "" {
newContact.email = emailTemp
} else {
newContact.email = nil
}
}
if let pictureTemp = picture {
newContact.picture = Image(uiImage: pictureTemp)
}
let context = appDelegate.persistentContainer.viewContext
if let entity = NSEntityDescription.entity(forEntityName: "Person", in: context) {
let newUser = NSManagedObject(entity: entity, insertInto: context)
newUser.setValue(newContact.name, forKey: "name")
newUser.setValue(newContact.phone, forKey: "phone")
newUser.setValue(newContact.email, forKey: "email")
if let pic = picture {
newUser.setValue(pic.pngData(), forKey: "picture")
}
do {
try context.save()
} catch {
NSLog("[Contacts] ERROR: Failed to save Contact to CoreData with name: \(newContact.name)")
}
self.contacts.append(newContact)
}
Now we need to populate the List with Contacts from CoreData. Let's do this by defining a fetchContacts()
method in the ViewModel.swift
.
func fetchContacts() {
let context = appDelegate.persistentContainer.viewContext
let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Person")
request.returnsObjectsAsFaults = false
do {
let result = try context.fetch(request)
for data in result as! [NSManagedObject] {
let newContact = Contact()
newContact.name = data.value(forKey: "name") as? String ?? ""
newContact.email = data.value(forKey: "email") as? String
newContact.phone = data.value(forKey: "phone") as? String
if let uiImageNSData: NSData = data.value(forKey: "picture") as? NSData {
newContact.picture = Image(uiImage: UIImage(data: uiImageNSData as Data, scale: 1.0)!)
}
contacts.append(newContact)
NSLog("[Contacts] loaded Contact with name: \(data.value(forKey: "name") as! String) from CoreData")
}
} catch {
NSLog("[Contacts] ERROR: was unable to load Contacts from CoreData")
}
}
Create a new method in your ViewModel
that will clear the contacts
array and then load it from CoreData using the fetchContacts()
method. Call this method updateContacts()
.
Then, in ContentView.swift
, call this method using the following code on the List block:
.onAppear(perform: {
self.viewModel.updateContacts()
})
Now our contacts should be saving to and loading from CoreData! Try running the application multiple times to see this is the case :)
Of course, sometimes friendships take a turn for the worst and we need to delete contacts from our device. After recovering from heartbreak we see that in our app right now it looks like we are not able to delete contacts from our list, causing more heartbreak. We need to put an end to this, but also give our application the ability to remove contacts from Core Data.
Fortunately, this is an easy fix.
Add the following code to your ViewModel:
func deleteContact(atOffsets: IndexSet) {
let context = appDelegate.persistentContainer.viewContext
let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Person")
request.returnsObjectsAsFaults = false
for index in atOffsets {
let contact: Contact = contacts[index]
do {
let result = try context.fetch(request)
for data in result as! [NSManagedObject] {
if (
// the contact we're deleting is the same as the one in core data
) {
context.delete(data)
try context.save()
}
}
} catch {
print (error.localizedDescription)
}
}
contacts.remove(atOffsets: atOffsets)
}
All that remains here is for us to figure out how to delete the exact contact the user has requested from CoreData. Take a look at the logic and determine how to compare the CoreData entity with our object in self.contacts
, then replace the commented out if
statement above (hint: consider the NSManagedObject.value
method)! Then our heartbreak will be eased, and the app almost complete.
Finally, add this method to the ContentView.swift
:
func delete(at offsets: IndexSet) {
viewModel.deleteContact(atOffsets: offsets)
}
And call this and add delete functionality to the List by adding: .onDelete(perform: delete)
at the end of the ForEach
block.
Qapla'!