PerspecDev Blog

Stuff we know stuff about.

Using CoreLocation (GPS) on iOS to Track a User's Distance and Speed

There are tons of great tutorials online for using CoreLocation. Apple even provides some sample code. So why am I bothering to write this article? Because all the existing tutorials focus on getting the user’s longitude and latitude, but none actually show you how to get the user’s travelled distance or speed. With that in mind, here are the main goals for the project we’ll be creating:

  • Determine the user’s travelled distance
  • Determine the user’s current speed
  • Determine GPS “signal” strength
  • Generate waypoints that can be saved and later used to recreate a user’s path

Heads up: I’m assuming you are comfortable with Objective C and iOS development. If not, Apple has provided a great starting point for you here. You should really be comfortable with all that before proceeding.

Alright, let’s dig right in. This isn’t going to be a tutorial, per se. Instead, I’m going to just go over some of the highlights and methodology behind my PSLocationManager class. You can follow along on the GitHub project here:

https://github.com/perspecdev/PSLocationManager

One quick prerequisite: PSLocationManager uses CoreLocation, and it’s easy to forget to add the framework to your project.

CLLocationManager does most of the heavy lifting, so you really need to be familiar with it. The documentation can be found here. One important thing to keep in mind is that CLLocationManager doesn’t ping your app at some regular interval with location updates. Instead, you provide CLLocationManager with a delegate, and the delegate methods are only called when the user changes location.

Before writing any code, it’s important to take a close look at the goals for the project:

  • Determine the user’s travelled distance

    CLLocationManager doesn’t provide any functionality for this, so PSLocationManager needs to calculate the travelled distance itself.

  • Determine the user’s current speed

    CLLocationManager does provide a speed property, but I wanted to customize how and when speed is calculated (e.g. to average the user’s speed over some span of time, rather than just calculating the speed between the last two locations received).

  • Determine GPS “signal” strength

    CLLocationManager doesn’t provide any indication of GPS “signal” strength.

  • Generate waypoints that can be saved and later used to recreate a user’s path

    For this, the CLLocation objects passed to CLLocationManager’s delegate can just be rebroadcast.

Knowing what we want to get out of PSLocationManager, we can go ahead and write a delegate protocol:

PSLocationManagerDelegate PSLocationManager.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef enum {
    PSLocationManagerGPSSignalStrengthInvalid = 0
    , PSLocationManagerGPSSignalStrengthWeak
    , PSLocationManagerGPSSignalStrengthStrong
} PSLocationManagerGPSSignalStrength;

@protocol PSLocationManagerDelegate <NSObject>

@optional
- (void)locationManager:(PSLocationManager *)locationManager signalStrengthChanged:(PSLocationManagerGPSSignalStrength)signalStrength;
- (void)locationManagerSignalConsistentlyWeak:(PSLocationManager *)locationManager;
- (void)locationManager:(PSLocationManager *)locationManager distanceUpdated:(CLLocationDistance)distance;
- (void)locationManager:(PSLocationManager *)locationManager waypoint:(CLLocation *)waypoint calculatedSpeed:(double)calculatedSpeed;
- (void)locationManager:(PSLocationManager *)locationManager error:(NSError *)error;
- (void)locationManager:(PSLocationManager *)locationManager debugText:(NSString *)text;

@end

All of that should be pretty straightforward. I went ahead and included a locationManager:debugText: method. It could be useful later for debugging things that aren’t exposed publicly.

Now the public interface for PSLocationManager can be defined:

PSLocationManager PSLocationManager.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@interface PSLocationManager : NSObject <CLLocationManagerDelegate>

@property (nonatomic, weak) id<PSLocationManagerDelegate> delegate;
@property (nonatomic, readonly) PSLocationManagerGPSSignalStrength signalStrength;
@property (nonatomic, readonly) CLLocationDistance totalDistance;
@property (nonatomic, readonly) NSTimeInterval totalSeconds;
@property (nonatomic, readonly) double currentSpeed;

+ (PSLocationManager *)sharedLocationManager;

- (BOOL)prepLocationUpdates; // this must be called before startLocationUpdates (best to call it early so we can get an early lock on location)
- (BOOL)startLocationUpdates;
- (void)stopLocationUpdates;
- (void)resetLocationUpdates;

@end

PSLocationManager will manage the CLLocationManager object, so it conforms to CLLocationManagerDelegate. Most of the properties here are self-explanatory. I included a totalSeconds property, which the delegate might use to calculate the user’s current pace. With the exception of delegate, all the properties are readonly, since there’s no good reason any external object should modify their values. They’ll be redefined as assign (implicitly) in the private category, which I’ll discuss a little later on.

Note also that I’ve included a sharedLocationManager class method, so that PSLocationManager can be used as a singleton.

The prepLocationUpdates method exists so that an app can initialize the CoreLocation service early on, since it might take a few seconds to get an accurate GPS reading. startLocationUpdates is what will actually begin tracking the user’s distance and speed.

And that’s all we need for the header file. I’m going to throw some defines at you for the implementation file. Don’t worry too much about them for now. They’re all used in PSLocationManager, but I won’t cover them in great detail here because they’re pretty well documented already:

PSLocationManager PSLocationManager.m
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static const NSUInteger kDistanceFilter = 5; // the minimum distance (meters) for which we want to receive location updates (see docs for CLLocationManager.distanceFilter)
static const NSUInteger kHeadingFilter = 30; // the minimum angular change (degrees) for which we want to receive heading updates (see docs for CLLocationManager.headingFilter)
static const NSUInteger kDistanceAndSpeedCalculationInterval = 3; // the interval (seconds) at which we calculate the user's distance and speed
static const NSUInteger kMinimumLocationUpdateInterval = 10; // the interval (seconds) at which we ping for a new location if we haven't received one yet
static const NSUInteger kNumLocationHistoriesToKeep = 5; // the number of locations to store in history so that we can look back at them and determine which is most accurate
static const NSUInteger kValidLocationHistoryDeltaInterval = 3; // the maximum valid age in seconds of a location stored in the location history
static const NSUInteger kNumSpeedHistoriesToAverage = 3; // the number of speeds to store in history so that we can average them to get the current speed
static const NSUInteger kPrioritizeFasterSpeeds = 1; // if > 0, the currentSpeed and complete speed history will automatically be set to to the new speed if the new speed is faster than the averaged speed
static const NSUInteger kMinLocationsNeededToUpdateDistanceAndSpeed = 3; // the number of locations needed in history before we will even update the current distance and speed
static const CGFloat kRequiredHorizontalAccuracy = 20.0; // the required accuracy in meters for a location.  if we receive anything above this number, the delegate will be informed that the signal is weak
static const CGFloat kMaximumAcceptableHorizontalAccuracy = 70.0; // the maximum acceptable accuracy in meters for a location.  anything above this number will be completely ignored
static const NSUInteger kGPSRefinementInterval = 15; // the number of seconds at which we will attempt to achieve kRequiredHorizontalAccuracy before giving up and accepting kMaximumAcceptableHorizontalAccuracy

static const CGFloat kSpeedNotSet = -1.0;

PSLocationManager keeps track of a few things that don’t need to be exposed publicly, so I used a private category in the implementation file. This separation helps to keep the role of PSLocationManager clearly defined. If you just throw all the properties in the header file, it’s really easy to just access them from other classes. And that’s a downward spiral toward bugs and spaghetti code. By keeping the header file clean of things that don’t need to be there, you’re forced to put some thought into the way you implement new functionality.

Here’s what the init method looks like:

PSLocationManager PSLocationManager.m
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (id)init {
    if ((self = [super init])) {
        if ([CLLocationManager locationServicesEnabled]) {
            self.locationManager = [[CLLocationManager alloc] init];
            self.locationManager.delegate = self;
            self.locationManager.desiredAccuracy = kCLLocationAccuracyBest;
            self.locationManager.distanceFilter = kDistanceFilter;
            self.locationManager.headingFilter = kHeadingFilter;
        }

        self.locationHistory = [NSMutableArray arrayWithCapacity:kNumLocationHistoriesToKeep];
        self.speedHistory = [NSMutableArray arrayWithCapacity:kNumSpeedHistoriesToAverage];
        [self resetLocationUpdates];
    }

    return self;
}

self.locationHistory is used to keep track of several of the most recently received CLLocation objects. When it’s time to calculate the user’s distance, it’ll loop through the recent locations and use the one with the best accuracy. self.speedHistory is used to calculate the user’s current speed. CLLocation objects (which are received from the locationManager:didUpdateToLocation:fromLocation: method) do provide a speed property, but in this case I wanted to calculate the user’s speed myself in order to have better control over the speed reported to the delegate. Take a look above at the comments for kPrioritizeFasterSpeeds in the defines above to get an idea of how this will prove useful.

There are a lot of things going on in PSLocationManager, but the juiciest bits are in the locationManager:didUpdateToLocation:fromLocation: delegate method. I’m going to pull out bits and pieces of it here, so it’s probably easiest if you also pull up the entire method for context.

Here we go:

1
2
3
4
5
6
7
8
9
10
11
12
if (newLocation.horizontalAccuracy <= kRequiredHorizontalAccuracy) {
    self.signalStrength = PSLocationManagerGPSSignalStrengthStrong;
} else {
    self.signalStrength = PSLocationManagerGPSSignalStrengthWeak;
}

double horizontalAccuracy;
if (self.allowMaximumAcceptableAccuracy) {
    horizontalAccuracy = kMaximumAcceptableHorizontalAccuracy;
} else {
    horizontalAccuracy = kRequiredHorizontalAccuracy;
}

PSLocationManager notifies its delegate of the GPS “signal” strength. Since CLLocationManager doesn’t directly provide that info, this was my way of determining that. Basically, there are two #define constants used to compare against the horizontalAccuracy property of a CLLocation object: kRequiredHorizontalAccuracy and kMaximumAcceptableHorizontalAccuracy. kRequiredHorizontalAccuracy specifies the horizontal accuracy needed for what I consider to be a strong GPS signal. kMaximumAcceptableHorizontalAccuracy specifies the maximum horizontal accuracy that the class will accept (horizontalAccuracy is measured in meters, so the lower the better). Thus, if the CLLocation’s horizontalAccuracy is <= kRequiredHorizontalAccuracy, the GPS signal can be considered to be strong. Otherwise it’s weak. There’s a custom setter so that when self.signalStrength is changed, the delegate is notified.

In order to prefer a strong GPS signal, I added the allowMaximumAcceptableAccuracy property. Its value is set to NO when the class is initialized, and also when self.signalStrength is set to PSLocationManagerGPSSignalStrengthStrong. If self.signalStrength is set to PSLocationManagerGPSSignalStrengthWeak, the class waits a number of seconds (kGPSRefinementInterval), then checks self.signalStrength again. If it’s still weak, self.allowMaximumAcceptableAccuracy is set to YES. Thus, the check in the code above. The horizontalAccuracy defined above is used to make sure the passed-in CLLocation’s horizontalAccuracy is <= whichever horizontal accuracy value has been chosen.

1
2
3
4
5
6
7
8
9
[self.locationHistory addObject:newLocation];
if ([self.locationHistory count] > kNumLocationHistoriesToKeep) {
   [self.locationHistory removeObjectAtIndex:0];
}

BOOL canUpdateDistanceAndSpeed = NO;
if ([self.locationHistory count] >= kMinLocationsNeededToUpdateDistanceAndSpeed) {
   canUpdateDistanceAndSpeed = YES && self.readyToExposeDistanceAndSpeed;
}

Especially since the first few messages to locationManager:didUpdateToLocation:fromLocation: usually provide a CLLocationManager with pretty poor horizontalAccuracy, I wanted to make sure that the class had a few CLLocation objects to choose from before calculating the user’s distance and speed. Remember that the class will loop through the most recent CLLocation objects to choose the one with the best accuracy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CLLocation *lastLocation = (self.lastRecordedLocation != nil) ? self.lastRecordedLocation : oldLocation;

CLLocation *bestLocation = nil;
CGFloat bestAccuracy = kRequiredHorizontalAccuracy;
for (CLLocation *location in self.locationHistory) {
    if ([NSDate timeIntervalSinceReferenceDate] - [location.timestamp timeIntervalSinceReferenceDate] <= kValidLocationHistoryDeltaInterval) {
        if (location.horizontalAccuracy < bestAccuracy && location != lastLocation) {
            bestAccuracy = location.horizontalAccuracy;
            bestLocation = location;
        }
    }
}
if (bestLocation == nil) bestLocation = newLocation;

CLLocationDistance distance = [bestLocation distanceFromLocation:lastLocation];
if (canUpdateDistanceAndSpeed) self.totalDistance += distance;
self.lastRecordedLocation = bestLocation;

The above is where the best recent location is plucked out and used to increment the user’s total distance. I also didn’t want to use CLLocation objects that are too stale, so the code uses kValidLocationHistoryDeltaInterval to see if the location is too old to be considered.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
NSTimeInterval timeSinceLastLocation = [bestLocation.timestamp timeIntervalSinceDate:lastLocation.timestamp];
if (timeSinceLastLocation > 0) {
    CGFloat speed = distance / timeSinceLastLocation;
    if (speed <= 0 && [self.speedHistory count] == 0) {
        // don't add a speed of 0 as the first item, since it just means we're not moving yet
    } else {
        [self.speedHistory addObject:[NSNumber numberWithDouble:speed]];
    }
    if ([self.speedHistory count] > kNumSpeedHistoriesToAverage) {
        [self.speedHistory removeObjectAtIndex:0];
    }
    if ([self.speedHistory count] > 1) {
        double totalSpeed = 0;
        for (NSNumber *speedNumber in self.speedHistory) {
            totalSpeed += [speedNumber doubleValue];
        }
        if (canUpdateDistanceAndSpeed) {
            double newSpeed = totalSpeed / (double)[self.speedHistory count];
            if (kPrioritizeFasterSpeeds > 0 && speed > newSpeed) {
                newSpeed = speed;
                [self.speedHistory removeAllObjects];
                for (int i=0; i<kNumSpeedHistoriesToAverage; i++) {
                    [self.speedHistory addObject:[NSNumber numberWithDouble:newSpeed]];
                }
            }
            self.currentSpeed = newSpeed;
        }
    }
}

self.speedHistory is used to average a number (kNumSpeedHistoriesToAverage) of the previous speeds together to find the user’s current speed. Averaging the past few speeds tends to smooth out the curve a bit, so that reported speed changes aren’t so jarring. This was especially important in the development of Faster, since the app changes the pace of the user’s music depending on their speed.

Another interesting thing that was very useful for Faster, is that I added the capability to prioritize faster speeds (kPrioritizeFasterSpeeds). What that means is that the reported speed will gradually decrease as the user slows, due to the averaged speed history. But if the user increases their speed, the speed reported to the delegate increases immediately. In Faster, this allows the app to give a little leeway when a user slows down, while (nearly) instantly rewarding the user for speeding up. It makes the app feel a bit more forgiving.

1
2
3
// this will be invalidated above if a new location is received before it fires
self.locationPingTimer = [NSTimer timerWithTimeInterval:kMinimumLocationUpdateInterval target:self selector:@selector(requestNewLocation) userInfo:nil repeats:NO];
[[NSRunLoop mainRunLoop] addTimer:self.locationPingTimer forMode:NSRunLoopCommonModes];

I just want to make a quick note about this. Since locationManager:didUpdateToLocation:fromLocation: doesn’t get sent any messages if the user isn’t moving, self.locationPingTimer was needed to determine if the user has stopped moving. Without that timer, locationManager:didUpdateToLocation:fromLocation: doesn’t get called again when the user stops, and thus, PSLocationManager can never report the change in speed to its delegate.

Well, there we have it. There’s a lot more to look at in PSLocationManager. If you have questions about any of it, don’t hesitate to send me an email.

If you’re looking for some iOS, Android, or web development work to be done, consider PerspecDev for the job.