9
COMP228/327 Week 6, w/c 28th October 2019 Favourite Places – A Map based application. Create a new Xcode single-view swift project using Storyboard (include Core Data support by ticking the Use Core Data tick-box). Call it “My Favourite Places”. In storyboard, drag a navigation controller from the object library onto the storyboard region (but not on top of the existing single view). Place the existing view to the right of it. Highlight the yellow icon in the top bar of the left most view of the navigation controller, and using the attributes inspector set it as the initial view controller Use the bottom left hand corner button of the storyboard pane to show the controller hierarchy. In the Object library type “bar” in the search box and select a bar button item. Drag it into the hierarchy for the “Root View Controller Scene” just below the “Root View Controller” item (dragging it onto storyboard’s WYSIWYG (What You See Is What You Get) view puts it in the bottom bar instead - we don’t want this). This will open up and place your bar button under the “Left Bar Button Items”. Drag it under the right bar button items so that the button is on the right. Now ctrl drag from the button in the hierarchy view to the currently unattached View Controller on the storyboard and select “show”. Note that the View Controller now has a segue and is part of the storyboard sequence. Also it now has a navigation bar with a button back to the Root View Controller, courtesy of the navigation controller… In the hierarchical list of view controllers on the left of storyboard, select the View Controller (under the View Controller Scene item) and

COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard

  • Upload
    others

  • View
    0

  • Download
    0

Embed Size (px)

Citation preview

Page 1: COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard

COMP228/327 Week 6, w/c 28th October 2019

Favourite Places – A Map based application.

Create a new Xcode single-view swift project using Storyboard (include Core Data support by ticking the Use Core Data tick-box).

Call it “My Favourite Places”.

In storyboard, drag a navigation controller from the object library onto the storyboard region (but not on top of the existing single view). Place the existing view to the right of it.

Highlight the yellow icon in the top bar of the left most view of the navigation controller, and using the attributes inspector set it as the initial view controller

Use the bottom left hand corner button of the storyboard pane to show the controller hierarchy. In the Object library type “bar” in the search box and select a bar button item. Drag it into the hierarchy for the “Root View Controller Scene” just below the “Root View Controller” item (dragging it onto storyboard’s WYSIWYG (What You See Is What You Get) view puts it in the bottom bar instead - we don’t want this). This will open up and place your bar button under the “Left Bar Button Items”. Drag it under the right bar button items so that the button is on the right. Now ctrl drag from the button in the hierarchy view to the currently unattached View Controller on the storyboard and select “show”. Note that the View Controller now has a segue and is part of the storyboard sequence. Also it now has a navigation bar with a button back to the Root View Controller, courtesy of the navigation controller…In the hierarchical list of view controllers on the left of storyboard, select the View Controller (under the View Controller Scene item) and

Page 2: COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard

set its title to “Map” in the attributes inspector because that’s where we’re going to put our map. Give a title to the “navigation item” that was added by us setting up the segue (also call this “Map”). Now select the “Root View Controller” item in the “Root View Controller Scene” section and in the inspector set its title to “Places”. That should immediately update the name in controller hierarchy list). Select the “Root View Controller” navigation item and give that the title “Places” too. Select the tab bar button “item” that we added earlier, and change it via the inspector to the system item “Add” rather than its previous “custom” type.Select the “Table View Cell” item, and, as we usually do, in the inspector under “identifier”, give it the identifier “myCell” - this is how we’ll refer to this prototype cell later in our swift code.

On the storyboard, select the segue between the Places View Controller (Root View Controller) and the Map View Controller and give it the identifier “toMap”.

The Places View Controller (the one that contains the table) currently doesn’t have a Swift source file associated with it, so create a new File - a Cocoa Touch class, but a subclass of UITableViewController (choose that from the popup menu before you name the class). Then enter the name “PlacesViewController” in the “class” field, click next, then click create.

In Storyboard, select the Places View Controller and then using the identity inspector, “Class” popup, select PlacesViewController. This will assign your new swift file to this View Controller. There’s no need to control drag from the Table View to the yellow icon in the top of the View to add the outlets etc, this has already been done for us by Xcode (a dataSource and a Delegate - if you right click (or ctrl-click) the yellow circle icon, the popup will confirm this).

When you create a class based on the UITableViewController Xcode gives you additional “boilerplate” code which includes some optional methods for use with tables. Click PlacesViewController.swift and in the numberOfSections method, set it to return 1 (we only have a single section in our table). Previously when we used a table we did not include this method and the default value was used - which is for a table to have a single section.

For now, let’s set the numberOfRowsInSection to 4 (its currently set to its default, which is zero), just so we can test it in the simulator.

Page 3: COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard

Uncomment the cellForRowAt indexPath method. Remove the supplied definition for “cell” and paste the following two lines of code into it to define our cell. This is just going to print the row number of the cell that is being drawn.

let cell = UITableViewCell(style: UITableViewCell.CellStyle.default, reuseIdentifier: "myCell") cell.textLabel?.text = "Row \(indexPath.row)"

Remember that we named our prototype cell “myCell” earlier on.

We need to arrange to invoke a segue when we select cells in our table and so we add another method to this View Controller. Paste this into your swift file.

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { performSegue(withIdentifier: "toMap", sender: nil) }

Remember that we setup the segue “toMap” from the places View Controller to the Map View Controller earlier on. This code means that that segue will be invoked when someone selects a cell in your table. Choose iPhone 8 as your target device, and try to run the project in the simulator and see what happens. Click the “+” button - you transition to the Map view (with no map - we haven’t done that bit yet). You can then transition back to the Root View Controller.

We can setup a global variable to hold detail of all of the places. Since we’re going to save the coordinates and the name of the place we can create an array of dictionaries each of which will contain the name, latitude and longitude of a place (all as strings for simplicity). Paste the following into the code, just below the “import” line. This will be global to the whole program. Declaring it like this creates an array containing one item - an empty dictionary of String keys and values.

var places = [[String : String]()]

We usually place a lot of our code in ViewDidLoad, but this time we need a viewDidAppear method, because this is called when we switch between views, whereas viewDidLoad is only called when we load up a view ready for display. In viewDidAppear we can check to see if we need to setup an initial value for our list of places - we can make sure there is a default initial place:

override func viewDidAppear(_ animated: Bool) { if places.count == 1 && places[0].count == 0 { places.remove(at: 0) places.append(["name":"Ashton Building", "lat": "53.406566", "lon": "-2.966531"]) } }

We can update the method that returns the number of cells to use the number of items in this new array:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { // #warning Incomplete implementation, return the number of rows return places.count }

And also the cellForRowAt method, to return the data from the name bit of the dictionary entry for this place (if it isn’t somehow nil).

Page 4: COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell(style: UITableViewCell.CellStyle.default, reuseIdentifier: "myCell") if places[indexPath.row]["name"] != nil { cell.textLabel?.text = places[indexPath.row]["name"] } return cell }

We need to add an outlet from our table to our file so that we can reference the table in our code. Switch to storyboard view and use the “add editor” button to show the source associated with the Places View Controller. Then Ctrl drag from table (in the hierarchical scene view) into the source and name the outlet “table”. Dragging from storyboard doesn’t always work / we can’t always be sure what object we’re dragging from. We cans exactly which object is the source using this technique. Now we can then add an extra line to our viewDidAppear method to reload the table, just at the end of that method, right before the closing “}”:

table.reloadData()

If you run the project now, you should get a table with a single item in it - the Ashton building. Clicking it transitions to the Map view (currently without a map). We can click the “Places” button at the top left to take us back to the table view.

Now we can add our Map View.

In main storyboard, drag a Map View Kit view from the object library into the Map View Controller. Make it sit just below the navigation bar but fill the rest of the view. Set constraints so that it always does that (using the “pin” constraints click all four of the “spacing to nearest neighbour” constraints and apply them). In the attributes inspector set the Type of the map from “Standard” to “Hybrid”.

In the Map View Controller source (called ViewController.swift), we will need to add a few items to support the Map:

add import MapKit to the list of frameworks to import at the top of the file.

Ctrl-drag from the map view to the source file and add an outlet for the map - call it “map”. We will need this in order to be able to access the map object from our code e.g. to add annotation pins to it.

We also need to add MKMapViewDelegate to the protocols for the ViewController, so that it receives messages from the Map object. The class declaration top line now reads:

class ViewController: UIViewController, MKMapViewDelegate {

In the Places View Controller we will add a further global to pass around details of the currently chosen place. We can call this currentPlace. We declare it at the top of the class file and set it -1 as a flag to indicate we don’t have a currentPlace.

var currentPlace = -1

Update the viewDidAppear method to include a statement that sets the value of currentPlace to -1 (signifying that when this view appears, we have no current Place selected). It should now look like the following:

Page 5: COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard

override func viewDidAppear(_ animated: Bool) { if places.count == 1 && places[0].count == 0 { places.remove(at: 0) places.append(["name":"Ashton Building", "lat": "53.406566", "lon": "-2.966531"]) } currentPlace = -1 table.reloadData() }

We need to set currentPlace to the value of indexPath.row in the didSelectRowAt method, so that when we perform the segue, we know which cell initiated it.

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { currentPlace = indexPath.row performSegue(withIdentifier: "toMap", sender: nil) }

In the swift source for our Map View Controller (called ViewController.swift), we can print out the value of the currentPlace for now, in the ViewDidLoad method, just so we can test that we are getting the appropriate value.

override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. print(currentPlace) }

Try it in the simulator. It should work to display the map. Tapping the Ashton Building table item causes the map to appear (showing a map of the UK) and a 0 to be printed in the console (the first item in a table is item 0). Go back to the table view. Tapping the “+” is similar, but prints -1.

Now we need to get the map to actually display the place we are looking at.

In the viewDidLoad we can check to see if currentPlace is -1, it it isn’t then we should have a place that we want the map to show us. We will use lots of if statements to retrieve the various items relating to the place in case any of them turn out to be nil and cause a crash.

Below is the code that retrieves the currentPlace details as a dictionary from the places array and then checks each part of it. It then converts the lat and lon strings into Doubles to use on the Map. We then create the appropriate objects to pass to the map to get it to display at the correct scale and centred on the correct coordinates. Finally we set up an annotation (map pin) and place it on the map (don’t copy the code below just yet).

override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. if currentPlace != -1 { if places.count > currentPlace { if let name = places[currentPlace]["name"] { if let lat = places[currentPlace]["lat"] { if let lon = places[currentPlace]["lon"] { if let latitude = Double(lat) { if let longitude = Double(lon) { let span = MKCoordinateSpan(latitudeDelta: 0.008, longitudeDelta: 0.008) let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) let region = MKCoordinateRegion(center: coordinate, span: span) self.map.setRegion(region, animated: true) let annotation = MKPointAnnotation() annotation.coordinate = coordinate annotation.title = name self.map.addAnnotation(annotation)

Page 6: COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard

} } } } } } } print(currentPlace) }

An alternative (and cleaner) version of the above may be written using the guard statement instead (and we avoid the Pyramid of Doom). Copy this to your Map View Swift source file, replacing the ViewDidLoad version already there:

override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. guard currentPlace != -1 else { return } guard places.count > currentPlace else { return } guard let name = places[currentPlace]["name"] else { return } guard let lat = places[currentPlace]["lat"] else { return } guard let lon = places[currentPlace]["lon"] else { return } guard let latitude = Double(lat) else { return } guard let longitude = Double(lon) else { return } let span = MKCoordinateSpan(latitudeDelta: 0.008, longitudeDelta: 0.008) let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) let region = MKCoordinateRegion(center: coordinate, span: span) self.map.setRegion(region, animated: true) let annotation = MKPointAnnotation() annotation.coordinate = coordinate annotation.title = name self.map.addAnnotation(annotation) print(currentPlace) }

OK, now try running this in the simulator. Tapping the Ashton Building item should now display a map centred on the Ashton building.

This is fine, but how do we add new locations?

We can do it by adding a long press gesture recogniser to the Map View so that we can do something when this gesture occurs. In Storyboard, from the object library choose a long press gesture recogniser (type “long” into the search box) and drag it on top of the Map. It will be easier to see it in the hierarchical list.

Control drag from this into the source code to add an action. Make sure to select UILongPressGestureRecognizer from the Type popup menu). Give it the name “longPress”. This method will be called when a long press on the map occurs. In the

Page 7: COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard

attributes inspector you can set the minimum duration before the long press is recognized. Set this to 1.5 (seconds).

Add the following code to your longPress method that prints the fact that a long press has occurred: print("===\nLong Press\n===") Run this in the simulator, and tap the Ashton Building cell to switch to the Map View. Now long press somewhere on the map. I get two messages printed to the console when I long press on the Map - one when the long press begins and one when it ends. We’re only interested in the beginning one, so update your longPress method like this to only react when the state of the long press is “began” (Warning: - don’t try to replace the first line of your method or you may break the connection with the Storyboard object. Leave the line the begins “@IBAction” in place, and only copy the rest of the code - the method body):

@IBAction func longPress(_ sender: UILongPressGestureRecognizer) { if sender.state == UIGestureRecognizer.State.began { print("===\nLong Press\n===") } }

Try it again in the simulator. Now you should only see one message for a long press.OK, now we want to be able to turn our long press into a new annotation on the map. Update the longPress method to get the touch point from the map and convert it into map coordinates that we can turn into a map annotation. For now we can set a dummy title for the annotation - we can get a proper title later. Update your method to the following (remember, don’t replace that first line):

@IBAction func longPress(_ sender: UILongPressGestureRecognizer) { if sender.state == UIGestureRecognizer.State.began { print("===\nLong Press\n===") let touchPoint = sender.location(in: self.map) let newCoordinate = self.map.convert(touchPoint, toCoordinateFrom: self.map) print(newCoordinate) let annotation = MKPointAnnotation() annotation.coordinate = newCoordinate annotation.title = "Dummy title" self.map.addAnnotation(annotation) } }

Run it again in the simulator. Click the Ashton Building item. Then when the map loads, long press on another part of the map. A new pin should be placed there and the coordinates of that place should be printed in the console.

We have the map coordinates, but to get the address for our annotation title we need to use reverse geocoding (which may not work if the place has no real “name”). If it works without error we can retrieve the appropriate items from the placemarks array, and set the annotation to something meaningful. Copy this code (remember - not the first line) and replace your longPress method body. @IBAction func longPress(_ sender: UILongPressGestureRecognizer) { if sender.state == UIGestureRecognizer.State.began { print("===\nLong Press\n===") let touchPoint = sender.location(in: self.map) let newCoordinate = self.map.convert(touchPoint, toCoordinateFrom: self.map) print(newCoordinate) let location = CLLocation(latitude: newCoordinate.latitude, longitude: newCoordinate.longitude) var title = ""

Page 8: COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard

CLGeocoder().reverseGeocodeLocation(location, completionHandler: { (placemarks, error) in if error != nil { print(error!) } else { if let placemark = placemarks?[0] { if placemark.subThoroughfare != nil { title += placemark.subThoroughfare! + " " } if placemark.thoroughfare != nil { title += placemark.thoroughfare! } } } if title == "" { title = "Added \(NSDate())" } let annotation = MKPointAnnotation() annotation.coordinate = newCoordinate annotation.title = title self.map.addAnnotation(annotation) places.append(["name":title, "lat": String(newCoordinate.latitude), "lon": String(newCoordinate.longitude)]) } ) } }

Run this in the simulator and check it works by long pressing on the map e.g. in the location indicated in the image below. Does it display a pin with a meaningful address?

The version of the application you have just created, will let you add a new place, but is missing some features which I’d like you to try to implement:

1. Persistent storage of the favourite places. Feel free to use Core Data or UserDefaults. You’ll need to load them up on startup, and save them when a new place is added.

2. When the user clicks the “+”, centre the map on the user’s current coordinates (since the simulator doesn’t have a real GPS, use the Ashton Building location instead).

3. Editing of the list of places. Add editing features to the table (deleting of places you no longer wish to record in your list).

Keep this App and submit it as part of your portfolio at the end of the module.

Long press here (Brownlow Hill Tesco is located here)

Page 9: COMP228/327 Week 6, w/c 28th October 2019cgi.csc.liv.ac.uk/~phil/Teaching/COMP228/practicals... · In storyboard, drag a navigation controller from the object library onto the storyboard