Lab 6A: Advanced Contacts (Android)
Due Date: October 15
Objectives
- teach students to save data via Room
- teach students to use the integrate the camera and photos using built in camera app
Due Date: October 15
Objectives
The equivalent of iOS's Core Data in Android is the Room Persistence Library usually referred to as simply Room. Please read Google's tutorial on how to use Room (https://developer.android.com/training/data-storage/room) before beginning this lab.
For a quick tutorial on how to take photos from your app and to import the images check out the camera tutorial again from Google's developer library (https://developer.android.com/training/camera/photobasics)
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 Contacts
, 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.
By selecting the Use Core Data Option
, we invoked the creation of a new file in our project: Contacts.xcdatamodeld
. Click on this file to see teh UI for managing entitines 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
}
}
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)")
}
}
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'!