Build a station finder, part 4

In part 3, we customized the markers for our annotations. Let’s wrap up our application by giving users the ability to choose which rail line colors they want to see markers for. We’ll add a new modal table view controller showing each line color, as well as a checkmark to show the current selection state for the map:

filter lines

This guide will be a little more difficult than the previous ones, but we’ll make sure to explain each step thoroughly.

Adding filtering

To display a list of lines with selection checkmarks, we are going to use a UITableView in our project. This table view will be displayed in a modal window that slides up and covers our map during selection.

We’ll also need to create a way to tell our main view controller that the filtering has updated, so we’ll create our own delegate protocol (similar to the RMMapViewDelegate protocol that we used for markers and callouts in part 3). Then we’ll make our main view controller a delegate of the new filtering table view controller. This lets us decouple the design of the main and filter view controllers while still letting them work together.

Finally, we’ll need to store a NSMutableSet of our markers as we create them. This way we can load them just once, but we can iterate over them after creation and show or hide them. We are using a NSMutableSet instead of an NSMutableArray to ensure a marker is only stored once, since a set can’t contain duplicate objects.

Note: Persisting the selected lines is out of the scope of this guide, so by default we are going to show all lines when the view loads.

Tracking markers

When we added markers in the previous guides, we didn’t store them in a way that allows us to iterate over them after creation. We don’t want to have to reload the markers by re-reading and re-parsing the GeoJSON every time the user changes their filtering selection, so we’ll create a property to store them after creation.

We’ll also create a property to store which line colors the user wants to see.

Adding NSMutableSet properties

In ViewController.m, add two property declarations for our mutable sets in the @interface declaration:

@interface ViewController () <RMMapViewDelegate>

@property (nonatomic, strong) RMMapView *mapView;
@property (nonatomic, strong) NSMutableSet *selectedLines;
@property (nonatomic, strong) NSMutableSet *stationAnnotations;

@end

In our viewDidLoad method, we need to initialize the selected lines with all line colors (every time we launch the app, all six lines will be selected by default). We also need to instantiate our stationAnnotations property so that we can start adding to it when the markers are created.

Add these lines to the end of viewDidLoad right before we call the loadStations method:

self.selectedLines = [[NSMutableSet alloc] initWithArray:@[@"Blue", @"Green", @"Orange", @"Red", @"Silver", @"Yellow"]];
self.stationAnnotations = [[NSMutableSet alloc] init];

[self loadStations];

Tracking markers

In our loadStations method where we create annotations from our GeoJSON, find the line where we set the annotation’s userInfo property. After that line, add the current station to the self.stationAnnotations mutable set:

stationAnnotation.userInfo = properties;

[self.stationAnnotations addObject:stationAnnotation];

Now, as the annotations are created, they are added to a mutable set that we can iterate over later to change the visibility of the marker. This allows us to load the markers from disk once but iterate over them at a later time.

Showing and hiding markers

When we created markers in the previous guides, we displayed them regardless of what line colors they are associated with. Now that we’ll add filtering, we need to be able to decide if an annotation should be displayed or not. We can do that by comparing the annotation’s lines to the user’s selected lines.

Each marker annotation has a userInfo dictionary property with information about that marker. One of the keys in that dictionary is an NSArray called lines that contains the colors of the lines that station services.

We need to create a method that looks in that array to see if it contains one of the lines the user wants to display. Add this method to ViewController.m:

- (BOOL)annotationShouldBeHidden:(RMAnnotation *)annotation
{
    NSSet *stationLineColors = [NSSet setWithArray:annotation.userInfo[@"lines"]];
    BOOL doesIntersect = [stationLineColors intersectsSet:self.selectedLines];
    return !doesIntersect;
}

This method might look a bit confusing at first glance. We’re comparing our self.selectedLines property (the line colors that the user wants to display) to the line colors in the annotation to see if they intersect.

Since we have NSSet and NSMutableSet objects, we can call intersectsSet: to see if any of the annotation’s line colors are in the selected lines set (an intersection occurs when an item in one set matches at least one item in another set).

At the end of the method, we return the opposite of doesIntersect. The purpose of this method is to decide if the annotation should be hidden; if the sets intersect then the annotation should not be hidden.

This will make more sense shortly; just remember that the annotation should be hidden if the annotation’s lines aren’t in the lines that the user wants to see.

Setting marker visibility

In the mapView:layerForAnnotation: method inside ViewController.m, we need to set the hidden property before we return the marker:

marker.hidden = [self annotationShouldBeHidden:annotation];
return marker;

Build and run, and you should still see all of the markers displayed because by default we ‘re still telling it to display all lines.

To make sure the new functionality works, comment out the line we added to viewDidLoad to set the self.selectedLines property. If you build and run with that commented out, you should see no markers on the map because there are no selected lines:

//self.selectedLines = [[NSMutableSet alloc] initWithArray:@[@"Blue", @"Green", @"Orange", @"Red", @"Silver", @"Yellow"]];
self.stationAnnotations = [[NSMutableSet alloc] init];

commented out

Remove the comment before continuing.

Adding a filtering screen

We need to add one more view controller so the user can choose which lines they wish to see. We’ll implement this as a modal UITableView with checkmarks. The result will look like this:

filter lines

Since we added our web view controller programmatically, we’ll do the same here. You are welcome to use storyboards for this if you prefer; the setup will be very similar.

Add a new file called FilterTableViewController and make it a subclass of UITableViewController. (Add a new file by clicking File → New → File and then selecting Cocoa Touch Class from the iOS Source panel.)

FilterTableViewController

Tracking selections

In the FilterTableViewController.h header file, we’re going to declare a property to hold a mutable set of the selected lines. As the user checks and unchecks a line by tapping on the row, the property will be updated to reflect the changes. We’re setting this property in the header file so that it’s visible to other classes:

#import <UIKit/UIKit.h>

@interface FilterTableViewController : UITableViewController

@property (nonatomic, strong) NSMutableSet *selectedLines;

@end

Before we start working in the FilterTableViewController.m implementation file, delete everything in the file. There is a lot of boilerplate code for working with the table views that we won’t need.

At the top of FilterTableViewController.m, declare a private array property for the available line colors:

#import "FilterTableViewController.h"

@interface FilterTableViewController ()

@property (nonatomic, copy) NSArray *lineColors;

@end

Also in the FilterTableViewController.m, set the line colors that will appear as rows in our table. Place this right after @end in the previous code block:

@implementation FilterTableViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    // These are the lines we support, each should be a row in the table
    self.lineColors = @[@"Blue", @"Green", @"Orange", @"Red", @"Silver", @"Yellow"];
}
@end

Registering a table view cell class

Since we are creating our table view programmatically, we need to tell it what class to use when creating table view cells. Since we will use a regular UITableViewCell for our cells, register it at the end of the viewDidLoad method in FilterTableViewController.m:

[self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"Cell"];

Now when our table view creates rows it will use a UITableViewCell to do it.

Adding a "Done" button

We need to put a Done button in our navigation bar that the user can tap when they finish adjusting their selected lines. Add this to the viewDidLoad method in FilterTableViewController.m:

UIBarButtonItem *doneButton =
    [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone
                                                  target:self
                                                  action:@selector(donePressed:)];

self.navigationItem.rightBarButtonItem = doneButton;

When the user taps the new Done button, it will call the donePressed: method, so we need to create it. Add this method to our FilterTableViewController.m file:

- (void)donePressed:(id)sender
{
    [self dismissViewControllerAnimated:YES completion:nil];
}

When the user presses the done button, the app will dismiss the modal view.

Adding rows

Next, we need to implement the numberOfSectionsInTableView: and tableView:cellForRowAtIndexPath delegate methods for the table view. If you have worked with table views before, this should be familiar.

Our table view only has a single section and the number of rows should equal the number of colors we declared in the viewDidLoad method above. Add this to FilterTableViewController.m:

#pragma mark - Table view data source

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return self.lineColors.count;
}

It’s better to return self.lineColors.count here, rather than a hardcoded number, to make our code more future-proof to changes we might make later on.

Adding checkmarks

Add a tableView:cellForRowAtIndexPath: method to FilterTableViewController.m, assigning the line color to the cell’s text label and putting a checkmark on the cell if that color line is currently selected:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

    UITableViewCell *cell =
    	[tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];

    NSString *lineColor = self.lineColors[indexPath.row];
    cell.textLabel.text = lineColor;

    BOOL shouldBeChecked = [self.selectedLines containsObject:lineColor];

    if (shouldBeChecked)
        cell.accessoryType = UITableViewCellAccessoryCheckmark;
    else
        cell.accessoryType = UITableViewCellAccessoryNone;

    return cell;
}

Toggling checkmarks

Finally, we need to implement the tableView:didSelectRowAtIndexPath: method that gets called when the user taps on a cell. We check to see which cell was tapped and if that cell is currently selected. If the cell is currently selected, we remove the checkmark. If not, we add a checkmark.

When the user taps the row, we add or remove that line color from our selectedLines property (the running list of what is and is not checked).

Before we finish, we set the cell selection to NO. (We don’t want the cell to remain highlighted; instead we’ll get a momentary flash of selection when the user taps on it.)

Add the following to FilterTableViewController.m:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
    NSString *lineColor = self.lineColors[indexPath.row];

    // See if the color is currently in the selected line colors
    BOOL isCurrentlySelected = [self.selectedLines containsObject:lineColor];

    // If it's currently selected, deselect it
    if (isCurrentlySelected) {
        cell.accessoryType = UITableViewCellAccessoryNone;
        [self.selectedLines removeObject:lineColor];
    }
    else {
        cell.accessoryType = UITableViewCellAccessoryCheckmark;
        [self.selectedLines addObject:lineColor];
    }

    // Deselect the row so it does not stay highlighted
    cell.selected = NO;
}

First we need to import FilterTableViewController.h at the top of our ViewController.m file:

#import "FilterTableViewController.h"

To see our new filtering table view, we need to add a button to our ViewController.m file’s navigation bar. In the viewDidLoad method of ViewController.m, add a button to the navigation bar:

// Add a button to our navigation controller
UIBarButtonItem *filterButton = [[UIBarButtonItem alloc] initWithTitle:@"Filter"
                                                                  style:UIBarButtonItemStylePlain
                                                                target:self
                                                                action:@selector(filterButtonPressed:)];
self.navigationItem.rightBarButtonItem = filterButton;

Also in ViewController.m, create the filterButtonPressed: method that our button calls:

- (void)filterButtonPressed:(id)sender
{
    FilterTableViewController *filterVC = [[FilterTableViewController alloc] initWithStyle:UITableViewStyleGrouped];
    filterVC.selectedLines = self.selectedLines;
    filterVC.title = @"Filter Lines";

    UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:filterVC];
    nav.modalPresentationStyle = UIModalTransitionStyleCoverVertical;
    [self presentViewController:nav animated:YES completion:nil];
}

This method creates our FilterTableViewController with a grouped-style table view, tells it what our currently selected lines are, and puts it into a UINavigationController before presenting it. From a presentation point of view, it works very similar to our modal web view controller that we made in part 3.

Testing the selections

Build and run, and you should see a Filter button in our main view. Tapping it should launch the filtering table view:

filter checkmarks

When the filtering table view appears, all of the rows should be checked. Tapping a row with a checkmark should remove the checkmark (and vice-versa).

When you hit the Done button, the modal view is dismissed. The markers won’t change yet, but we’re almost there.

Let’s add a last bit of polish to the table view by adding a header above it. In FilterViewController.m, add the tableView:titleForHeaderInSection: delegate method:

- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
{
	return @"Select the lines to display on the map";
}

Build and run, and you should see the header above your table view.

table view header

Updating the map

When the user changes their line selections and presses Done, we’re going to need a way to tell our main view controller that the selected lines have changed. To accomplish this, we need our filter view controller to call a method in our main view controller when it closes.

We’ll do this with the delegation, a design pattern common in Cocoa programming.

Creating a protocol

In our FilterTableViewController.h header file, we need to declare a protocol and say which method(s) a delegate needs to implement. Add this to the top of FilterTableViewController.h after the imports and before the @interface line.

#import <UIKit/UIKit.h>

@protocol StationFilterDelegate

@required

- (void)didUpdateLines:(NSMutableSet *)selectedLines;

@end

@interface FilterTableViewController : UITableViewController

This code says that we are creating a protocol called StationFilterDelegate and that any class that wishes to conform to that protocol must have a didUpdateLines: method.

We need to add a property that assigns a weak reference to the delegate of our class. Add the delegate property to the @interface section of FilterViewController.h:

@interface FilterTableViewController : UITableViewController

@property (nonatomic, strong) NSMutableSet *selectedLines;
@property (nonatomic, weak) id<StationFilterDelegate> delegate;

@end

Cocoa objects as a rule shouldn’t keep a strong reference to their delegates, since this can lead to memory issues, which is why we make this a weak property.

Working with delegation is a more advanced topic in Cocoa programming, but it’s an extremely powerful way of building flexible, modular software. We are keeping track of who called the filtering table view controller. This allows us to say: “When you’re done filtering, we have an object for you to notify. You don’t have to know anything about how the internals of that object work, but you can be sure that it will understand how to handle the message according to our shared protocol.”

Calling our delegate method

The last thing we need to do is update our donePressed: method to call our didUpdateLines delegate method before the modal window closes.

In FilterTableViewController.m, update the donePressed: method to call the delegate method before we dismiss ourselves:

- (void)donePressed:(id)sender
{
    [self.delegate didUpdateLines:self.selectedLines];
    [self dismissViewControllerAnimated:YES completion:nil];
}

Responding to updates

We’re in the home stretch now!

Setting the delegate

In our ViewController.m file, we need to do a couple things to update the map when the filter view controller closes.

First, update our filterButtonPressed: method in ViewController.m and set the delegate of FilterTableViewController to self right after we set the title:

filterVC.title = @"Filter Lines";
filterVC.delegate = self;

This tells our filtering table view controller: “I’m the class that has the method to call when you’re done.”

Next, update the @interface line at the top of ViewController.m to conform to both the RMMapViewDelegate protocol and the StationFilterDelegate protocol:

@interface ViewController () <RMMapViewDelegate, StationFilterDelegate>

Now, when FilterViewController is instantiated, ViewController will be the delegate.

Implementing the delegate method

We need to implement the delegate method called didUpdateLines: that will get called by our FilterTableViewController when it closes. Add this method to ViewController.m:

#pragma mark Station Loader Delegate Methods

- (void)didUpdateLines:(NSMutableSet *)selectedLines;
{
    self.selectedLines = selectedLines;

    for (id annotation in self.stationAnnotations)
    {
        RMAnnotation *theAnnotation = (RMAnnotation *)annotation;
        NSLog(@"Should be hidden: %d", [self annotationShouldBeHidden:annotation]);
        theAnnotation.layer.hidden = [self annotationShouldBeHidden:annotation];
    }

}

Build and run, then open the filtering screen and uncheck all the lines. Press Done and see that our map is not showing any markers:

filter unchecked

Update the selections and add back the lines you wish to display. You should see the markers update when you do.

Note: Many stations service multiple lines, so it’s not uncommon to see the same markers for different selections.

Mission accomplished

Awesome work! You made an iOS app!

Screenshot

This was a pretty tough section. We covered a lot of information, including:

  • Adding a filtering table view in a custom UITableViewController subclass
  • Showing table view rows for each line color
  • Checking and unchecking selections when the user taps rows
  • Creating a delegate protocol for updating lines
  • Updating the map when the user changes their selections

We placed all the code for the Station Finder app in a repo for you to browse.

Additional questions? Ask our support team or learn more about How Mapbox Works.