ObjC/Footprint/AAPLViewController.m
/* |
Copyright (C) 2016 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
Primary view controller for what is displayed by the application. In this class we configure an MKMapView to display a floorplan, recieve location updates to determine floor number, as well as provide a few helpful debugging annotations. |
We will also show how to highlight a region that you have defined in PDF coordinates but not Latitude Longitude. |
*/ |
#import "AAPLViewController.h" |
#import "AAPLCoordinateConverter.h" |
#import "AAPLFloorplanOverlay.h" |
#import "AAPLFloorplanOverlayRenderer.h" |
#import "AAPLHideBackgroundOverlay.h" |
#define USE_DEBUG_ANNOTATIONS |
#define AAPL_HIGHLIGHT_REGION_COUNT 3 |
@import MapKit; |
/** |
This class manages an \c MKMapView camera scroll & zoom by implementing the |
typical \c MKMapViewDelegate \c regionDidChangeAnimated and |
\c regionWillChangeAnimated to add bounce-back when the user scrolls/zooms |
away from the floorplan. |
*/ |
@interface AAPLVisibleMapRegionDelegate : NSObject |
/* |
Keep track of changes to [mapView camera].altitude so that we know |
whether to auto-zoom or auto-scroll. |
*/ |
@property (nonatomic) CLLocationDistance lastAltitude; |
// Properties of the floorplan. See AAPLFloorplanOverlay for more. |
@property (nonatomic) MKMapRect boundingMapRectIncludingRotations; |
@property (nonatomic) AAPLMKMapRectRotated boundingPDFBox; |
@property (nonatomic) CLLocationCoordinate2D floorplanCenter; |
@property (nonatomic) CLLocationDirection floorplanUprightMKMapCameraHeading; |
- (instancetype)initWithFloorplanBounds:(MKMapRect)boundingMapRectWithRotations pdfBoundingBox:(AAPLMKMapRectRotated)pdfBoundingBox centerOfFloorplan:(CLLocationCoordinate2D)centerOfFloorplan floorplanUprightMKMapCameraHeading:(CLLocationDirection)heading; |
- (void)mapViewResetCameraToFloorplan:(MKMapView *)mapView; |
- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated; |
@end |
@interface AAPLViewController () <MKMapViewDelegate> |
/// Outlet for the map view in the storyboard. |
@property (nonatomic, weak) IBOutlet MKMapView *mapView; |
/// Outlet for the debug visuals switch at the lower-right of the storyboard. |
@property (weak, nonatomic) IBOutlet UISwitch *debugVisualsSwitch; |
/** |
To enable user location to be shown in the map, go to Main.storyboard, |
select the Map View, open its Attribute Inspector and click the checkbox |
next to User Location |
The user will need to authorize this app to use their location either by |
enabling it in Settings or by selecting the appropriate option when |
prompted. |
*/ |
@property (nonatomic, strong) CLLocationManager *locationManager; |
/** |
This is the alpha value we'll use for the White overlay that hides the |
underlying Apple base map tiles. |
*/ |
@property (nonatomic) CGFloat hideBackgroundOverlayAlpha; |
/// Helper class for managing the scroll & zoom of the MapView camera. |
@property (nonatomic, strong) AAPLVisibleMapRegionDelegate *visibleMapRegionDelegate; |
/// Store the data about our floorplan here. |
@property (nonatomic, strong) AAPLFloorplanOverlay *floorplan0; |
/// This property remembers which floor we're on. See documentation for CLFloor. |
@property (strong) CLFloor *lastFloor; |
#ifdef USE_DEBUG_ANNOTATIONS |
@property (nonatomic, strong, nonnull) NSArray<id<MKOverlay>> *debuggingOverlays; |
@property (nonatomic, strong, nonnull) NSArray<id<MKAnnotation>> *debuggingAnnotations; |
/** |
Set to NO if you want to turn off \c AAPLFloorplanOverlayRenderer's |
diagnostic visuals. |
*/ |
@property (nonatomic) BOOL showsPDFDiagnosticVisuals; |
#endif |
/** |
Set to NO if you want to turn off auto-scroll & auto-zoom that snaps to the |
floorplan in case you scroll or zoom too far away. |
*/ |
@property (nonatomic) BOOL snapsMapViewToFloorplan; |
/** |
Set to YES when we reveal the MapKit tileset (by pressing the |
X button). |
*/ |
@property (nonatomic) BOOL mapKitTilesetRevealed; |
/// Call this to reset the camera. |
- (IBAction)resetCamera:(id)sender; |
/** |
When the X icon hasn't yet been pressed, this toggles the debug visuals. |
Otherwise, this toggles the floorplan. |
*/ |
- (IBAction)toggleDebugVisuals:(id)sender; |
/** |
Remove all the overlays except for the debug visuals. Forces the debug |
visuals switch off. |
*/ |
- (IBAction)revealMapKitTileset:(id)sender; |
/** |
If you have set up your anchors correctly, this function will create: |
1. a red pin at the location of your \c fromAnchor. |
2. a green pin at the location of your \c toAnchor. |
3. a purple pin at the location of the PDF's internal origin. |
Use these pins to: |
* Compare the location of pins #1 and #2 with the underlying Apple Maps |
tiles. |
+ The pins should appear, on the real world, in the physical |
locations corresponding to the landmarks that you chose for each |
anchor. |
+ If either pin does not seem to be at the correct position on Apple |
Maps, double-check for typos in the \c CLLocationCoordinate2D |
values of your \c AAPLGeoAnchor struct. |
* Compare the location of pins #1 and #2 with the matching colored |
squares drawn by AAPLFloorplanOverlayRenderer.m:drawDiagnosticVisuals |
on your floorplan overlay. |
+ The red pin should appear at the same location as the red square; |
the green pin should appear at the same location as the green |
square. |
+ If either pin does not match the location of its corresponding |
square, you may be having problems with coordinate conversion |
accuracy. Try picking anchor points that are further apart. |
@param mapView MapView to draw on. |
@param aboutFloorplan floorplan from which we get anchors and coordinates. |
*/ |
+ (nonnull NSArray<id<MKAnnotation>> *)createDebuggingAnnotationsForMapView:(MKMapView *)mapView aboutFloorplan:(AAPLFloorplanOverlay *)floorplan; |
/** |
Return an NSArray of three debugging overlays. These overlays will show: |
1. the PDF Page Box that was selected for this floor. |
2. the \c boundingMapRect used to define the rendering of this floorplan |
by \c MKMapView. |
3. the \c boundingMapRectIncludingRotations used to define the rendering |
of this floorplan. |
Use these outlines to: |
* Ensure that #1 shows a polygon that is just small enough to enclose |
all of the important visual content in your floorplan. |
+ If this polygon is much larger than your floorplan, you may |
experience runtime performance issues. In this case it's better |
to choose or define a smaller PDF Page Box. |
* Ensure that #2 shows a polygon that encloses your floorplan exactly. |
+ If any important visual floorplan information is outside this |
polygon, those parts of the floorplan might not be displayed to |
the user, depending on their zoom & scrolling. In this case it's |
better to choose or define a larger PDF Page Box. |
* Ensure that #3 shows a polygon that is large enough to contain your |
floorplan comfortably, but still small enough to cause bounce-back |
when the user scrolls/zooms out too far. |
+ The \c boundingMapRect is based on the PDF Page Box, so the best |
way to adjust the \c boundingMapRect is to get a more accurate |
PDF Page Box. |
+ Note: In this sample code app we use the \c boundingMapRect also |
to determine the limits where zoom/scroll bounce-back takes |
place. |
For more information, see enum \c CGPDFBox and |
\c AAPLFloorplanOverlay \c initWithFloorplanURL:... \c PDFBox: |
*/ |
+ (nonnull NSArray<id<MKOverlay>> *)createDebuggingOverlaysForMapView:(MKMapView *)mapView aboutFloorplan:(AAPLFloorplanOverlay *)floorplan; |
@end |
#pragma mark AAPLViewController |
@implementation AAPLViewController |
- (IBAction)resetCamera:(id)sender { |
[self.visibleMapRegionDelegate mapViewResetCameraToFloorplan:self.mapView]; |
} |
- (IBAction)toggleDebugVisuals:(id)sender { |
if (![sender isKindOfClass:[UISwitch class]]) { |
return; |
} |
UISwitch *senderSwitch = (UISwitch *)sender; |
if (self.mapKitTilesetRevealed) { |
if (senderSwitch.isOn) { |
[self showFloorplan]; |
} |
else { |
[self hideFloorplan]; |
} |
} |
else { |
if (senderSwitch.isOn) { |
[self showDebugVisuals]; |
} |
else { |
[self hideDebugVisuals]; |
} |
} |
} |
- (IBAction)revealMapKitTileset:(id)sender { |
[self.mapView removeOverlays:self.mapView.overlays]; |
[self.mapView removeAnnotations:self.debuggingAnnotations]; |
// Show labels for restaurants, schools, etc. |
self.mapView.showsPointsOfInterest = YES; |
// Show building outlines. |
self.mapView.showsBuildings = YES; |
self.mapKitTilesetRevealed = YES; |
// Set switch to off. |
[self.debugVisualsSwitch setOn:NO animated:YES]; |
[self showDebugVisuals]; |
} |
- (void)viewDidLoad { |
[super viewDidLoad]; |
self.mapKitTilesetRevealed = NO; |
self.locationManager = [[CLLocationManager alloc] init]; |
// === Configure our floorplan |
/* |
We setup a pair of anchors that will define how the "floorplan image" |
maps to geographic co-ordinates. |
*/ |
AAPLGeoAnchor anchor1 = { |
.latitudeLongitude = CLLocationCoordinate2DMake(37.770419, -122.465726), |
.PDFPoint = CGPointMake(26.2, 86.4) |
}; |
AAPLGeoAnchor anchor2 = { |
.latitudeLongitude = CLLocationCoordinate2DMake(37.769288, -122.466376), |
.PDFPoint = CGPointMake(570.1, 317.7) |
}; |
AAPLGeoAnchorPair anchorPair = { |
.fromAnchor = anchor1, |
.toAnchor = anchor2 |
}; |
/* |
Pick a triangle on your PDF that you would like to highlight in yellow. |
Feel free to try regions with more than three edges, up to you. |
*/ |
CGPoint pdfTriangleRegionToHighlight[AAPL_HIGHLIGHT_REGION_COUNT] = { |
/* |
Note that these coordinates are given in PDF coordinates, but they |
will show up on just fine on MapKit in MapKit coordinates. |
*/ |
CGPointMake(205.0, 335.3), |
CGPointMake(205.0, 367.3), |
CGPointMake(138.5, 367.3) |
}; |
// === Initialize our assets |
/* |
We have to specify subdirectory here since we copy our folder reference |
during "Copy Bundle Resources" section under target settings build |
phases. |
*/ |
NSURL *pdfUrl = [[NSBundle mainBundle] URLForResource:@"floorplan_overlay_floor0" withExtension:@"pdf" subdirectory:@"Floorplans"]; |
self.floorplan0 = [[AAPLFloorplanOverlay alloc] initWithFloorplanURL:pdfUrl PDFBox:kCGPDFTrimBox anchors:anchorPair forFloorAtLevel:0]; |
self.visibleMapRegionDelegate = |
[[AAPLVisibleMapRegionDelegate alloc] |
initWithFloorplanBounds:self.floorplan0.boundingMapRectIncludingRotations |
pdfBoundingBox:self.floorplan0.floorplanPDFBox |
centerOfFloorplan:self.floorplan0.coordinate |
floorplanUprightMKMapCameraHeading:self.floorplan0.floorplanUprightMKMapCameraHeading |
]; |
#ifdef USE_DEBUG_ANNOTATIONS |
// The following are provided for debugging. |
self.debuggingOverlays = [AAPLViewController createDebuggingOverlaysForMapView:self.mapView aboutFloorplan:self.floorplan0]; |
self.debuggingAnnotations = [AAPLViewController createDebuggingAnnotationsForMapView:self.mapView aboutFloorplan:self.floorplan0]; |
#endif |
// Turn on AAPLFloorplanOverlayRenderer's diagnostic visuals. |
self.showsPDFDiagnosticVisuals = YES; |
// === Initialize our view |
self.hideBackgroundOverlayAlpha = 1.0; |
// disable tileset. |
[self.mapView addOverlay:[AAPLHideBackgroundOverlay hideBackgroundOverlay] level:MKOverlayLevelAboveRoads]; |
// Draw the floorplan! |
[self.mapView addOverlay:self.floorplan0]; |
// Highlight our region (originally specified in PDF coordinates) in yellow! |
MKPolygon *customHighlightRegion = [self.floorplan0 polygonFromCustomPDFPath:pdfTriangleRegionToHighlight count:AAPL_HIGHLIGHT_REGION_COUNT]; |
customHighlightRegion.title = @"Hello World"; |
customHighlightRegion.subtitle = @"This custom region will be highlighted in Yellow!"; |
[self.mapView addOverlay:customHighlightRegion]; |
/* |
By default, we listen to the scroll & zoom events to make sure that if |
the user scrolls/zooms too far away from the floorplan, we automatically |
bounce back. |
To disable this behavior, comment out the following line. |
*/ |
self.snapsMapViewToFloorplan = YES; |
} |
- (void)viewDidAppear:(BOOL)animated { |
/* |
For additional debugging, you may prefer to use non-satellite (standard) |
view instead of satellite view. If so, uncomment the line below. |
However, satellite view allows you to zoom in more closely than |
non-satellite view so you probably do not want to leave it this way |
in production. |
*/ |
//self.mapView.mapType = MKMapTypeStandard; |
} |
- (BOOL)shouldAutorotate { |
return NO; |
} |
/// Respond to CoreLocation updates. |
- (void)mapView:(MKMapView *)mapView didUpdateUserLocation:(MKUserLocation *)userLocation NS_AVAILABLE(10_9, 4_0) { |
CLLocation *location = userLocation.location; |
// CLLocation updates will not always have floor information... |
if (location.floor != nil) { |
NSLog(@"Location (Floor %@): %@", location.floor, location.description); |
// ...but when they do, take note! |
self.lastFloor = location.floor; |
NSLog(@"We are on floor %ld", (long)self.lastFloor.level); |
} |
} |
/// Request authorization if needed. |
- (void)mapViewWillStartLocatingUser:(MKMapView *)mapView { |
switch ([CLLocationManager authorizationStatus]) { |
case kCLAuthorizationStatusNotDetermined: |
// Ask the user for permission to use location. |
[self.locationManager requestWhenInUseAuthorization]; |
break; |
case kCLAuthorizationStatusDenied: |
NSLog(@"Please authorize location services for this app under Settings > Privacy."); |
break; |
case kCLAuthorizationStatusAuthorizedAlways: |
case kCLAuthorizationStatusAuthorizedWhenInUse: |
case kCLAuthorizationStatusRestricted: |
// Nothing to do. |
break; |
} |
} |
/// Helper method that shows the floorplan. |
- (void)showFloorplan { |
[self.mapView addOverlay:self.floorplan0]; |
} |
/// Helper function that hides the floorplan. |
- (void)hideFloorplan { |
[self.mapView removeOverlay:self.floorplan0]; |
} |
/// Helper function that shows the debug visuals. |
- (void)showDebugVisuals { |
// Make the background transparent to reveal, slightly the underlying grid. |
self.hideBackgroundOverlayAlpha = 0.5; |
// Show debugging bounding boxes. |
[self.mapView addOverlays:self.debuggingOverlays level: MKOverlayLevelAboveRoads]; |
// Show debugging pins. |
[self.mapView addAnnotations:self.debuggingAnnotations]; |
} |
/// Helper function that hides the debug visuals. |
- (void)hideDebugVisuals { |
[self.mapView removeAnnotations:self.debuggingAnnotations]; |
[self.mapView removeOverlays:self.debuggingOverlays]; |
self.hideBackgroundOverlayAlpha = 1.0; |
} |
/** |
Check for when the \c MKMapView is zoomed or scrolled in case we need to |
bounce back to the floorplan. |
If, instead, you're using e.g. \c MKUserTrackingModeFollow then you'll want |
to disable \c snapsMapViewToFloorplan since it will conflict with the |
user-follow scroll/zoom. |
*/ |
- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated { |
if (self.snapsMapViewToFloorplan) { |
[self.visibleMapRegionDelegate mapView:mapView regionDidChangeAnimated:animated]; |
} |
} |
/// Produce each type of renderer that might exist in our mapView. |
- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id<MKOverlay>)overlay { |
if ([overlay isKindOfClass:[AAPLFloorplanOverlay class]]) { |
AAPLFloorplanOverlayRenderer *renderer = [[AAPLFloorplanOverlayRenderer alloc] initWithOverlay:overlay]; |
return renderer; |
} |
if ([overlay isKindOfClass:[AAPLHideBackgroundOverlay class]]) { |
MKPolygonRenderer *renderer = [[MKPolygonRenderer alloc] initWithPolygon:overlay]; |
/* |
AAPLHideBackgroundOverlay covers the entire world, so this means |
all of MapKit tiles will be replaced with a solid white background. |
*/ |
renderer.fillColor = [[UIColor whiteColor] colorWithAlphaComponent:self.hideBackgroundOverlayAlpha]; |
// no border. |
renderer.lineWidth = 0.0; |
renderer.strokeColor = [[UIColor whiteColor] colorWithAlphaComponent:0.0]; |
return renderer; |
} |
if ([overlay isKindOfClass:[MKPolygon class]]) { |
MKPolygon *polygon = (MKPolygon *)overlay; |
/* |
A quick and dirty MKPolygon renderer for addDebuggingOverlays and |
our custom highlight region. |
In production, you'll want to implement this more cleanly: |
"However, if each overlay uses different colors or drawing |
attributes, you should find a way to initialize that information |
using the annotation object, rather than having a large decision |
tree in mapView:rendererForOverlay:" |
See "Creating Overlay Renderers from Your Delegate Object" for more. |
*/ |
if ([polygon.title isEqualToString:@"Hello World"]) { |
MKPolygonRenderer *renderer = [[MKPolygonRenderer alloc] initWithPolygon:polygon]; |
renderer.fillColor = [[UIColor yellowColor] colorWithAlphaComponent:0.5]; |
renderer.strokeColor = [[UIColor yellowColor] colorWithAlphaComponent:0.0]; |
renderer.lineWidth = 0.0; |
return renderer; |
} |
if ([polygon.title isEqualToString:@"debug"]) { |
MKPolygonRenderer *renderer = [[MKPolygonRenderer alloc] initWithPolygon:polygon]; |
renderer.fillColor = [[UIColor grayColor] colorWithAlphaComponent:0.1]; |
renderer.strokeColor = [[UIColor cyanColor] colorWithAlphaComponent:0.5]; |
renderer.lineWidth = 2.0; |
return renderer; |
} |
} |
return nil; |
} |
/// Produce each type of annotation view that might exist in our MapView. |
- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id<MKAnnotation>)annotation { |
/* |
For now, all we have are some quick and dirty pins for viewing debug |
annotations. |
To learn more about showing annotations, see "Annotating Maps" doc |
*/ |
if ([annotation.title isEqualToString:@"red"]) { |
MKPinAnnotationView *pinView = [[MKPinAnnotationView alloc] init]; |
pinView.pinTintColor = [UIColor redColor]; |
pinView.canShowCallout = YES; |
return pinView; |
} |
if ([annotation.title isEqualToString:@"green"]) { |
MKPinAnnotationView *pinView = [[MKPinAnnotationView alloc] init]; |
pinView.pinTintColor = [UIColor greenColor]; |
pinView.canShowCallout = YES; |
return pinView; |
} |
if ([annotation.title isEqualToString:@"purple"]) { |
MKPinAnnotationView *pinView = [[MKPinAnnotationView alloc] init]; |
pinView.pinTintColor = [UIColor purpleColor]; |
pinView.canShowCallout = YES; |
return pinView; |
} |
return nil; |
} |
+ (nonnull NSArray<id<MKAnnotation>> *)createDebuggingAnnotationsForMapView:(MKMapView *)mapView aboutFloorplan:(AAPLFloorplanOverlay *)floorplan { |
// Drop a red pin on the fromAnchor latitudeLongitude location. |
MKPointAnnotation *fromAnchor = [[MKPointAnnotation alloc] init]; |
fromAnchor.title = @"red"; |
fromAnchor.subtitle = @"fromAnchor should be here"; |
fromAnchor.coordinate = floorplan.anchors.fromAnchor.latitudeLongitude; |
// Drop a green pin on the toAnchor latitudeLongitude location. |
MKPointAnnotation *toAnchor = [[MKPointAnnotation alloc] init]; |
toAnchor.title = @"green"; |
toAnchor.subtitle = @"toAnchor should be here"; |
toAnchor.coordinate = floorplan.anchors.toAnchor.latitudeLongitude; |
// Drop a purple pin showing the (0.0 pt, 0.0 pt) location of the PDF. |
MKPointAnnotation *pdfOrigin = [[MKPointAnnotation alloc] init]; |
pdfOrigin.title = @"purple"; |
pdfOrigin.subtitle = @"This is the 0.0, 0.0 coordinate of your PDF"; |
pdfOrigin.coordinate = MKCoordinateForMapPoint(floorplan.PDFOrigin); |
return @[ |
fromAnchor, |
toAnchor, |
pdfOrigin |
]; |
} |
+ (nonnull NSArray<id<MKOverlay>> *)createDebuggingOverlaysForMapView:(MKMapView *)mapView aboutFloorplan:(AAPLFloorplanOverlay *)floorplan { |
MKPolygon *floorplanPDFBox = floorplan.polygonFromFloorplanPDFBoxCorners; |
floorplanPDFBox.title = @"debug"; |
floorplanPDFBox.subtitle = @"PDF Page Box"; |
MKPolygon *floorplanBoundingMapRect = floorplan.polygonFromBoundingMapRect; |
floorplanBoundingMapRect.title = @"debug"; |
floorplanBoundingMapRect.subtitle = @"boundingMapRect"; |
MKPolygon *floorplanBoundingMapRectIncludingRotations = floorplan.polygonFromBoundingMapRectIncludingRotations; |
floorplanBoundingMapRect.title = @"debug"; |
floorplanBoundingMapRect.subtitle = @"boundingMapRectIncludingRotations"; |
return @[ |
floorplanPDFBox, |
floorplanBoundingMapRect, |
floorplanBoundingMapRectIncludingRotations |
]; |
} |
@end |
#pragma mark - Functions related to AAPLVisibleMapRegionDelegate |
/** |
@param a an \c MKMapSize object. |
@return The area of an \c MKMapSize. |
*/ |
static double AAPLMKMapSizeArea(MKMapSize a) { |
return a.height * a.width; |
} |
/** |
@param a point A. |
@param b point B. |
@return The hypotenuse defined by two. |
*/ |
static double AAPLCGPointHypot(CGPoint a, CGPoint b) { |
return hypot(b.x - a.x, b.y - a.y); |
} |
/** |
Resets the camera orientation to the given centerpoint with the given |
heading/orientation. |
@param mapView MapView which needs to be re-centered. |
@param center new centerpoint. |
@param heading orientation to use. |
*/ |
static void resetCameraOrientation(MKMapView *mapView, CLLocationCoordinate2D center, CLLocationDirection heading) { |
MKMapCamera *newCamera = [mapView.camera copy]; |
// Center the floorplan... |
newCamera.centerCoordinate = center; |
// ...and rotate so the floorplan is upright. |
newCamera.heading = heading; |
[mapView setCamera:newCamera animated:YES]; |
} |
/** |
@return YES if the floorplan doesn't fill the screen |
@param mapView MapView to check |
@param floorplanBoundingMapRect \c MKMapRect that defines the floorplan's boundaries |
*/ |
static BOOL floorplanDoesNotFillScreen(MKMapView *mapView, MKMapRect floorplanBoundingMapRect) { |
if (MKMapRectContainsRect(floorplanBoundingMapRect, mapView.visibleMapRect)) { |
// Your view is already entirely inside the floorplan. Nothing to do. |
return NO; |
} |
// The specific part of the floorplan that is currently visible. |
MKMapRect visiblePartOfFloorplan = MKMapRectIntersection(floorplanBoundingMapRect, mapView.visibleMapRect); |
/* |
The floorplan does not fill your screen in either direction. You must have |
scrolled or zoomed out too far. |
*/ |
return visiblePartOfFloorplan.size.width < mapView.visibleMapRect.size.width && |
visiblePartOfFloorplan.size.height < mapView.visibleMapRect.size.height; |
} |
/** |
Helper function for \c clampZoomToFloorplan(). |
@return the MapCamera altitude required to bounce back the MapCamera zoom |
back onto the floorplan. if no zoom adjustment is needed, returns NAN. |
@param mapView The \c MKMapView we're looking at. |
@param floorplanBoundingMapRect bounding rectangle of the floorplan. |
*/ |
static double getZoomAdjustment(MKMapView *mapView, MKMapRect floorplanBoundingMapRect) { |
double mapViewVisibleMapRectArea = AAPLMKMapSizeArea(mapView.visibleMapRect.size); |
MKMapRect maxZoomedOut = [mapView mapRectThatFits:floorplanBoundingMapRect]; |
double maxZoomedOutArea = AAPLMKMapSizeArea(maxZoomedOut.size); |
if (maxZoomedOutArea < mapViewVisibleMapRectArea) { |
// You have zoomed out too far? |
double zoomFactor = sqrt(maxZoomedOutArea / mapViewVisibleMapRectArea); |
CLLocationDistance currentAltitude = mapView.camera.altitude; |
CLLocationDistance newAltitude = currentAltitude * zoomFactor; |
// getUsableAltitude(newAltitude, detectZoomLevel); |
CLLocationDistance newAltitudeUsable = newAltitude; |
/* |
NOTE: MapKit's internal zoom level counter is by powers of two, so a |
0.5x buffer here is safe and should prevent pulsing when we're near |
the maximum zoom level. |
Assumption: We will never see a lowestGoodAltitude smaller than 0.5x |
a stable MapKit altitude. |
*/ |
if (newAltitudeUsable < currentAltitude) { |
// Zoom back in. |
return newAltitudeUsable; |
} |
} |
// No change. Return NAN. |
return NAN; |
} |
/** |
Detect whether the user has zoomed away from the floorplan and, if so, |
bounce back. |
@return `YES` if we needed to bounce back. |
@param mapView mapview we're working on. |
@param floorplanBoundingMapRect bounds of the floorplan. |
@param floorplanCenter center of the floorplan. |
*/ |
static BOOL clampZoomToFloorplan(MKMapView *mapView, MKMapRect floorplanBoundingMapRect, CLLocationCoordinate2D floorplanCenter) { |
if (floorplanDoesNotFillScreen(mapView, floorplanBoundingMapRect)) { |
// Clamp! |
CLLocationDistance newAltitude = getZoomAdjustment(mapView, floorplanBoundingMapRect); |
if (!isnan(newAltitude)) { |
// We have a zoom change to make! |
MKMapCamera *newCamera = [mapView.camera copy]; |
newCamera.altitude = newAltitude; |
// Since we've zoomed out enough to see the entire floorplan anyway, let's re-center to make sure the entire floorplan is actually on-screen. |
newCamera.centerCoordinate = floorplanCenter; |
[mapView setCamera:newCamera animated:YES]; |
// DONE |
return YES; |
} |
} |
// No zoom correction took place. |
return NO; |
} |
/** |
Detect whether the user has scrolled away from the floorplan, and if so, |
bounce back. |
@param mapView The MapView to scroll |
@param floorplanBoundingMapRect A map rect that must be "in view" when the |
scrolling is complete. We will only scroll until this map rect |
enters the view. |
@param optionalCameraHeading If you give valid \c CLLocationDirection, we |
will also adjust the camera heading. If you give an invalid |
\c CLLocationDirection (e.g. -1.0), we'll keep whatever heading the |
camera already has. |
*/ |
static void clampScrollToFloorplan(MKMapView *mapView, AAPLMKMapRectRotated floorplanBoundingPDFBoxRect, CLLocationDirection optionalCameraHeading) { |
BOOL rotationNeeded = 0.0 <= optionalCameraHeading && optionalCameraHeading < 360.0; |
/* |
Assuming we are zoomed at the correct level, we still can't see the |
floorplan. You have scrolled too far? |
*/ |
MKMapPoint visibleMapRectMid = { |
.x = MKMapRectGetMidX(mapView.visibleMapRect), |
.y = MKMapRectGetMidY(mapView.visibleMapRect) |
}; |
MKMapPoint visibleMapRectOriginProposed = AAPLMKMapRectRotatedNearestPoint(floorplanBoundingPDFBoxRect, visibleMapRectMid); |
double dxOffset = visibleMapRectOriginProposed.x - visibleMapRectMid.x; |
double dyOffset = visibleMapRectOriginProposed.y - visibleMapRectMid.y; |
// Okay, now we know the "proposed" scroll adjustment... |
CGPoint visibleMapRectMidPixels = [mapView convertCoordinate:MKCoordinateForMapPoint(visibleMapRectMid) toPointToView:mapView]; |
CGPoint visibleMapRectProposedPixels = [mapView convertCoordinate:MKCoordinateForMapPoint(visibleMapRectOriginProposed) toPointToView:mapView]; |
double scrollDistancePixels = AAPLCGPointHypot(visibleMapRectProposedPixels, visibleMapRectMidPixels); |
/* |
... but is it more than 1.0 screen pixel worth? (Otherwise the user |
probably wouldn't even notice). |
NOTE: Due to rounding errors it's hard to get exactly |
scrollDistancePixels == 0.0 anyway, so doing a check like this improves |
general responsiveness overall. |
*/ |
BOOL scrollNeeded = scrollDistancePixels > 1.0; |
if (rotationNeeded || scrollNeeded) { |
MKMapCamera *newCamera = [mapView.camera copy]; |
if (rotationNeeded) { |
// Rotation the camera (e.g. to make the floorplan upright). |
newCamera.heading = optionalCameraHeading; |
} |
if (scrollNeeded) { |
// Scroll back toward the floorplan. |
MKMapPoint cameraCenter = MKMapPointForCoordinate(mapView.camera.centerCoordinate); |
cameraCenter.x += dxOffset; |
cameraCenter.y += dyOffset; |
newCamera.centerCoordinate = MKCoordinateForMapPoint(cameraCenter); |
} |
[mapView setCamera:newCamera animated:YES]; |
} |
} |
#pragma mark - AAPLVisibleMapRegionDelegate |
@interface AAPLVisibleMapRegionDelegate () |
/// Set to YES if you would want reset the MapCamera to center on the floorplan. |
@property BOOL needsCameraOrientationReset; |
@end |
@implementation AAPLVisibleMapRegionDelegate |
@synthesize boundingMapRectIncludingRotations; |
@synthesize boundingPDFBox; |
@synthesize floorplanCenter; |
@synthesize floorplanUprightMKMapCameraHeading; |
- (instancetype)initWithFloorplanBounds:(MKMapRect)boundingMapRectWithRotations pdfBoundingBox:(AAPLMKMapRectRotated)pdfBoundingBox centerOfFloorplan:(CLLocationCoordinate2D)centerOfFloorplan floorplanUprightMKMapCameraHeading:(CLLocationDirection)heading { |
self = [super init]; |
if (self) { |
boundingMapRectIncludingRotations = boundingMapRectWithRotations; |
boundingPDFBox = pdfBoundingBox; |
floorplanCenter = centerOfFloorplan; |
floorplanUprightMKMapCameraHeading = heading; |
_lastAltitude = NAN; |
_needsCameraOrientationReset = YES; |
} |
return self; |
} |
- (void)mapViewResetCameraToFloorplan:(MKMapView *)mapView { |
resetCameraOrientation(mapView, floorplanCenter, floorplanUprightMKMapCameraHeading); |
} |
// Catch regionDidChange events to ensure that we can always see the floorplan. |
- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated { |
MKMapCamera * camera = mapView.camera; |
BOOL didClampZoom = NO; |
// Has the zoom level stabilized? |
if (_lastAltitude != camera.altitude) { |
// Not yet! Someone is changing the zoom! |
_lastAltitude = camera.altitude; |
// Auto-zoom the camera to fit the floorplan. |
didClampZoom = clampZoomToFloorplan(mapView, boundingMapRectIncludingRotations, floorplanCenter); |
} |
if (!didClampZoom) { |
// Once the zoom level has stabilized, auto-scroll if needed. |
clampScrollToFloorplan(mapView, boundingPDFBox, (self.needsCameraOrientationReset) ? floorplanUprightMKMapCameraHeading : NAN); |
self.needsCameraOrientationReset = NO; |
} |
} |
@end |
Copyright © 2016 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2016-09-28