Chapter 4. Digital Compass

The digital compass functionality on the iPhone and iPad is provided by a magnetometer. The magnetometer measures the strength of the magnetic field surrounding the device. However, in the absence of any strong local fields, these measurements will be of the ambient magnetic field of the Earth, allowing the device to determine its “heading” with respect to the geomagnetic North Pole and act as a digital compass. The geomagnetic heading and true heading, relative to the geographical North Pole, can vary widely (by several tens of degrees depending on your location).

About the Magnetometer

The magnetometer will return the heading (or yaw) of the device (see Figure 4-1).

Using the magnetometer to determine the heading (yaw) of the device

Figure 4-1. Using the magnetometer to determine the heading (yaw) of the device

Along with reporting the current location, the CLLocationManager class can report the current heading of the device, in the case where the device’s hardware supports it. If location updates are also enabled, the location manager returns both true heading and magnetic heading values. If location updates are not enabled, the location manager returns only the magnetic heading value.

Note

Magnetic heading updates are available even if the user has switched off location updates in the Settings application. Additionally, users are not prompted to give permission to use heading data, as it is assumed that magnetic heading information cannot compromise user privacy. On an enabled device, the magnetic heading data should therefore always be available to your application.

As I mentioned previously, the magnetometer readings will be affected by local magnetic fields, so the CLLocationManager may attempt to calibrate its heading readings by displaying a heading calibration panel before it starts to issue update messages (see Figure 4-2).

The heading calibration panel

Figure 4-2. The heading calibration panel

However, before it does so, it will call the locationManagerShouldDisplayHeadingCalibration: delegate method:

- (BOOL)locationManagerShouldDisplayHeadingCalibration:
  (CLLocationManager *)manager {
      return YES;
}

If you return YES from this method, the CLLocationManager will display the device calibration panel on top of the current window. The calibration panel prompts the user to move the device in a figure-eight pattern so that Core Location can distinguish between the Earth’s magnetic field and any local magnetic fields. The panel will remain visible until calibration is complete or until you dismiss it by calling the dismissHeadingCalibrationDisplay: method in the CLLocationManager class.

Writing a Compass Application

Let’s go ahead and implement a simple view-based application to illustrate how to use the magnetometer. Open Xcode and start a new iPhone project, select a View-based Application template, and name the project “Compass” when prompted for a filename.

Since you’ll be making use of the Core Location framework, the first thing you need to do is add it to your new project. Click the Compass project file in the Project navigator window on the right in Xcode, select the Target, and click the Build Phases tab. Then, click the Link with Libraries drop-down, and click the + button to open the file popup window. Select CoreLocation.framework from the list of available frameworks and click the Add button.

You’re going to build an application that will act as a compass, so you’re going to need an image of an arrow to act as the compass needle. Download (or draw in the graphics package of your choice) an image of an arrow pointing upwards on a transparent background. Save it as, or convert it to, a PNG format file. Drag and drop this PNG into your Xcode Project; remember to tick the “Copy items into destination group’s folder (if needed)” check box in the popup dialog that appears when you drop the files into Xcode.

Click CompassViewController.xib to open it in Interface Builder. Drag and drop a UIImageView from the Object Library into the View, positioning it roughly in the center of your window and resizing the bounding box to be a square (as in Figure 4-3). In the Attributes inspector of the Utilities pane set the View mode to be Aspect Fit, uncheck the Opaque checkbox in the Drawing section, and select the arrow image that you added to your project in the Image drop-down.

Next, drag and drop four UILabel elements from the Object Library into the View, position the four labels as in Figure 4-3, and change the text in the left most two to read Magnetic Heading: and True Heading:.

Close the Utility pane and switch from the Standard to the Assistant Editor. Control-click and drag from the two right most UILabel elements to the assistant editor to create a magneticHeadingLabel and trueHeadingLabel outlet, and then again for the UIImageView to create an arrowImage outlet (see Figure 4-3).

Connecting the outlets to the UI elements in Interface Builder

Figure 4-3. Connecting the outlets to the UI elements in Interface Builder

Then click the CompassViewController.h interface file and declare the class as a CLLocationManagerDelegate, remembering to import the CoreLocation.h header file. After doing so, the interface should look like this:

#import <UIKit/UIKit.h>
#import <CoreLocation/CoreLocation.h>

@interface CompassViewController : UIViewController <CLLocationManagerDelegate> {

    IBOutlet UIImageView *arrowImage;
    IBOutlet UILabel *magneticHeadingLabel;
    IBOutlet UILabel *trueHeadingLabel;
}

@end

Save your changes, and click the corresponding CompassViewController.m implementation file. Uncomment the viewDidLoad method and the following code to the implementation. This will create an instance of the CLLocationManager class, and will send both location and heading update messages to the designated delegate class:

- (void)viewDidLoad {
    [super viewDidLoad];
    CLLocationManager *locationManager = [[CLLocationManager alloc] init];
    locationManager.delegate = self;
    if( [CLLocationManager locationServicesEnabled]
        &&  [CLLocationManager headingAvailable]) {1
          [locationManager startUpdatingLocation];
          [locationManager startUpdatingHeading];
    } else {
          NSLog(@"Can't report heading");
    }
}
1

It is more important to check whether heading information is available than it is to check whether location services are available, as the availability of heading information is restricted to the latest generation of devices.

You can (optionally) filter the heading update messages based on an angular filter. Changes in heading of less than this amount will not generate an update message to the delegate. For example:

locationManager.headingFilter = 5;  // 5 degrees

The default value of this property is kCLHeadingFilterNone. You should use this value if you want to be notified of all heading updates. I’m going to leave the filter set to the default value. However, if you want to filter messages from Core Location this way, add the above line to your viewDidLoad method inside the if-block:

    if( [CLLocationManager locationServicesEnabled]
        &&  [CLLocationManager headingAvailable]) {
          [locationManager startUpdatingLocation];
          [locationManager startUpdatingHeading];
         locationManager.headingFilter = 5;  // 5 degrees
    } else {
          ... code ...
    }

The CLLocationManagerDelegate protocol calls the locationManager:didUpdateHeading: delegate method when the heading is updated. You’re going to use this method to update your user interface, so add the following code to your view controller:

- (void)locationManager:(CLLocationManager*)manager 
        didUpdateHeading:(CLHeading*)newHeading {

    if (newHeading.headingAccuracy > 0) {
          float magneticHeading = newHeading.magneticHeading;
          float trueHeading = newHeading.trueHeading;1

          magneticHeadingLabel.text = [NSString stringWithFormat:@"%f", magneticHeading];
          trueHeadingLabel.text = [NSString stringWithFormat:@"%f", trueHeading];

          float heading = 1.0f * M_PI * newHeading.magneticHeading / 180.0f;
          arrowImage.transform = CGAffineTransformMakeRotation(heading);
    }
}
1

If location updates are also enabled, the location manager returns both true heading and magnetic heading values. If location updates are not enabled, or the location of the device is not yet known, the location manager returns only the magnetic heading value and the value returned by this call will be −1.

You’re done: save your changes, and then click the Run button in the Xcode toolbar to deploy your new application onto your device. If you hold your device in Face Up or Portrait mode, you should see something very similar to Figure 4-4.

The compass application running on the iPhone 3GS

Figure 4-4. The compass application running on the iPhone 3GS

Unfortunately, as it stands your application has a critical flaw. If the user orientates the device into Landscape Mode, the reported headings will be incorrect, or at least look incorrect to the user. This will become especially important when you look at augmented reality interfaces later in the book; such interfaces are generally viewed in Landscape Left mode.

Determining the Heading in Landscape Mode

The magnetic and true headings are correct when the iPhone device is held like a traditional compass. In portrait mode, if the user rotates the device, then the heading readings will still be in this frame of reference. Even though the user has not changed the direction he is facing, the heading values reported by the device will have changed. You’re going to have to correct for orientation before reporting headings back to the user (see Figure 4-5).

In the Project navigator, click the CompassViewController.xib file to open it in Interface Builder, then drag and drop another UILabel from the Object Library in the Utility pane into the View window. While using the Assistant Editor, connect the label to a new outlet in the CompassViewController.h interface file, as in Figure 4-6.

After doing so, the interface file should look like the following:

@interface CompassViewController : UIViewController <CLLocationManagerDelegate> {

    IBOutlet UILabel *trueHeadingLabel;
    IBOutlet UILabel *magneticHeadingLabel;
    IBOutlet UILabel *orientationLabel;
    IBOutlet UIImageView *arrowImage;

}

Just as you did for the Accelerometer application in Chapter 3, you’re going to use this to report the current device orientation.

The real heading of the user when he is holding the device in landscape mode is the reported heading + 90 degrees

Figure 4-5. The real heading of the user when he is holding the device in landscape mode is the reported heading + 90 degrees

Connecting the orientation label in Interface Builder

Figure 4-6. Connecting the orientation label in Interface Builder

Close the Assistant Editor and reopen the CompassViewController.h interface file in the Standard Editor. Then, add the following convenience methods to the class definition:

- (float)magneticHeading:(float)heading 
        fromOrientation:(UIDeviceOrientation) orientation;
- (float)trueHeading:(float)heading 
        fromOrientation:(UIDeviceOrientation) orientation;
- (NSString *)stringFromOrientation:(UIDeviceOrientation) orientation;

Save your changes, and open the corresponding CompassViewController.m implementation file. Unfortunately, since the CLHeading object is read only, you can’t modify it directly. Therefore, you’re going to add the following method that will correct the magnetic heading for the device orientation:

-(float)magneticHeading:(float)heading 
        fromOrientation:(UIDeviceOrientation) orientation {

    float realHeading = heading;
    switch (orientation) {1
          case UIDeviceOrientationPortrait:
               break;
          case UIDeviceOrientationPortraitUpsideDown:
               realHeading = realHeading + 180.0f;
               break;
          case UIDeviceOrientationLandscapeLeft:
               realHeading = realHeading + 90.0f;
               break;
          case UIDeviceOrientationLandscapeRight:
               realHeading = realHeading - 90.0f;
               break;
          default:
               break;
    }
    while ( realHeading > 360.0f ) {
          realHeading = realHeading - 360;
    }
    return realHeading;
}
1

The UIDeviceOrientationFaceUp and UIDeviceOrientationFaceDown orientation cases are undefined and the user is presumed to be holding the device in UIDeviceOrientationPortrait mode.

However, you will also need to add a corresponding method to correct the true heading.

-(float)trueHeading:(float)heading fromOrientation:(UIDeviceOrientation) orientation {

    float realHeading = heading;
    switch (orientation) {1
          case UIDeviceOrientationPortrait:
               break;
          case UIDeviceOrientationPortraitUpsideDown:
               realHeading = realHeading + 180.0f;
               break;
          case UIDeviceOrientationLandscapeLeft:
               realHeading = realHeading + 90.0f;
               break;
          case UIDeviceOrientationLandscapeRight:
               realHeading = realHeading - 90.0f;
               break;
          default:
               break;
    }
    while ( realHeading > 360.0f ) {
          realHeading = realHeading - 360;
    }
    return realHeading;
}
1

The UIDeviceOrientationFaceUp and UIDeviceOrientationFaceDown orientation cases are undefined and the user is presumed to be holding the device in UIDeviceOrientationPortrait mode.

Finally, add the stringFromOrientation: method from Chapter 5. You’ll use this to update the orientationLabel outlet.

- (NSString *)stringFromOrientation:(UIDeviceOrientation) orientation {

    NSString *orientationString;
    switch (orientation) {
          case UIDeviceOrientationPortrait:
               orientationString =  @"Portrait";
               break;
          case UIDeviceOrientationPortraitUpsideDown:
               orientationString =  @"Portrait Upside Down";
               break;
          case UIDeviceOrientationLandscapeLeft:
               orientationString =  @"Landscape Left";
               break;
          case UIDeviceOrientationLandscapeRight:
               orientationString =  @"Landscape Right";
               break;
          case UIDeviceOrientationFaceUp:
               orientationString =  @"Face Up";
               break;
          case UIDeviceOrientationFaceDown:
               orientationString =  @"Face Down";
               break;
          case UIDeviceOrientationUnknown:
               orientationString = @"Unknown";
               break;
          default:
               orientationString = @"Not Known";
               break;
    }
    return orientationString;
}

When that’s done, return to the locationManager:didUpdateHeading: delegate method and modify the lines highlighted below to use the new methods and update your headings depending on the device orientation.

- (void)locationManager:(CLLocationManager*)manager 
        didUpdateHeading:(CLHeading*)newHeading {

    UIDevice *device = [UIDevice currentDevice];
    orientationLabel.text = [self stringFromOrientation:device.orientation];

    if (newHeading.headingAccuracy > 0) {
          float magneticHeading = [self magneticHeading:newHeading.magneticHeading
                                        fromOrientation:device.orientation];
          float trueHeading = [self trueHeading:newHeading.trueHeading
                                fromOrientation:device.orientation];

          magneticHeadingLabel.text = [NSString stringWithFormat:@"%f", magneticHeading];
          trueHeadingLabel.text = [NSString stringWithFormat:@"%f", trueHeading];

          float heading = 1.0f * M_PI * newHeading.magneticHeading / 180.0f;1
          arrowImage.transform = CGAffineTransformMakeRotation(heading);
    }
}
1

You should still use the directly reported newHeading.magneticHeading for the compass needle rather than the adjusted heading, otherwise the compass will not point correctly.

Make sure you’ve saved all the changes to the implementation file and click the Run button in the Xcode toolbar to deploy the application onto the device. If all goes well, you should see the same compass display as before. However, if you rotate the display this time, the heading values should be the same, irrespective of the device orientation (see Figure 4-7).

Heading values are now the same, irrespective of orientation

Figure 4-7. Heading values are now the same, irrespective of orientation

Although you have not implemented it here, if the CLLocationManager object encounters an error, it will call the locationManager:didFailWithError: delegate method.

- (void)locationManager:(CLLocationManager *)manager 
        didFailWithError:(NSError *)error {
    if ([error code] == kCLErrorDenied) {
        // User has denied the application's request to use location services.
        [manager stopUpdatingHeading];

    } else if ([error code] == kCLErrorHeadingFailure) {
        // Heading could not be determined
    }
}

Get Geolocation in iOS now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.