Display a driver's reported location from a server

Let's say a driver is using turn-by-turn directions and the driver's mobile app will report the current location and ETA to a server.

In this example, from a second mobile app, we monitor the driver's location and ETA, and display that on a map.

You will need to provide your own server for this. In our example, we use WebSockets to connect to the server in order to receive immediate updates as the location or ETA changes. In your implementation, you might choose to use WebSockets or some other similar technology to meet this need.

Before you do this:

This is an advanced example, with a lot of moving parts (two client apps and one server). If you haven't already, we suggest getting a feel for the SDK with some of the more basic examples before trying this one.

First make sure you have turn-by-turn navigation up and running in your app. If you haven't done this yet, here's how .

You will also need a server that can receive and send data for this example. We provide a reference server that you can use for testing this, but you will probably want to replace it with your own server implementation later.

If you use our reference server, you will also need to implement a driver app that can report the current location and ETA to a server.

If you've done all that, then by all means, read on!

iOS doesn't have built-in support for WebSockets, so we can use a third-party library to help us with that. For this example we will use Starscream . Add it to your Podfile :

pod 'Starscream'

Then run pod install . In your view controller, import it:

import Starscream

Let's set up some string constants for the two kinds of events we will be handling:

enum EventType: String {
    case currentLocation = "current_location"
    case ETA = "eta"
}

You will also need a TGMapView . In this example, we'll assume you have set one up in a Storyboard. If you would prefer to do it in code, see this example .

@IBOutlet weak var mapView: TGMapView!

Now let's set up a WebSockets connection and parsing for any data we receive through it. We assume the data will be in JSON format.

var socket: WebSocket?

func setupWebSocket() {
    let socket = WebSocket(url: URL(string: "ws://localhost:3200/")!)

    socket.onConnect = {
        print("WebSocket is connected")
    }

    socket.onDisconnect = { (error: Error?) in
        print("WebSocket is disconnected: \(error?.localizedDescription ?? "")")
    }

    socket.onText = { (text: String) in
        do {
            var data = try JSONSerialization.jsonObject(with: text.data(using: .utf8)!, options: []) as! Dictionary<String, Any>

            guard let eventTypeString = data["event_type"] as? String,
                let eventType = EventType(rawValue: eventTypeString),
                let payload = data["payload"] as? Dictionary<String, Any> else {

                NSLog("JSON not in expected format: \(text)")
                return
            }

            self.handleEvent(eventType: eventType, payload: payload)

        } catch (let error) {
            NSLog("Error parsing JSON from WebSocket: \(error), JSON: \(text)")
        }
    }

    socket.connect()

    self.socket = socket
}

WebSockets are a persistent connection, so we should have a method for disconnecting when we need to.

func teardownWebSocket() {
    socket?.disconnect()
}

Assuming this view controller is dedicated to this purpose, it's convenient to have it connect and disconnect whenever it appears or disappears, respectively.

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    setupWebSocket()
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)

    teardownWebSocket()
}

So, we have connected to the server and parsed the JSON data, but we still need to do something with that data. Let's separate that out into the two kinds of events we are handling:

func handleEvent(eventType: EventType, payload: Dictionary<String, Any>) {
    switch eventType {
    case .currentLocation:
        guard let latitude = payload["latitude"] as? Double, let longitude = payload["longitude"] as? Double else {
            NSLog("Payload not in expected format: \(payload)")
            return
        }

        let location = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
        self.handleLocationEvent(location: location)

    case .ETA:
        guard let ETAString = payload["ETA"] as? String else {
            NSLog("Payload not in expected format: \(payload)")
            return
        }

        let formatter = ISO8601DateFormatter()
        guard let ETA = formatter.date(from: ETAString) else {
            NSLog("Date not in expected format: \(ETAString)")
            return
        }

        self.handleETAEvent(ETA: ETA)
    }
}

The first type is the current location event. This places an annotation marker on the map to show the driver's current location, and scrolls the map to keep it in focus:

var driverAnnotation: MGLPointAnnotation = MGLPointAnnotation()

func handleLocationEvent(location: CLLocationCoordinate2D) {
    // Add the annotation to the map, if it hasn't been already
    if mapView.annotations == nil || !(mapView.annotations! as NSArray).contains(driverAnnotation) {
        mapView.addAnnotation(driverAnnotation)
    }

    driverAnnotation.coordinate = location

    mapView.setCenter(location, zoomLevel: 15, animated: true)
}

We need to implement a delegate method to return an image for the map marker. This example uses a simple map marker icon provided by TallyGoKit, but you can swap it out for any UIImage . Make sure your view controller implements the MGLMapViewDelegate protocol. Also make sure that your TGMapView has its delegate property set to your view controller. Then implement this delegate method:

func mapView(_ mapView: MGLMapView, imageFor annotation: MGLAnnotation) -> MGLAnnotationImage? {
    let ID = "driver annotation image"
    if let annotationImage = mapView.dequeueReusableAnnotationImage(withIdentifier: ID) {
        return annotationImage
    } else {
        let image = TallyGoStyleKit.imageOfGenericPlaceIcon()!

        return MGLAnnotationImage(image: image, reuseIdentifier: ID)
    }
}

Now let's handle the remaining event type, which is the ETA. We'll parse the date, and for now, we'll just set it to the view controller's title, since that's easy; you can of course display it however you would like on the screen.

func handleETAEvent(ETA: Date) {
    let formatter = DateFormatter()
    formatter.dateStyle = .none
    formatter.timeStyle = .long

    let ETAString = formatter.string(from: ETA)
    self.navigationItem.title = "ETA: \(ETAString)"
}

And with that, you're done!

You should now be able to run turn-by-turn navigation in the first (driver's) app from the other examples, which will report the current location and ETA to the server whenever it changes, which the server will then broadcast over WebSockets, which the second (this) app will then display on a map.

iOS doesn't have built-in support for WebSockets, so we can use a third-party library to help us with that. For this example we will use Jetfire . Add it to your Podfile :

pod 'jetfire'

Then run pod install . In your view controller, import it:

#import <jetfire/JFRWebSocket.h>

Let's set up some string constants for the two kinds of events we will be handling:

typedef NSString *DriverEventType NS_TYPED_ENUM;

DriverEventType const DriverEventTypeCurrentLocation = @"current_location";
DriverEventType const DriverEventTypeETA = @"eta";

You will also need a TGMapView . In this example, we'll assume you have set one up in a Storyboard. If you would prefer to do it in code, see this example .

@property (weak, nonatomic) IBOutlet TGMapView *mapView;

We will also need a property for the WebSockets connection.

@property (nonatomic, nullable) JFRWebSocket *socket;

Now let's set up the connection and parsing for any data we receive through it. We assume the data will be in JSON format.

- (void)setupWebSocket {
    JFRWebSocket *socket = [JFRWebSocket.alloc initWithURL:[NSURL URLWithString:@"ws://localhost:3200/"] protocols:@[]];

    socket.onConnect = ^{
        NSLog(@"WebSocket is connected");
    };

    socket.onDisconnect = ^(NSError *error) {
        NSLog(@"WebSocket is disconnected: %@", error.userInfo);
    };

    socket.onText = ^(NSString *text) {
        NSError *error = nil;
        NSDictionary *data = [NSJSONSerialization JSONObjectWithData:[text dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&error];
        if (error != nil || ![data isKindOfClass:NSDictionary.class]) {
            NSLog(@"Error parsing JSON from WebSocket: %@, JSON: %@", error.userInfo, text);
            return;
        }

        DriverEventType eventType = data[@"event_type"];
        NSDictionary *payload = data[@"payload"];

        if (![eventType isKindOfClass:NSString.class] || ![payload isKindOfClass:NSDictionary.class]) {
            NSLog(@"JSON not in expected format: %@", text);
            return;
        }

        [self handleEvent:eventType payload:payload];
    };

    [socket connect];

    self.socket = socket;
}

WebSockets are a persistent connection, so we should have a method for disconnecting when we need to.

- (void)teardownWebSocket {
    [self.socket disconnect];
}

Assuming this view controller is dedicated to this purpose, it's convenient to have it connect and disconnect whenever it appears or disappears, respectively.

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];

    [self setupWebSocket];
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];

    [self teardownWebSocket];
}

So, we have connected to the server and parsed the JSON data, but we still need to do something with that data. Let's separate that out into the two kinds of events we are handling:

- (void)handleEvent:(nonnull DriverEventType)eventType payload:(nonnull NSDictionary *)payload {
    if ([eventType isEqualToString:DriverEventTypeCurrentLocation]) {
        NSNumber *latitudeObj = payload[@"latitude"];
        NSNumber *longitudeObj = payload[@"longitude"];
        if (![latitudeObj isKindOfClass:NSNumber.class] || ![longitudeObj isKindOfClass:NSNumber.class]) {
            NSLog(@"Payload not in expected format: %@", payload);
            return;
        }

        CLLocationCoordinate2D location = CLLocationCoordinate2DMake(latitudeObj.doubleValue, longitudeObj.doubleValue);
        [self handleLocationEvent:location];

    } else if ([eventType isEqualToString:DriverEventTypeETA]) {
        NSString *ETAString = payload[@"ETA"];
        if (![ETAString isKindOfClass:NSString.class]) {
            NSLog(@"Payload not in expected format: %@", payload);
            return;
        }

        NSISO8601DateFormatter *formatter = NSISO8601DateFormatter.new;
        NSDate *ETA = [formatter dateFromString:ETAString];
        if (![ETA isKindOfClass:NSDate.class]) {
            NSLog(@"Date not in expected format: %@", ETAString);
            return;
        }

        [self handleETAEvent:ETA];

    } else {
        NSLog(@"Unexpected event type: %@", eventType);
    }
}

The first type is the current location event. This places an annotation marker on the map to show the driver's current location, and scrolls the map to keep it in focus.

First, we need another property for the annotation:

@property (nonatomic, nonnull) MGLPointAnnotation *driverAnnotation;

// Make sure to init the annotation when the view controller class loads
- (void)viewDidLoad {
    [super viewDidLoad];

    self.driverAnnotation = MGLPointAnnotation.new;
}

And now the logic:

- (void)handleLocationEvent:(CLLocationCoordinate2D)location {
    // Add the annotation to the map, if it hasn't been already
    if (self.mapView.annotations == nil || ![self.mapView.annotations containsObject:self.driverAnnotation]) {
        [self.mapView addAnnotation:self.driverAnnotation];
    }

    self.driverAnnotation.coordinate = location;

    [self.mapView setCenterCoordinate:location zoomLevel:15 animated:YES];
}

We need to implement a delegate method to return an image for the map marker. This example uses a simple map marker icon provided by TallyGoKit, but you can swap it out for any UIImage . Make sure your view controller implements the MGLMapViewDelegate protocol. Also make sure that your TGMapView has its delegate property set to your view controller. Then implement this delegate method:

- (MGLAnnotationImage *)mapView:(MGLMapView *)mapView imageForAnnotation:(id)annotation {
    static NSString *ID = @"driver annotation image";
    MGLAnnotationImage *annotationImage = [mapView dequeueReusableAnnotationImageWithIdentifier:ID];
    if (annotationImage != nil) {
        return annotationImage;
    } else {
        UIImage *image = TallyGoStyleKit.imageOfGenericPlaceIcon;

        return [MGLAnnotationImage annotationImageWithImage:image reuseIdentifier:ID];
    }
}

Now let's handle the remaining event type, which is the ETA. We'll parse the date, and for now, we'll just set it to the view controller's title, since that's easy; you can of course display it however you would like on the screen.

- (void)handleETAEvent:(NSDate *)ETA {
    NSDateFormatter *formatter = NSDateFormatter.new;
    formatter.dateStyle = NSDateFormatterNoStyle;
    formatter.timeStyle = NSDateFormatterLongStyle;

    NSString *ETAString = [formatter stringFromDate:ETA];
    self.navigationItem.title = [NSString stringWithFormat:@"ETA: %@", ETAString];
}

And with that, you're done!

You should now be able to run turn-by-turn navigation in the first (driver's) app from the other examples, which will report the current location and ETA to the server whenever it changes, which the server will then broadcast over WebSockets, which the second (this) app will then display on a map. Phew!

Now I can see my burrito travelling towards me in real time, as well as an ETA for when it will get here, which I'm sure you'll agree makes this all worth it.

Expected Output
TallyGo
Check out the iOS Reference App to see this example in action.