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 video 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)
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)
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)
}
}
}
Memory Class
1. Inside the Classes
folder, create a new Cocoa Touch Class
file
2. Make sure your options are filled in as follows:
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
}
}
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)
}
}
}
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()
}
}
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)
}
}
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
}
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)
}
}
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
}
}
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)
}
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)
}
}
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
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)
}
}
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
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)"
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)
}
}
}
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)
}
}
}
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)
}
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)
}
}
}
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)
}
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)
}
}
}
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
}
}
}
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: How to Create a Digital Scrapbook in iOS - Part 1
An Xcode Collection of Useful Functions and Tips
Video Demo
SnapShot GitHub Repo
SashiDo
Parse Documentation
Parse Video Playlist