Building The Perfect iOS Map (I): Synchronising a Map and a List

A very common pattern in mobile applications is the kind of app where the data model involves a series of POI (points of interest) in a physical location. In this kind of scenario, the UI/UX involves having a map with those POI represented as pins alongside a list displaying the currently visible points. This map-list combination usually implies adjusting the list of POIs shown in the list dynamically as the user navigates through the map, by zooming in/out or swiping in any direction.

mini1476307456Many well-known apps like AirBnB and Idealista use this technique. In this tutorial, we are going to show how to build this kind of interface. We will be building an application that shows the museums of the USA in a main screen consisting of a map in the upper half and a list (UITableView) of the currently visible museums at the bottom, like the one depicted in the picture.

Our POIs will be a list of museums from the US, expressed in a comma-separated CSV. For the pins, we will use some custom pins found here. We will not discuss the user location in this tutorial, so we will be faking it to a fixed, hardcoded coordinate.

You can follow along the tutorial, or you can download the code right away from my Github repository.

Building the user interface

mini1476308739First we will build the user interface. We will set a MKMapView at the top, pinned to the top, left and right edges, and set a height equal to half the height of its superview. We will also add a UITableView at the bottom, pinned to the MKMapView at the top and to the bottom, left and right edges of the superview.

We will add one cell to the UITableView with the common title-subtitle style, so we can easily set the title to the name of the museum and the subtitle to the coordinate.

We will also set the MKMapView delegate and UITableView delegate and datasource to the ViewController, and will set the outlets in place for both controls.

Defining the POIs and data structures

We will create a PointOfInterest class as the main data structure of our model, with a name for the museum and the coordinate:

class PointOfInterest: NSObject {
  var name: String
  var coordinate: CLLocationCoordinate2D
 
 init(name: String, coordinate: CLLocationCoordinate2D) {
    self.name = name
    self.coordinate = coordinate
 }
 
 init?(csvLine: String) {
    let parts = csvLine.components(separatedBy: ",")
    if parts.count < 3 { return nil }
    guard let latitude = parts[1].toFailableDouble() else { return nil }
    guard let longitude = parts[0].toFailableDouble() else { return nil }
    self.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
    self.name = parts[2].replacingOccurrences(of: "\"", with: "")
 }
}
func ==(lhs: PointOfInterest, rhs: PointOfInterest) -> Bool {
   return lhs.name == rhs.name && lhs.coordinate == rhs.coordinate
}

The “==” method defined for PointOfInterest will be useful later to look for annotations in the map with a given point of interest. We will get the data for the different POIs from a csv file with the following format:

-52.68997,47.57273,"Johnson Geo Centre"
-52.71184,47.56624,"The Rooms"
-52.73261,47.57983,"The Fluvarium"
-63.56583,44.63778,"Pier 21"
...

The init?(csvLine: String) method will read a line of that file and generate a PointOfInterest. Our ViewController will have an array of points of interest, and we will read the values in viewWillAppear. Then we will center the map in a simulated fake user location and will display the annotation pins for that POIs.

class ViewController: UIViewController, MKMapViewDelegate, UITableViewDelegate, UITableViewDataSource {
 // outlets
 @IBOutlet weak var mapView: MKMapView!
 @IBOutlet weak var tableView: UITableView!
 
 // data
 var poi: [PointOfInterest] = []
 let userLocation = CLLocationCoordinate2D(latitude: 38.88833, longitude: -77.01639) 

override func viewWillAppear(_ animated: Bool) {
   super.viewWillAppear(animated)
   loadPointsOfInterest()
   centerMapInInitialCoordinates()
   showPointsOfInterestInMap()
 }
...
}

The loadPointsOfInterest() method will just read the csv file line by line and create the different points of interest, adding them to the “poi” array:

 func loadPointsOfInterest() {
    guard let poiPath = Bundle.main.path(forResource: "museums_usa", ofType: "csv") else {
       print("Unable to find POI file in App's bundle.")
       return
    }
    guard let poiData = FileManager.default.contents(atPath: poiPath) else {
       print("Error getting data from POI file at \(poiPath)")
       return
    }
    guard let poiString = String(data: poiData, encoding: String.Encoding.utf8) else {
       print("Unable to get a valid string from data in POI file \(poiPath)")
       return
    }
 
    for line in poiString.components(separatedBy: "\n") {
       if let point = PointOfInterest(csvLine: line) { poi.append(point) }
    }
 }

centerMapInInitialCoordinates() just centers the map in a simulated user location, setting the visible map region to 100km. In a real application scenario, you will need to get the location of the user via CLLocationManager.

 func centerMapInInitialCoordinates() {
    // fixed user location at latitude: -77.01639, longitude: 38.88833
    mapView.setCenter(userLocation, animated: true)
    let visibleRegion = MKCoordinateRegionMakeWithDistance(userLocation, 100000, 100000)
    self.mapView.setRegion(self.mapView.regionThatFits(visibleRegion), animated: true)
 }

For showing the points of interest in a map, we will need to define a custom class that adheres to the MKAnnotation protocol, containing the point of interest. We will call our class POIAnnotation:

class POIAnnotation: NSObject, MKAnnotation {
   let pointOfInterest: PointOfInterest
   var coordinate: CLLocationCoordinate2D { return pointOfInterest.coordinate }
 
   init(point: PointOfInterest) {
      self.pointOfInterest = point
      super.init()
   }
 
   var title: String? { return pointOfInterest.name }
   var subtitle: String? {
      return "(\(pointOfInterest.coordinate.latitude), \(pointOfInterest.coordinate.longitude))"
   }
}

Then, we will just implement showPointsOfInterestInMap() by removing all current annotations in the map and adding an array of annotations with the current POIs:

func showPointsOfInterestInMap() {
   mapView.removeAnnotations(mapView.annotations)

   for point in poi {
     let pin = POIAnnotation(point: point)
     mapView.addAnnotation(pin)
   }
}

For the list, we will just implement the numberOfRowsInSection and cellForRowAtIndexPath methods.

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return poi.count
}
 
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   let cell = tableView.dequeueReusableCell(withIdentifier: "PointOfInterestCell", for: indexPath)
 
   // configure cell
   let point = poi[indexPath.row]
   cell.textLabel?.text = point.name
   cell.detailTextLabel?.text = "(\(point.coordinate.latitude), \(point.coordinate.longitude))"
 
   return cell
}

Up until this point, this is very basic stuff. Now we will make sure that selecting a POI in the UITableView brings us to the pin location at the map. In order to do that, we will create a method called selectPinPointInTheMap(annotation) and make sure to call it when selecting a row in tableView:didSelectRowAtIndexPath. Remember how we defined a “==” method for PointOfInterest? We will use it now to find the right annotation to select in the map, by getting the POI from the selected cell, and then iterate through all the iterations looking for an annotation whose PointOfInterest is equal to the one selected:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
   let point = poi[indexPath.row]
   if let annotation = (mapView.annotations as? [POIAnnotation])?.filter({ $0.pointOfInterest == point }).first {
      selectPinPointInTheMap(annotation: annotation)
   }
}
 
func selectPinPointInTheMap(annotation: POIAnnotation) {
   mapView.selectAnnotation(annotation, animated: true)
   if CLLocationCoordinate2DIsValid(annotation.coordinate) {
      self.mapView.setCenter(annotation.coordinate, animated: true)
   }
}

If we run the App now, it will show us all the list of POI, and if we click on any item of the list, the map will center on that POI and the MKAnnotationView will appear. Hooray!

Now let’s add the functionality to filter down the list to the points of interest currently shown in the map. We will do this by implementing the MKMapViewDelegate method mapView:regionDidChangeAnimated:. This method is invoked each time the visible region in the map changes, due to a zoom in/out pinch gesture or a swipe. This is exactly what we need. We will also need a variable to store the points that are currently visible in the map, called visiblePOI. We’ll make sure to update visiblePOI whenever a change in the poi origin array happens.

 var visiblePOI: [PointOfInterest] = []

Then, we will create a new method called filterVisiblePOI and call it whenever the mapView:regionDidChangeAnimated: occurs.

func filterVisiblePOI() {
   let visibleAnnotations = self.mapView.annotations(in: self.mapView.visibleMapRect)
   var annotations = [POIAnnotation]()
   for visibleAnnotation in visibleAnnotations {
      if let annotation = visibleAnnotation as? POIAnnotation {
         annotations.append(annotation)
      }
   }
   self.visiblePOI = annotations.map({$0.pointOfInterest})
   self.tableView.reloadSections(IndexSet(integer: 0), with: .automatic)
}
 
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
   filterVisiblePOI()
}

Now, all we need to do is make sure that a change in poi will trigger an update in visiblePOI by means of the didSet event in the poi array, and we will modify all UITableViewDelegate/DataSource methods to make sure they get the information from visiblePOI instead of poi.

var poi: [PointOfInterest] = [] { didSet { visiblePOI = poi; filterVisiblePOI() } }

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
   return visiblePOI.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   ... 
   // configure cell
   let point = visiblePOI[indexPath.row]
   ...
}
  
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
   let point = visiblePOI[indexPath.row]
   ...
}

ezgif-com-optimizeWe will also add a call to filterVisiblePOI() in viewDidAppear, to make sure that we will start with the currently visible pins displayed in the list, not all of them.

override func viewDidAppear(_ animated: Bool) {
   super.viewDidAppear(animated)
   filterVisiblePOI()
}

And that’s it! The result is a nice interaction between the map and the list of POI. As always, you can download the full project in my github repository.

 

 

 

Comment(1)

Artem
May 3, 2018 At 2:07 pm

Great article! Why did you create the class POIAnnotation, you would can use PointOfInterest class instead? I didn’t understand

Leave a Comment

sing in to post your comment or sign-up if you dont have any account.