MKGeodesicPolyline
We knew that the Earth was not flat long before 1492. Early navigators observed the way ships would dip out of view over the horizon many centuries before the Age of Discovery.
For many iOS developers, though, a flat MKMap
was a necessary conceit until recently.
What changed? The discovery of MKGeodesic
, which is the subject of this week’s article.
MKGeodesic
was introduced to the Map Kit framework in iOS 7. As its name implies, it creates a geodesic—essentially a straight line over a curved surface.
On the surface of a sphere oblate spheroid geoid, the shortest distance between two points appears as an arc on a flat projection. Over large distances, this takes a pronounced, circular shape.
An MKGeodesic
is created with an array of 2 MKMap
s or CLLocation
s:
MKGeodesic Polyline
Creating an let LAX = CLLocation(latitude: 33.9424955, longitude: -118.4080684)
let JFK = CLLocation(latitude: 40.6397511, longitude: -73.7789256)
var coordinates = [LAX.coordinate, JFK.coordinate]
let geodesic Polyline = MKGeodesic Polyline(coordinates: &coordinates, count: 2)
map View.add Overlay(geodesic Polyline)
CLLocation *LAX = [[CLLocation alloc] init With Latitude:33.9424955
longitude:-118.4080684];
CLLocation *JFK = [[CLLocation alloc] init With Latitude:40.6397511
longitude:-73.7789256];
CLLocation Coordinate2D coordinates[2] =
{LAX.coordinate, JFK.coordinate};
MKGeodesic Polyline *geodesic Polyline =
[MKGeodesic Polyline polyline With Coordinates:coordinates
count:2];
[map View add Overlay:geodesic Polyline];
Although the overlay looks like a smooth curve, it is actually comprised of thousands of tiny line segments (true to its MKPolyline
lineage):
print(geodesic Polyline.point Count) // 3984
NSLog(@"%d", geodesic Polyline.point Count) // 3984
Like any object conforming to the MKOverlay
protocol, an MKGeodesic
instance is displayed by adding it to an MKMap
with add
and implementing map
:
MKGeodesic Polyline
on an MKMap View
Rendering // MARK: MKMap View Delegate
func map View(map View: MKMap View, renderer For Overlay overlay: MKOverlay) -> MKOverlay Renderer {
guard let polyline = overlay as? MKPolyline else {
return MKOverlay Renderer()
}
let renderer = MKPolyline Renderer(polyline: polyline)
renderer.line Width = 3.0
renderer.alpha = 0.5
renderer.stroke Color = UIColor.blue Color()
return renderer
}
#pragma mark - MKMap View Delegate
- (MKOverlay Renderer *)map View:(MKMap View *)map View
renderer For Overlay:(id <MKOverlay>)overlay
{
if (![overlay is Kind Of Class:[MKPolyline class]]) {
return nil;
}
MKPolyline Renderer *renderer =
[[MKPolyline Renderer alloc] init With Polyline:(MKPolyline *)overlay];
renderer.line Width = 3.0f;
renderer.stroke Color = [UIColor blue Color];
renderer.alpha = 0.5;
return renderer;
}
For comparison, here’s the same geodesic overlaid with a route created from
MKDirections
:
As the crow flies, it’s 3,983 km.
As the wolf runs, it’s 4,559 km—nearly 15% longer.
…and that’s just distance; taking into account average travel speed, the total time is ~5 hours by air and 40+ hours by land.
MKAnnotation View
on a MKGeodesic Polyline
Animating an Since geodesics make reasonable approximations for flight paths, a common use case would be to animate the trajectory of a flight over time.
To do this, we’ll make properties for our map view and geodesic polyline between LAX and JFK, and add new properties for the plane
and plane
(the index of the current map point for the polyline):
// MARK: Flight Path Properties
var map View: MKMap View!
var flightpath Polyline: MKGeodesic Polyline!
var plane Annotation: MKPoint Annotation!
var plane Annotation Position = 0
@interface Map View Controller () <MKMap View Delegate>
@property MKMap View *map View;
@property MKGeodesic Polyline *flightpath Polyline;
@property MKPoint Annotation *plane Annotation;
@property NSUInteger plane Annotation Position;
@end
Next, right below the initialization of our map view and polyline, we create an MKPoint
for our plane:
let annotation = MKPoint Annotation()
annotation.title = NSLocalized String("Plane", comment: "Plane marker")
map View.add Annotation(annotation)
self.plane Annotation = annotation
self.update Plane Position()
self.plane Annotation = [[MKPoint Annotation alloc] init];
self.plane Annotation.title = NSLocalized String(@"Plane", nil);
[self.map View add Annotation:self.plane Annotation];
[self update Plane Position];
That call to update
in the last line ticks the animation and updates the position of the plane:
func update Plane Position() {
let step = 5
guard plane Annotation Position + step < flightpath Polyline.point Count
else { return }
let points = flightpath Polyline.points()
self.plane Annotation Position += step
let next Map Point = points[plane Annotation Position]
self.plane Annotation.coordinate = MKCoordinate For Map Point(next Map Point)
perform Selector("update Plane Position", with Object: nil, after Delay: 0.03)
}
- (void)update Plane Position {
static NSUInteger const step = 5;
if (self.plane Annotation Position + step >= self.flightpath Polyline.point Count) {
return;
}
self.plane Annotation Position += step;
MKMap Point next Map Point = self.flightpath Polyline.points[self.plane Annotation Position];
self.plane Annotation.coordinate = MKCoordinate For Map Point(next Map Point);
[self perform Selector:@selector(update Plane Position) with Object:nil after Delay:0.03];
}
We’ll perform this method roughly 30 times a second, until the plane has arrived at its final destination.
Finally, we implement map
to have the annotation render on the map view:
func map View(map View: MKMap View, view For Annotation annotation: MKAnnotation) -> MKAnnotation View? {
let plane Identifier = "Plane"
let annotation View = map View.dequeue Reusable Annotation View With Identifier(plane Identifier)
?? MKAnnotation View(annotation: annotation, reuse Identifier: plane Identifier)
annotation View.image = UIImage(named: "airplane")
return annotation View
}
- (MKAnnotation View *)map View:(MKMap View *)map View
view For Annotation:(id <MKAnnotation>)annotation
{
static NSString * Pin Identifier = @"Pin";
MKAnnotation View *annotation View = [map View dequeue Reusable Annotation View With Identifier:Pin Identifier];
if (!annotation View) {
annotation View = [[MKAnnotation View alloc] init With Annotation:annotation reuse Identifier:Pin Identifier];
}
annotation View.image = [UIImage image Named:@"plane"];
return annotation View;
}
Hmm… close but no SkyMall Personalized Cigar Case Flask.
Let’s update the rotation of the plane as it moves across its flightpath.
MKAnnotation View
along a Path
Rotating an To calculate the plane’s direction, we’ll take the slope from the previous and next points:
let previous Map Point = points[plane Annotation Position]
plane Annotation Position += step
let next Map Point = points[plane Annotation Position]
self.plane Direction = direction Between Points(previous Map Point, next Map Point)
self.plane Annotation.coordinate = MKCoordinate For Map Point(next Map Point)
MKMap Point previous Map Point = self.flightpath Polyline.points[self.plane Annotation Position];
self.plane Annotation Position += step;
MKMap Point next Map Point = self.flightpath Polyline.points[self.plane Annotation Position];
self.plane Direction = XXDirection Between Points(previous Map Point, next Map Point);
self.plane Annotation.coordinate = MKCoordinate For Map Point(next Map Point);
direction
is a function that returns a CLLocation
(0 – 360 degrees, where North = 0) given two MKMap
s.
We calculate from
MKMap
s rather than converted coordinates, because we’re interested in the slope of the line on the flat projection.Point
private func direction Between Points(source Point: MKMap Point, _ destination Point: MKMap Point) -> CLLocation Direction {
let x = destination Point.x - source Point.x
let y = destination Point.y - source Point.y
return radians To Degrees(atan2(y, x)) % 360 + 90
}
static CLLocation Direction XXDirection Between Points(MKMap Point source Point, MKMap Point destination Point) {
double x = destination Point.x - source Point.x;
double y = destination Point.y - source Point.y;
return fmod(XXRadians To Degrees(atan2(y, x)), 360.0f) + 90.0f;
}
That convenience function radians
(and its partner, degrees
) are simply:
private func radians To Degrees(radians: Double) -> Double {
return radians * 180 / M_PI
}
private func degrees To Radians(degrees: Double) -> Double {
return degrees * M_PI / 180
}
static inline double XXRadians To Degrees(double radians) {
return radians * 180.0f / M_PI;
}
static inline double XXDegrees To Radians(double degrees) {
return degrees * M_PI / 180.0f;
}
That direction is stored in a new property, var plane
, calculated from self.plane
in update
(ideally renamed to update
with this addition). To make the annotation rotate, we apply a transform
on annotation
:
annotation View.transform = CGAffine Transform Rotate(map View.transform,
degrees To Radians(plane Direction))
self.annotation View.transform =
CGAffine Transform Rotate(self.map View.transform,
XXDegrees To Radians(self.plane Direction));
Ah much better! At last, we have mastered the skies with a fancy visualization, worthy of any travel-related app.
Perhaps more than any other system framework, MapKit has managed to get incrementally better, little by little with every iOS release [1] [2]. For anyone with a touch-and-go relationship to the framework, returning after a few releases is a delightful experience of discovery and rediscovery.
I look forward to seeing what lies on the horizon with iOS 8 and beyond.