Welcome, Guest User :: Click here to login

Logo 67443

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

README.md

Contacts


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:

  • Model data using Xcode's model editor
  • Add new records to Core Data
  • Fetch a set of records from Code Data
  • Display the fetched records using a List view
  • Delete a record from Core Data

Getting Started

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:

AppDelegate

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.

SceneDelegate

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()
  }
  
  
}

Contacts.xcdatamodeld

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.

Creating the Model

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.

Creating the View Model

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
  }
}

Creating the View

Contact Row

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.

Contact Detail View

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)
  }
}

Add Contact View

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
  }
}

List Contacts View

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:

  1. Pressing the Done button in the Add Contact view does not bring you back to the list view
  2. Tapping outside of the buttons does nothing

Both 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!!

Adding 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.

Creating an Entity

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):

  • email (String)
  • name (String)
  • phone (String)
  • picture (Binary Data) Note photo must be stored as raw data

Here is a picture of what your entity should look like.

Saving Contacts

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)
    }

Loading Contacts

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")
    }
  }

Updating Contacts

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 :)

Deleting Contacts

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'!