Welcome, Guest User :: Click here to login

Logo 67443

Lab 8: Find My Car

Due Date: November 05

Objectives

  • Teach students how to save state using plists
  • Teach students how to find and work with locations in iOS
  • Teach students how to create maps and drop pins
  • Reinforce previous mobile application development lessons

README.md

FindMyCar


This week, we will make an app that allows users to track where their car is parked and where they are relative to the car. We will be using MapKit, SwiftUI, Location, and plists to save car data. Here is a sneak peak of the final app, but let's dive in!

Part 1: Model

  1. Download the starter code for this lab at: https://github.com/67443-Mobile-Apps/FindMyCar_starter

  2. The first thing you need to do when working with locations is getting permission from the user to use their location. To do this, go into the Info.plist file found in the file explorer and add a new property list item called NSLocationWhenInUseUsageDescription and add in the text "This app would like to use your location." (This can be done by clicking the + icon shown by hovering over "Information Property List"). This is the message that will be displayed when your app requests permission from the phone to use location services.

  3. Create a model file called Location.swift. After import Foundation add in the directive import CoreLocation. Then add in the following code:

    class Location: NSObject {
      
      var latitude: CLLocationDegrees
      var longitude: CLLocationDegrees
      var locationManager = CLLocationManager()
      
      override init() {
        self.latitude = 0.00
        self.longitude = 0.00
        super.init()
      }
      
      func getCurrentLocation() {
        locationManager.requestWhenInUseAuthorization()
        if CLLocationManager.locationServicesEnabled() {
          locationManager.distanceFilter = kCLDistanceFilterNone
          locationManager.desiredAccuracy = kCLLocationAccuracyBest
          locationManager.startUpdatingLocation()
        }
        
        if let currLocation = locationManager.location {
          self.latitude = currLocation.coordinate.latitude
          self.longitude = currLocation.coordinate.longitude
        }
      }
    }
    

    Right now this class mostly tracks (and will later store) a location's latitude and longitude, but it also has the getCurrentLocation() method. One thing to note in getCurrentLocation() is that the first thing the app will do is verify that it has permission to use the phone's location services. The user will only have to give permission once (they can later change it in their phone's settings) but the app will always check to see that it has permission before calculating the current location. In some cases the initial granting of approval can take a second or two for the device to process, so the initial location will be at (0.00, 0.00) because the authorization hasn't been processed yet even though the user literally just gave permission.

Part 2: Views

  1. Go the ContentView.swift and add two button: Here's My Car and Where's My Car?. At this point, you app should look like the left screen at the beginning of the lab. (You may need to add Spacer()s to get the spacing right.). Note that right now these buttons do nothing (an empty closure associated with action) and we will actually change the type associated with one of these buttons shortly, but this gives us something to work with. If done correctly, the code inside the body var of the ContentView struct should look like this:

    VStack {
      Spacer()
      Button(action: {}) {
        Text("Here's my car")
      }
      Spacer()
      Button(action: {}) {
        Text("Where's my car?")
      }
      Spacer()
    }
    
  2. Create a new file SwiftUI View called MapView.swift and within the struct, start by adding an instance of the view controller with let viewController: ViewController.

    Change the struct declaration such that this class conforms to the UIViewRepresentable protocol instead of the View protocol. You will also need to delete the three lines that define the var body within this MapView struct.

    Below, in the MapView_Previews struct you will need to change the MapView constructor to be passed an instance of ViewController: MapView(viewController: ViewController())

  3. Add in the following functions to the MapView struct:

func updateUIView(_ uiView: MKMapView, context: UIViewRepresentableContext<MapView>) {
  let coordinate = CLLocationCoordinate2D(latitude: 40.445771, longitude: -79.944223
  )
  let span = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
  let region = MKCoordinateRegion(center: coordinate, span: span)
  uiView.setRegion(region, animated: true)
}

func makeUIView(context: Context) -> MKMapView {
  let mapView = MKMapView(frame: .zero)
  return mapView
}

You will need to import MapKit at the top of the file.

  1. Now, we have to go back and fix our ContentView in two ways. First, we need to wrap the VStack() in a NavigationView and then replace the Where's My Car? button in the ContentView with a NavigationLink to the MapView. Your code (in the body var) should something like this.
NavigationView {
   VStack {
     Spacer()
     Button(action: {}) {
       Text("here's my car")
     }
     Spacer()
     NavigationLink(destination: MapView(viewController: viewController).navigationBarTitle("map view")) {
       Text("where's my car?")
     }
     Spacer()
   }
   .alert(isPresented: $showingAlert) {
     Alert(title: Text(viewController.generateTitle()), message: Text(viewController.generateMessage()))
   }
 }

You will also need to define a constant let viewController: ViewController = ViewController() at the top of the ContentView struct.

  1. Add an @State private var showingAlert = false to your struct so that your code compiles. This will will be true if and only if the alert should be displayed.

  2. Our views are complete, but we can compile the app yet because we have not yet made the ViewController that they depend on. We will revisit this and test our veiws soon.

Part 3: Controller / Adding In Current Location

  1. Create a new plain swift file called ViewController.swift for the view controller. In a class called ViewController, create two (constant) instances of Location--one for the user's current location and one for the location of the car.

    Let's also add two methods to the ViewController class that will help us out later.

    func generateTitle() -> String {
      let message = "Your car is currently at:\n(\(self.carLocation.latitude), \(self.carLocation.longitude))"
      return message
    }
    
    func generateMessage() -> String {
      let message = "\nWhen you want to map to this location, simply press the \"Where is my car?\" button."
      return message
    }
    
  2. Okay, now we can test out our views! The Here's My Car button is not yet functional, but when you press on the other button, you should see your car near the Tepper Quad. Feel free to experiment with the MKCoordinateSpan to find a radius that you like best. Also note you can hold down the option key in the simulator and drag around to perform a pinch-to-zoom effect.

  3. Back to work. Now in the MapView, let's make sure that the map is always centered around the user. To do so, add a line to get the user's current location at the top of the updateUIView method and change the hardcoded values for latitude and longitude to be the user's current latitude and longitude.

  4. Let's also create a pin for our location. Luckily, doing this in iOS is super easy; just add the following code to the end within the makeUIView function:

       let droppedPin = MKPointAnnotation()
       droppedPin.coordinate = CLLocationCoordinate2D(
       	latitude: // fill in with your current location latitude ,
       	longitude: // fill in with your current location longitude
       )
       droppedPin.title = "You are Here"
       droppedPin.subtitle = "Look it's you!"
    

    Add mapView.addAnnotation(droppedPin) right before you return mapView.

    Replace the comments, but make sure you keep the comma between latitude and longitude above :)

    Also, make sure to update the user's location with getCurrentLocation() before you set the coordinates.

  5. Test this out in the simulator! Rerun the project to see the pin and the title beneath. Note the subtitle after you click on the pin. You should see something like the second screenshot at the top of the lab.

Note: Your simulator doesn't have GPS in it, but you can give it coordinates that it might have gotten from a GPS unit. To do this, go to your simulator and choose Features > Location > Custom Location.

Part 4: Finding and Mapping Locations

Let's go back to the ContentView.swift and fill in the action for our Here's My Car Button. We are going to get the current location of the car like we did for the user but also display an alert that the car's location has been saved.

  1. Go back to the empty action closure in the "Here's my car" button in ContentView.swift and update that button's action with the following:
Button(action: {
    self.viewController.carLocation.getCurrentLocation()
    self.showingAlert = true
  })
  1. Run your project and verify it is working as expected by tweaking the locations in the simulator to map to different locations. FYI, the British Prime Minister's home is at (51.5034070, -0.1275920) and the White House is at (38.8976090, -77.0367350) if you want to try funky, non-Pittsburgh places to test. Here is an example of the alert:

  2. Now when we click "Where's my car" we want to see the car's location. Change the pin so that it appears at the car's location instead of the user's location. You can also change the title/subtitle of the pin if you like.

Also, change the map to center to on the car's location and not the initial location as we did earlier.

Remeber that the pin is defined in the makeUIView method of the MapView struct and the map centering is controlled in the updateUIView method of the same struct.

  1. We know where our car is and there is a pin dropped on the map to make it clear, but it'd be nice if we knew that location relative to our current position. This is easy, simply add the following line to your updateUIView method in your MapView:

      uiView.showsUserLocation = true
    

    In the simulator, assuming the car is still parked in Morewood (40.4454261, -79.9437277), change the location to Wean Hall (40.4426092, -79.9454014). When you press on the show me the car button, the red pin drops on the car and your location has a blue glowing button. In a real mobile device with GPS, this blue dot will readjust as you move.

    Part 5: Saving State

    Now we will take a look at implementing a way to save our car's state no matter what may happen to our device: the app dies or the phone shuts down to name a few. We will do this for now by using a plist file, which is essentially writing some information to a file. In future weeks we will talk about using CoreData, which is an iOS framework for accessing internal memory similar to a database.

    For now, let's step through the steps to implement saving and loading our coordinates in the Location object.

    Augmenting the Location Class

    1. Let's begin by adding this string extension to the top of the Location.swift file (outside of the Location class):

      extension String {
        // recreating a function that String class no longer supports in Swift 2.3
        // but still exists in the NSString class. (This trick is useful in other
        // contexts as well when moving between NS classes and Swift counterparts.)
        
        /**
         Returns a new string made by appending to the receiver a given string.  In this case, a new string made by appending 'aPath' to the receiver, preceded if necessary by a path separator.
         
         - parameter aPath: The path component to append to the receiver. (String)
         
         - returns: A new string made by appending 'aPath' to the receiver, preceded if necessary by a path separator. (String)
         
        */
        func stringByAppendingPathComponent(aPath: String) -> String {
          let nsSt = self as NSString
          return nsSt.appendingPathComponent(aPath)
        }
      }
      

      This extension allows us to append a filepath component to a given string, allowing us to create the filepath we will need to the plist file.

    2. Now, we must define the Swift functions inside the Location class to retrieve the appropriate directory for our plist file and the appropriate final filepath including our plist file in the device's memory:

      func documentsDirectory() -> String {
        let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
        return paths[0]
      }
      
      func dataFilePath() -> String {
        return documentsDirectory().stringByAppendingPathComponent(aPath: "Coordinates.plist")
      }
      

      We should then print the dataFilePath out of the init function (for debugging if need be), so let's add that now as well.

    3. Now we are ready to write a function to save the location of the car which we will later call in several places around our app's files. This function will take our current latitude and longitude and save it to the plist. Add the saveLocation function in the Location class:

      func saveLocation() {
        let data = NSMutableData()
        let archiver = NSKeyedArchiver(forWritingWith: data)
        archiver.encode(self.latitude, forKey: "latitude")
        archiver.encode(self.longitude, forKey: "longitude")
        archiver.finishEncoding()
        data.write(toFile: dataFilePath(), atomically: true)
      }
      
    4. Similarily, below the saveLocation function, add the loadLocation function which we will call to retrieve the latitude and logitude from the plist and save them to our location object:

      func loadLocation() {
        let path = dataFilePath()
        if FileManager.default.fileExists(atPath: path) {
          if let data = NSData(contentsOfFile: path) {
            let unarchiver = NSKeyedUnarchiver(forReadingWith: data as Data)
            self.latitude = unarchiver.decodeDouble(forKey: "latitude")
            self.longitude = unarchiver.decodeDouble(forKey: "longitude") 
            unarchiver.finishDecoding()
          } else {
            print("\nFILE NOT FOUND AT: \(path)")
          }
        }
      }
      
    5. We should also write a function to clear out our latitude and longitude before saving. Add the clearLocation file to the Location class:

      func clearLocation () {
        self.latitude = 0.00
        self.longitude = 0.00
      }
      
    6. The last thing we need to do is update our getCurrentLocation function in the Location class to clear the car's location and save the updated location to the plist. Call clearLocation to the beginning of this function, and call saveLocation right at the end.

    7. And now we will update the usage of the Location class to take advantage of these changes.

    Augmenting the MapView

    The only things that need to be done are to switch the call from getCurrentLocation to loadLocation so we load from the plist in updateUIView and to add loadLocation to the top of makeUIView.

    Augmenting the AppDelegate

    For AppDelegate.swift we must invoke our actions to save and load the location of the car on various triggers.

    First, add let location = Location() right at the top of the class so that we have an instance of a location to work with.

    This location should be (loaded first, and then) saved in:

    • didDiscardSceneSessions

    While this location should be just loaded in:

    • didFinishLaunchingWithOptions
    • configurationForConnecting
    1. Now the plist should be fully integrated into the app! Try adding location, killing the app, and checking to see it is there. If you are having issues with the plist, be sure it is being saved properly to the path printed when the Location object is initialized.