DEV Community

CarolineBaillie
CarolineBaillie

Posted on • Originally published at blog.sashido.io

SnapShot: How to Create a Digital Scrapbook in iOS - Part 2

Welcome to Part 2 of SnapShot: A Digital iOS Scrapbook! If you haven’t seen Part 1, I recommend you check it out as it takes care of all of the setup and registration.

Just to refresh your mind, SnapShot is an iOS app made using Xcode and SashiDo. It is kind of like a digital scrapbook, but for more information, here is the demo:


Again, I recommend looking at SnapShot Part 1, or even my first tutorial Fish Classification iOS App with SashiDo and Teachable Machine). Additionally, all my code is available on my GitHub.

Table of Contents

Map

Adding the map is probably the biggest step in this project. Below I have outlined all the steps I took to create and connect everything. However, if you want more visual instructions on how to create and display the map, I recommend checking out this video playlist!

Setup

     1. Go to the storyboard map controller (I created mine on the default ViewController)
     2. Add MKMapView controller
     3. Drag the sides so the MKMapView takes up the whole screen
     4. On the top bar, click Editor -> Resolve Auto Layout Issues -> Add Missing Constraints (selected views)

Alt Text
     5. Open dual editors (storyboard and ViewController.swift)
         a. Control drag from MKMapView into the ViewController class
         b. Call the outlet mapView
     6. Select the MKMapView/mapView
     7. Control drag to ViewController and click delegate
     8. Make sure you have import MapKit and import CoreLocation in ViewController.swift
     9. Make sure ViewController.swift conforms to MKMapViewDelegate
We also have to set up a reload function that could be called from a different controller (used when memory is edited). The code is also given in the Extras Backpropagation Function Section, but basically there are only two sections of code for ViewController.swift:
     1. In viewDidLoad add this:

// setup for function that is called from newMemory NotificationCenter.default.addObserver(self, selector: #selector(reload), name: Notification.Name("reload"), object: nil) 
Enter fullscreen mode Exit fullscreen mode

     2. Add the following reload function inside the class:

// function to reload newMemories bc when dismissed adding controller doesn't show without @objc func reload (notification: NSNotification){ self.mapView.removeAnnotations(self.mapView.annotations) for m in sessionManager.shared.memories { // if there is a mapView (protection) if mapView != nil { // add that Memory mapView.addAnnotation(m) } } } 
Enter fullscreen mode Exit fullscreen mode

Memory Class

     1. Inside the Classes folder, create a new Cocoa Touch Class file
     2. Make sure your options are filled in as follows:

Alt Text
     3. Click next and create
     4. Inside this file, create a class with all the elements of each memory:

import UIKit import MapKit class Memory: NSObject, MKAnnotation { var title: String? var coordinate: CLLocationCoordinate2D var desc: String var category: String? var tags: String? var location: String var date: String var Image: UIImage var objID: String init(title:String, Image:UIImage, coordinate:CLLocationCoordinate2D,desc:String,category:String,tags:String, location:String,date:String, objID:String){ self.title = title self.Image = Image self.coordinate = coordinate self.desc = desc self.category = category self.tags = tags self.location = location self.date = date self.objID = objID } } 
Enter fullscreen mode Exit fullscreen mode

Now you can create a variable of type Memory and add it to the annotation map. However, we want to use the information from the database to populate this annotation. Therefore we must create a function in sessionManager.

Request Memories

For our current task, we want a function that gets all the data from SashiDo:
     1. Inside the sessionManager class, add var memories : [Memory]! = [] to create an array of type Memory
     2. Add the following function:

// get all memories func GetAllMem(completion:@escaping (_ success:Bool) -> ()){ // clear previous data memories.removeAll() // get all rows let query = PFQuery(className: "Memory") // where user is equal to current user query.whereKey("userID", equalTo:user.id) query.findObjectsInBackground { (objects, error) in // no errors if error == nil { // if there are objects in the array if let returnedObjects = objects { // loop through all objects in array for object in returnedObjects { // extract the image let file = object["Image"] as! PFFileObject file.getDataInBackground { (imageData: Data?, error: Error?) in if error == nil { if let imageData = imageData { let image = UIImage(data: imageData) // convert coord of type GeoPoint to CLLocationCoordinate2D let coor = object["coordinate"]! as! PFGeoPoint let coord = CLLocationCoordinate2D(latitude: coor.latitude, longitude: coor.longitude) // create a new memory let memData = Memory(title: object["title"]! as! String, Image: (image ?? UIImage(named: "test"))!, coordinate: coord, desc: object["desc"]! as! String, category: object["category"]! as! String, tags: object["tags"]! as! String, location: object["location"]! as! String, date: object["date"]! as! String, objID: object.objectId!) // add memory to the global array self.memories.append(memData) } } } } } completion(true) } else { //return false completion if fails completion(false) } } } 
Enter fullscreen mode Exit fullscreen mode

         a. Essentially the function is getting all the information from the database rows that correspond with the current user (using Parse Documentation)
         b. It then loops through all the rows and creates a variable of type Memory
         c. Finally, it adds that memory to the array
     3. Now go to settings.swift and add this code to viewDidLoad:

// get all memories from database sessionManager.shared.GetAllMem { (success) in if success { self.removeSpinner() } } 
Enter fullscreen mode Exit fullscreen mode

         a. This means that whenever a user logs in, all their information will be saved to the array memories in sessionManager (sessionManager.shared.memories)
     4. To show these memories as annotations on the map, return to ViewContoller.swift and add this code in viewDidLoad:

// go through all memories in global list for m in sessionManager.shared.memories { // if there is a mapView (protection) if mapView != nil { // add that Memory mapView.addAnnotation(m) } } 
Enter fullscreen mode Exit fullscreen mode

         a. This code just loops through all the memories and adds the memory to the mapView

Interactive Annotations

Now that we have displayed all our memories, we want to be able to click on them and open a new controller that displays all the information. To do this we need to add two functions to ViewController.swift:
     1. This function changes the style of the pin and displays the i button when clicked:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { // make sure only right type of pins display guard annotation is Memory else {return nil} // create identifier for annotation views let identifier = "Memory" // get back an annotation if it is one with identifier, otherwise nil var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) if annotationView == nil { annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier) annotationView?.canShowCallout = true //create button - i button for info let btn = UIButton(type: .detailDisclosure) annotationView?.rightCalloutAccessoryView = btn } else { // if have in dq go ahead and use annotationView?.annotation = annotation } return annotationView } 
Enter fullscreen mode Exit fullscreen mode

     2. This function presents a different view controller when the i button is clicked

func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) { // make sure is type Memory guard let memory = view.annotation as? Memory else {return} if control == view.rightCalloutAccessoryView { // go to info page for that memory let obj = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "memInfoPage") as! memInfoPage // pass memory information obj.page = memory self.present(obj, animated: true, completion: nil) } } 
Enter fullscreen mode Exit fullscreen mode

Memory Info Page

The previous code showed you how to make an annotation clickable and open a new controller. It also sent information to the next controller (memInfoPage). To display this information in memInfoPage.swift you must:
     1. Create outlets for all the storyboard elements (labels, images, etc.)
     2. Add var page : Memory! to the top of the class to collect the memory passed from ViewContoller.swift
     3. In viewDidLoad, pass the information from page into all the storyboard elements
     4. Add a function to reload the controller (this is needed for editing the memory)
     5. All together it should look like this:

import Foundation import UIKit import Parse class memInfoPage : UIViewController { // var from prev page var page : Memory! // connections @IBOutlet weak var memTitle: UILabel! @IBOutlet weak var memImage: UIImageView! @IBOutlet weak var memDesc: UITextView! @IBOutlet weak var memCategory: UILabel! @IBOutlet weak var memLocation: UILabel! @IBOutlet weak var memTags: UITextView! @IBOutlet weak var memDate: UILabel! override func viewDidLoad() { super.viewDidLoad() self.memTitle.text = page.title memTitle.adjustsFontSizeToFitWidth = true self.memImage.image = page.Image self.memDesc.text = page.desc self.memCategory.text = page.category self.memLocation.text = page.location self.memTags.text = page.tags self.memDate.text = page.date NotificationCenter.default.addObserver(self, selector: #selector(reloadContent), name: Notification.Name("reloadContent"), object: nil) } @objc func reloadContent (notification: NSNotification){ self.memTitle.text = page.title memTitle.adjustsFontSizeToFitWidth = true self.memImage.image = page.Image self.memDesc.text = page.desc self.memCategory.text = page.category self.memLocation.text = page.location self.memTags.text = page.tags self.memDate.text = page.date } } 
Enter fullscreen mode Exit fullscreen mode

Now when you click the i button on an annotation, a page with all the information about the memory will appear.

Add Annotation

The next logical step is to create a way to add new memories:
     1. Add a button to the storyboard map controller and create an action function (addAnnotation) connecting to ViewController.swift
     2. Insert the following code to present a camera controller (picController):

@IBAction func addAnnotation(_ sender: Any) { // should pop up different controller so that can enter info let obj = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "picController") as! picController self.present(obj, animated: true, completion: nil) } 
Enter fullscreen mode Exit fullscreen mode

To create picController.swift:
     1. If you haven’t already, create a controller with a button and imageView
     2. Create a file called picController.swift with the following code:

import Foundation import UIKit class picController: UIViewController { @IBOutlet weak var img: UIImageView! // create camera let pickerController = UIImagePickerController() override func viewDidLoad() { super.viewDidLoad() // set camera pickerController.sourceType = UIImagePickerController.SourceType.camera pickerController.delegate = self } // linked to button that shows the camera @IBAction func onClickTakePic(_ sender: Any) { present(pickerController, animated: true, completion: nil) } } // image taken extension picController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { picker.dismiss(animated: true, completion: nil) if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { // set image taken to image on screen img.image = image dismiss(animated: true, completion: nil) // send image to create new mem let obj = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "newMemory") as! newMemory obj.image = image self.present(obj, animated: true, completion: nil) } } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { //close out (camera has been closed) } } 
Enter fullscreen mode Exit fullscreen mode

     3. Connect img and onClickTakePic to the image and button on the controller
     4. Allow camera access:
         a. Go to the outermost snapShot.xcodeproj
         b. info -> Custom iOS Target Properties -> right click → add row -> Privacy - Camera Usage Description

Alt Text
From the code, you can see that once the picture is taken, it sends the information to a different controller called newMemory.

New Memory

newMemory is the controller where the user inputs all the different information on the memory they wish to create. The corresponding file has 4 major parts to it:
     1. Basic setup
     2. Gets users location
     3. Gets current date
     4. Creates a new memory
Breaking each part down…

Basic setup:
     1. Add var image : UIImage! to the top of the newMemory class (it will store the image passed from picController)
     2. Create connections between each input field, image, and button
     3. In viewDidLoad, add self.memImage.image = image to set the image

Location:
     1. Allow location access:
         a. Go to the outermost snapShot.xcodeproj
         b. info -> Custom iOS Target Properties -> right click add row (x2) -> Privacy - Location When In Use Usage Description & Privacy - Location Always and When In Use Usage Description
     2. Add private var locationManager:CLLocationManager? to the top of the newMemory class so we can access the user’s location
     3. Add the following two functions to the class:

func getUserLocation() { locationManager = CLLocationManager() locationManager?.requestAlwaysAuthorization() locationManager?.startUpdatingLocation() locationManager?.delegate = self } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { if let location = locations.last { sessionManager.shared.currentCoord = CLLocationCoordinate2D(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude) } } 
Enter fullscreen mode Exit fullscreen mode

     4. In sessionManager.swift add var currentCoord : CLLocationCoordinate2D? as a variable at the top of the class (this will store the users current coordinate)
     5. Finally, back in newMemory.swift, create a variable for the location in the save button function (saveToggled)

getUserLocation() let coord = sessionManager.shared.currentCoord 
Enter fullscreen mode Exit fullscreen mode

Date:
     1. Add the following to saveToggled:

let date = Date() let calendar = Calendar.current let y = calendar.component(.year, from: date) let m = calendar.component(.month, from: date) let d = calendar.component(.day, from: date) let dateNow = "\(m)/\(d)/\(y)" 
Enter fullscreen mode Exit fullscreen mode

New Memory:
     1. In saveToggled, create a new variable of type Memory and save it to sessionManager

Code all together:

import Foundation import UIKit import MapKit import Parse import CoreLocation class newMemory : UIViewController, UITextFieldDelegate, CLLocationManagerDelegate { var image : UIImage! private var locationManager:CLLocationManager? @IBOutlet weak var memTitle: UITextField! @IBOutlet weak var memCategory: UITextField! @IBOutlet weak var memImage: UIImageView! @IBOutlet weak var memDesc: UITextView! @IBOutlet weak var memLocation: UITextField! @IBOutlet weak var memTags: UITextView! override func viewDidLoad() { super.viewDidLoad() // Set image from passed in value self.memImage.image = image } //dismiss keyboard when tapped override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { self.view.endEditing(true) } @IBAction func saveToggled(_ sender: Any) { self.showSpinner() // find current location getUserLocation() let coord = sessionManager.shared.currentCoord // get current date let date = Date() let calendar = Calendar.current let y = calendar.component(.year, from: date) let m = calendar.component(.month, from: date) let d = calendar.component(.day, from: date) let dateNow = "\(m)/\(d)/\(y)" // create new memory let newMem = Memory(title: memTitle.text!, Image: image, coordinate: coord, desc: memDesc.text!, category: memCategory.text!, tags: memTags.text!, location: memLocation.text!, date: dateNow, objID: "none") // save memory sessionManager.shared.saveMemory(memory: newMem) { (success) in // reload map controller NotificationCenter.default.post(name: Notification.Name("reload"), object: nil) // move to next view controller self.dismiss(animated: true, completion: nil) } } // get user location stuff func getUserLocation() { locationManager = CLLocationManager() locationManager?.requestAlwaysAuthorization() locationManager?.startUpdatingLocation() locationManager?.delegate = self } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { if let location = locations.last { sessionManager.shared.currentCoord = CLLocationCoordinate2D(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude) } } } 
Enter fullscreen mode Exit fullscreen mode

In sessionManager.swift, the function to save a memory looked like this:
     1. Set all the database rows to be the inputted information
     2. Reduce the image size (otherwise might cause error)
     3. Save changes to the database
     4. Add the new memory to global array (memories)

func saveMemory (memory:Memory, completion:@escaping (_ success:Bool) -> ()) { // set values for new memory let mem = PFObject(className:"Memory") mem["title"] = memory.title mem["desc"] = memory.desc mem["location"] = memory.location mem["date"] = memory.date mem["userID"] = user.id mem["tags"] = memory.tags mem["category"] = memory.category mem["coordinate"] = PFGeoPoint(latitude: memory.coordinate.latitude, longitude: memory.coordinate.longitude) // reducing image size let image = memory.Image let actualHeight:CGFloat = image.size.height let actualWidth:CGFloat = image.size.width let imgRatio:CGFloat = actualWidth/actualHeight let maxWidth:CGFloat = 1024.0 let resizedHeight:CGFloat = maxWidth/imgRatio let compressionQuality:CGFloat = 0.5 let rect:CGRect = CGRect(x: 0, y: 0, width: maxWidth, height: resizedHeight) UIGraphicsBeginImageContext(rect.size) image.draw(in: rect) let img: UIImage = UIGraphicsGetImageFromCurrentImageContext()! let imageData:Data = img.jpegData(compressionQuality: compressionQuality)! UIGraphicsEndImageContext() let imageFinal = UIImage(data: imageData)! // prepping to save image let imgData = imageFinal.pngData() let imageFile = PFFileObject(name:"image.png", data:imgData!) mem["Image"] = imageFile // save all mem.saveInBackground { (succeeded, error) in if (succeeded) { // The object has been saved. memory.objID = mem.objectId as! String self.memories.append(memory) completion(true) } else { // There was a problem completion(false) } } } 
Enter fullscreen mode Exit fullscreen mode

Delete

I wanted to allow the user to delete a memory if they no longer wanted it. Fortunately, this process was relatively simple:
     1. Create an action function from the memInfoPage delete button (deleteToggled)
     2. Create an alert asking the user to confirm that they want to delete this item
     3. If canceled dismiss
     4. If confirmed call the sessionManager delete function and return to the dismiss the controller

@IBAction func deleteToggle(_ sender: Any) { // alert are you sure you want to delete this let confirmAlert = UIAlertController(title: "Delete", message: "Are you sure you want to delete this memory?", preferredStyle: UIAlertController.Style.alert) // alert confirmed confirmAlert.addAction(UIAlertAction(title: "Confirm", style: .default, handler: { (action: UIAlertAction!) in // run delete function sessionManager.shared.deleteMemory(memory: self.page) { (success) in // reload map controller NotificationCenter.default.post(name: Notification.Name("reload"), object: nil) self.dismiss(animated: true, completion: nil) } })) // alert canceled confirmAlert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (action: UIAlertAction!) in // do nothing })) // show the alert present(confirmAlert, animated: true, completion: nil) } 
Enter fullscreen mode Exit fullscreen mode

To delete the memory in the sessionManager.swift function I had to:
     1. Find that memory in the database
     2. Delete it from the database
     3. Delete it from the global memories array (by comparing objectIDs)

func deleteMemory (memory:Memory, completion:@escaping (_ success:Bool) -> ()) { // get the memory let query = PFQuery(className:"Memory") query.getObjectInBackground(withId: memory.objID) { (object, error) in if error == nil { // Success! if let object = object { // delete this row object.deleteInBackground() // delete from array memories self.deleteMemArray(memory: memory) } completion(true) } else { // Fail! completion(false) } } } func deleteMemArray(memory:Memory) { // loop through all memories in array for i in 0..<memories.count-1 { // if the objectIDs match if memories[i].objID == memory.objID { // delete the memory memories.remove(at: i) } } } 
Enter fullscreen mode Exit fullscreen mode

I used two separate functions as a stylistic choice.

Edit

I also wanted users to be able to edit memories instead of having to delete and recreate them. This proved to be a little bit harder, but I eventually got it to work:
     1. Create an action function from the memInfoPage edit button (editToggled)
     2. Present and pass information to the edit controller (editMem)

@IBAction func editToggle(_ sender: Any) { // put all inputs into the text stuff to be resaved let VC = self.storyboard?.instantiateViewController(withIdentifier: "editMem") as! editMem // pass memory information VC.page = page self.present(VC, animated: true, completion: nil) } 
Enter fullscreen mode Exit fullscreen mode

     3. In editMem.swift, write the following code:

import Foundation import UIKit import MapKit import Parse import CoreLocation class editMem: UIViewController, UITextFieldDelegate, CLLocationManagerDelegate { var page : Memory! @IBOutlet weak var memTitle: UITextField! @IBOutlet weak var memCategory: UITextField! @IBOutlet weak var memImage: UIImageView! @IBOutlet weak var memDesc: UITextView! @IBOutlet weak var memTags: UITextView! @IBOutlet weak var memLocation: UITextField! override func viewDidLoad() { super.viewDidLoad() // set all input fields to the previous values memTitle.text = page.title memCategory.text = page.category memDesc.text = page.desc memTags.text = page.tags memLocation.text = page.location memImage.image = page.Image } @IBAction func memUpdate(_ sender: Any) { self.showSpinner() // can't edit coord or image let coord = page.coordinate let image = page.Image // create new memory with inputted info let newMem = Memory(title: memTitle.text!, Image: image, coordinate: coord, desc: memDesc.text!, category: memCategory.text!, tags: memTags.text!, location: memLocation.text!, date: page.date, objID: page.objID) // call update function sessionManager.shared.updateMemory(memory: newMem) { (success) in self.removeSpinner() // go back to memInfoPage let VC = self.storyboard?.instantiateViewController(withIdentifier: "memInfoPage") as! memInfoPage VC.page = newMem self.dismiss(animated: true, completion: nil) // call reload functions so update appears NotificationCenter.default.post(name: Notification.Name("reloadContent"), object: nil) NotificationCenter.default.post(name: Notification.Name("reload"), object: nil) } } } 
Enter fullscreen mode Exit fullscreen mode

         a. This code first sets all the inputs equal to whatever the previous information on the memory was
         b. The user can then edit the information and click the update button when finished (which runs memUpdate)
         c. memUpdate creates a new memory with the updated information and runs the updateMemory function in sessionManager
         d. It then dismisses the controller (returning to memInfopage)
         e. Finally, it triggers the reload functions mentioned earlier in memInfoPage and ViewController so that the updated information is displayed
In sessionManager, create an update function that:
     1. Finds the object in the database
     2. Replaces with and saves the new information
     3. Replaces the old memory with the new memory in the global memories array (by comparing objectIDs)

func updateMemory (memory:Memory, completion:@escaping (_ success:Bool) -> ()) { // find memory let query = PFQuery(className:"Memory") // with the same objectID query.getObjectInBackground(withId: memory.objID) { (object, error) in if error == nil { // Success! if let object = object { // update all values object["title"] = memory.title object["desc"] = memory.desc object["location"] = memory.location object["date"] = memory.date object["tags"] = memory.tags object["category"] = memory.category } // save object object!.saveInBackground() // change in global array self.updateMemArray(memory: memory) completion(true) } else { // Fail! completion(false) } } } func updateMemArray(memory:Memory) { // loop through memories for m in memories { // if objectIDs the same if m.objID == memory.objID { // update that memory in the global array m.title = memory.title m.desc = memory.desc m.location = memory.location m.date = memory.date m.tags = memory.tags m.category = memory.category } } } 
Enter fullscreen mode Exit fullscreen mode

Again, using two functions is stylistic.

Storing

Most of the previous code has been edited to exclude the sorting tableViewControllers and collectionViewControllers. This is because accessing information this way presented challenges when editing the memories (it was difficult to get the updated information to show up when clicking the navigation back button). Although I managed to do it in the end, including those steps and code in this tutorial added another layer of length and complexity. However, if you are interested in adding a similar kind of sorting mechanism, check out my code on GitHub to see how it was done!

Closing Remarks

Congratulations, you have now created your own digital scrapbook! I know I learned a lot from this project, and hope you did, too. I encourage all readers to embark on the rewarding journey of experimenting with and creating tutorials about Xcode, SashiDo, and iOS Maps. There are so many great resources all over the internet that help make your life easier when coding so there really is no downside to taking things into your own hands and having fun. I hope you all enjoyed my tutorial and again, make sure to check out (or download) my code on GitHub to get the full experience or my previous tutorial that uses Teachable Machine to classify fish types.

Thanks so much to SashiDo and all you readers! Happy future coding!

Resources

SnapShot Part 1: https://blog.sashido.io/snapshot-how-to-create-a-digital-scrapbook-in-ios-part-1/
An Xcode Collection of Useful Functions and Tips: https://blog.sashido.io/xcode-collection-of-useful-functions-and-tips/
SashiDo: https://www.sashido.io/en/
Parse Documentation: https://docs.parseplatform.org/ios/guide/
Parse Video Playlist: https://www.youtube.com/playlist?list=PLMRqhzcHGw1ZFjFyHGJTTPuvcLbwVCuG4
GitHub: https://github.com/CarolineBaillie/snapShot

Top comments (0)