Is there way to limit MKMapView maximum zoom level?
Asked Answered
U

12

35

the question is - is there a way to limit maximum zoom level for MKMapView? Or is there a way to track when user zooms to the level where there's no map image available?

Unconscionable answered 28/10, 2009 at 12:12 Comment(0)
U
29

You could use the mapView:regionWillChangeAnimated: delegate method to listen for region change events, and if the region is wider than your maximum region, set it back to the max region with setRegion:animated: to indicate to your user that they can't zoom out that far. Here's the methods:

- (void)mapView:(MKMapView *)mapView regionWillChangeAnimated:(BOOL)animated
- (void)setRegion:(MKCoordinateRegion)region animated:(BOOL)animated
Uphill answered 28/10, 2009 at 13:59 Comment(6)
Thank you. It seems to work ok for me, It just appeared that its better to do this in regionDidChangeAnimated method instead of regionDidChangeAnimatedUnconscionable
Glad it worked. What difference did changing the method to regionDidChangeAnimated make? Was it a timing thing?Uphill
Can you provide the code you used inside of regionDidAnimate? Every time I call [mapView setRegion] from inside of regionDidAnimate I enter an infinite loop.Puckett
Hi flashcards. It would probably be easier to open a new question with your problem. You could explain it more fully with a code sample.Uphill
I had exactly the same problem as flashcards. I would argue that the question hasn't been answered sufficiently - could someone post some working source code that does NOT create an infinite loop?Petr
@Puckett setRegion will actually cause regionWillChange to fire; you need to make sure that regionWillChange has conditional logic that fires under whatever condition, while setRegion will set a region that does not fire that logic.Neckar
F
29

If you're working with iOS 7+ only, there's a new camera.altitude property that you can get/set to enforce a zoom level. Its equivalent to azdev's solution, but no external code is required.

In testing, I also discovered that it was possible to enter an infinite loop if you repeatedly tried to zoom in at detail, so I have a var to prevent that in my code below.

- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated {
    // enforce maximum zoom level
    if (_mapView.camera.altitude < 120.00 && !_modifyingMap) {
        _modifyingMap = YES; // prevents strange infinite loop case

        _mapView.camera.altitude = 120.00;

        _modifyingMap = NO;
    }
}
Ferland answered 31/1, 2014 at 1:11 Comment(6)
Couldn't you also set altitude to 125 every time it's less than 120?Anselme
@EricWelander Sure. Does that change my answer though? Whether 120 or 125, _mapView.camera.altitude < 120.00 still evaluates to false.Ferland
could potentially eliminate need for boolean flag?Anselme
Setting the new altitude to a higher number than in the if statement did eliminate my need for a boolean flag.Pimbley
I thought this was going to be a solution that works for me, but it doesn't. I need to do the checking when I look up a location and add an annotation. After adding the annotation, I call showAnnotations(), which is what changes the zoom (too close if there is only one annotation). Setting altitude before this does nothing, since showAnnotations() resets the zoom. Setting altitude afterward doesn't work because the newly added annotation doesn't appear for some reason.Pomander
I figured out a solution to my situation: upon getting coords, save current region to a variable, add the annotation, call showAnnotations without animation, check the new region. If the new region is too close, restore the original region, adjust the span of the new region, then set it with animation.Pomander
G
21

I just spent some time working on this for an app i'm building. Here's what I came up with:

  1. I started with Troy Brant's script on this page which is a nicer way to set the map view I think.

  2. I added a method to return the current zoom level.

    In MKMapView+ZoomLevel.h:

    - (double)getZoomLevel;
    

    In MKMapView+ZoomLevel.m:

    // Return the current map zoomLevel equivalent, just like above but in reverse
    - (double)getZoomLevel{
        MKCoordinateRegion reg=self.region; // the current visible region
        MKCoordinateSpan span=reg.span; // the deltas
        CLLocationCoordinate2D centerCoordinate=reg.center; // the center in degrees
        // Get the left and right most lonitudes
        CLLocationDegrees leftLongitude=(centerCoordinate.longitude-(span.longitudeDelta/2));
        CLLocationDegrees rightLongitude=(centerCoordinate.longitude+(span.longitudeDelta/2));
        CGSize mapSizeInPixels = self.bounds.size; // the size of the display window
    
        // Get the left and right side of the screen in fully zoomed-in pixels
        double leftPixel=[self longitudeToPixelSpaceX:leftLongitude]; 
        double rightPixel=[self longitudeToPixelSpaceX:rightLongitude];
        // The span of the screen width in fully zoomed-in pixels
        double pixelDelta=abs(rightPixel-leftPixel);
    
        // The ratio of the pixels to what we're actually showing
        double zoomScale= mapSizeInPixels.width /pixelDelta;
        // Inverse exponent
        double zoomExponent=log2(zoomScale);
        // Adjust our scale
        double zoomLevel=zoomExponent+20; 
        return zoomLevel;
    }
    

    This method relies on a few private methods in the code linked above.

  3. I added this in to my MKMapView delegate (as @vladimir recommended above)

    - (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated {
        NSLog(@"%f",[mapView getZoomLevel]);
        if([mapView getZoomLevel]<10) {
            [mapView setCenterCoordinate:[mapView centerCoordinate] zoomLevel:10 animated:TRUE];
        }
    }
    

    This has the effect of re-zooming if the user gets too far out. You can use regionWillChangeAnimated to prevent the map from 'bouncing' back in.

    Regarding the looping comments above, it looks like this method only iterates once.

Glister answered 19/6, 2011 at 0:6 Comment(1)
this code was not working ok in my app, I wasted several hours, finally made it work by changing it to: - (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated { newDesiredZoomLevel = [mapView getZoomLevel]; NSLog(@"%f",newDesiredZoomLevel); if((newDesiredZoomLevel > 2.0f) && (newDesiredZoomLevel < 6.0f)) { [mapView setCenterCoordinate:[mapView centerCoordinate] zoomLevel:7.9 animated:TRUE]; } The first condition is there to prevent this setCenterCorodinate firing when the mapview is initiating (zoomlevel=1) and the value for zoomLevel inside the if was found by trying.Mellar
S
15

Yes, this is doable. First, extend MKMapView by using MKMapView+ZoomLevel.

Then, implement this in your MKMapViewDelegate:

- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated
{
    // Constrain zoom level to 8.
    if( [mapView zoomLevel] < 8 )
    {
        [mapView setCenterCoordinate:mapView.centerCoordinate 
            zoomLevel:8 
            animated:NO];
    }
}
Scriptural answered 6/10, 2011 at 19:35 Comment(0)
M
9

Here is code rewritten in Swift 3 using MKMapView+ZoomLevel and @T.Markle answer:

import Foundation
import MapKit

fileprivate let MERCATOR_OFFSET: Double = 268435456
fileprivate let MERCATOR_RADIUS: Double = 85445659.44705395

extension MKMapView {

    func getZoomLevel() -> Double {

        let reg = self.region
        let span = reg.span
        let centerCoordinate = reg.center

        // Get the left and right most lonitudes
        let leftLongitude = centerCoordinate.longitude - (span.longitudeDelta / 2)
        let rightLongitude = centerCoordinate.longitude + (span.longitudeDelta / 2)
        let mapSizeInPixels = self.bounds.size

        // Get the left and right side of the screen in fully zoomed-in pixels
        let leftPixel = self.longitudeToPixelSpaceX(longitude: leftLongitude)
        let rightPixel = self.longitudeToPixelSpaceX(longitude: rightLongitude)
        let pixelDelta = abs(rightPixel - leftPixel)

        let zoomScale = Double(mapSizeInPixels.width) / pixelDelta
        let zoomExponent = log2(zoomScale)
        let zoomLevel = zoomExponent + 20

        return zoomLevel
    }

    func setCenter(coordinate: CLLocationCoordinate2D, zoomLevel: Int, animated: Bool) {

        let zoom = min(zoomLevel, 28)

        let span = self.coordinateSpan(centerCoordinate: coordinate, zoomLevel: zoom)
        let region = MKCoordinateRegion(center: coordinate, span: span)

        self.setRegion(region, animated: true)
    }

    // MARK: - Private func

    private func coordinateSpan(centerCoordinate: CLLocationCoordinate2D, zoomLevel: Int) -> MKCoordinateSpan {

        // Convert center coordiate to pixel space
        let centerPixelX = self.longitudeToPixelSpaceX(longitude: centerCoordinate.longitude)
        let centerPixelY = self.latitudeToPixelSpaceY(latitude: centerCoordinate.latitude)

        // Determine the scale value from the zoom level
        let zoomExponent = 20 - zoomLevel
        let zoomScale = NSDecimalNumber(decimal: pow(2, zoomExponent)).doubleValue

        // Scale the map’s size in pixel space
        let mapSizeInPixels = self.bounds.size
        let scaledMapWidth = Double(mapSizeInPixels.width) * zoomScale
        let scaledMapHeight = Double(mapSizeInPixels.height) * zoomScale

        // Figure out the position of the top-left pixel
        let topLeftPixelX = centerPixelX - (scaledMapWidth / 2)
        let topLeftPixelY = centerPixelY - (scaledMapHeight / 2)

        // Find delta between left and right longitudes
        let minLng: CLLocationDegrees = self.pixelSpaceXToLongitude(pixelX: topLeftPixelX)
        let maxLng: CLLocationDegrees = self.pixelSpaceXToLongitude(pixelX: topLeftPixelX + scaledMapWidth)
        let longitudeDelta: CLLocationDegrees = maxLng - minLng

        // Find delta between top and bottom latitudes
        let minLat: CLLocationDegrees = self.pixelSpaceYToLatitude(pixelY: topLeftPixelY)
        let maxLat: CLLocationDegrees = self.pixelSpaceYToLatitude(pixelY: topLeftPixelY + scaledMapHeight)
        let latitudeDelta: CLLocationDegrees = -1 * (maxLat - minLat)

        return MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta)
    }

    private func longitudeToPixelSpaceX(longitude: Double) -> Double {
        return round(MERCATOR_OFFSET + MERCATOR_RADIUS * longitude * M_PI / 180.0)
    }

    private func latitudeToPixelSpaceY(latitude: Double) -> Double {
        if latitude == 90.0 {
            return 0
        } else if latitude == -90.0 {
            return MERCATOR_OFFSET * 2
        } else {
            return round(MERCATOR_OFFSET - MERCATOR_RADIUS * Double(logf((1 + sinf(Float(latitude * M_PI) / 180.0)) / (1 - sinf(Float(latitude * M_PI) / 180.0))) / 2.0))
        }
    }

    private func pixelSpaceXToLongitude(pixelX: Double) -> Double {
        return ((round(pixelX) - MERCATOR_OFFSET) / MERCATOR_RADIUS) * 180.0 / M_PI
    }


    private func pixelSpaceYToLatitude(pixelY: Double) -> Double {
        return (M_PI / 2.0 - 2.0 * atan(exp((round(pixelY) - MERCATOR_OFFSET) / MERCATOR_RADIUS))) * 180.0 / M_PI
    }
}

Example of use in your view controller:

func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
        print("Zoom: \(mapView.getZoomLevel())")
        if mapView.getZoomLevel() > 6 {
            mapView.setCenter(coordinate: mapView.centerCoordinate, zoomLevel: 6, animated: true)
        }
    }
Mclean answered 1/3, 2017 at 14:39 Comment(0)
C
5

Use this example to lock the maximum zoom range, also equally you can limit the minimum

map.cameraZoomRange = MKMapView.CameraZoomRange(maxCenterCoordinateDistance: 1200000)

Canakin answered 5/1, 2020 at 20:44 Comment(0)
A
4

If you are targeting iOS 13+, use the MKMapView setCameraZoomRange method. Simply provide the min and max center coordinate distances (measured in meters).

See Apple's Documentation here: https://developer.apple.com/documentation/mapkit/mkmapview/3114302-setcamerazoomrange

Alanealanine answered 16/4, 2021 at 18:33 Comment(0)
S
2

Don't use regionWillChangeAnimated. Use regionDidChangeAnimated

  • we can also use setRegion(region, animated: true). Normally it will freeze MKMapView if we use regionWillChangeAnimated, but with regionDidChangeAnimated it works perfectly

    func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
      mapView.checkSpan()
    }
    
    extension MKMapView {
      func zoom() {
        let region = MKCoordinateRegionMakeWithDistance(userLocation.coordinate, 2000, 2000)
        setRegion(region, animated: true)
      }
    
      func checkSpan() {
        let rect = visibleMapRect
        let westMapPoint = MKMapPointMake(MKMapRectGetMinX(rect), MKMapRectGetMidY(rect))
        let eastMapPoint = MKMapPointMake(MKMapRectGetMaxX(rect), MKMapRectGetMidY(rect))
    
        let distanceInMeter = MKMetersBetweenMapPoints(westMapPoint, eastMapPoint)
    
        if distanceInMeter > 2100 {
          zoom()
        }
      }
    }
    
Strontian answered 20/4, 2017 at 9:6 Comment(0)
E
1

The MKMapView has, inside of it, a MKScrollView (private API), that is a subclass of UIScrollView. The delegate of this MKScrollView is its own mapView.

So, in order to control the max zoom do the following:

Create a subclass of MKMapView:

MapView.h

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

@interface MapView : MKMapView <UIScrollViewDelegate>

@end

MapView.m

#import "MapView.h"

@implementation MapView

-(void)scrollViewDidZoom:(UIScrollView *)scrollView {

    UIScrollView * scroll = [[[[self subviews] objectAtIndex:0] subviews] objectAtIndex:0];

    if (scroll.zoomScale > 0.09) {
        [scroll setZoomScale:0.09 animated:NO];
    }

}

@end

Then, access the scroll subview and see the zoomScale property. When the zoom is greater than a number, set your max zoom.

Eruptive answered 12/8, 2011 at 1:19 Comment(4)
hm, nice one. btw, have you checked if this solution is appstore-safe?Unconscionable
Probably, it is. I'm gonna submit one app using it on next week. But I've already submitted apps with similar approach for UIWebView and UITabBar and all of them was approved.Eruptive
Everything has changed in iOS6. It doesn't work now. scrollViewDidZoom isn't called.Overcoat
I would discourage anyone from ever using private APIs. Never a good idea.Bingle
L
0

The post by Raphael Petegrosso with the extended MKMapView works great with some small modifications. The version below is also much more "user friendly", as it gracefully "snaps" back to the defined zoom level as soon as the user lets go of the screen, being similar in feel to Apple's own bouncy scrolling.

Edit: This solution is not optimal and will break/damage the map view, I found a much better solution here: How to detect any tap inside an MKMapView. This allows you to intercept pinching and other motions.


MyMapView.h

#import <MapKit/MapKit.h>


@interface MyMapView : MKMapView <UIScrollViewDelegate>
@end

MyMapView.m

#import "MyMapView.h"

@implementation MyMapView

- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(float)scale
{
    if (scale > 0.001)
    {
        [scrollView setZoomScale:0.001 animated:YES];
    }
}
@end

For a hard limit, use this:

#import "MyMapView.h"

@implementation MyMapView

-(void)scrollViewDidZoom:(UIScrollView *)scrollView
{
    if (scrollView.zoomScale > 0.001)
    {
        [scrollView setZoomScale:0.001 animated:NO];
    }

}

@end
Lope answered 22/2, 2012 at 14:57 Comment(0)
I
0

The following code worked for me and is conceptually easy to use because it sets the region based on a distance in meters. The code is derived from the answer posted by: @nevan-king and the comment posted by @Awais-Fayyaz to use regionDidChangeAnimated

Add the following extension to your MapViewDelegate

var currentLocation: CLLocationCoordinate2D?

extension MyMapViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
        if self.currentLocation != nil, mapView.region.longitudinalMeters > 1000 {
            let initialLocation = CLLocation(latitude: (self.currentLocation?.latitude)!,
                                         longitude: (self.currentLocation?.longitude)!)
            let coordinateRegion = MKCoordinateRegionMakeWithDistance(initialLocation.coordinate,
                                                                  regionRadius, regionRadius)
            mapView.setRegion(coordinateRegion, animated: true)
        }
    }
}

Then define an extension for MKCoordinateRegion as follows.

extension MKCoordinateRegion {
    /// middle of the south edge
    var south: CLLocation {
        return CLLocation(latitude: center.latitude - span.latitudeDelta / 2, longitude: center.longitude)
    }
    /// middle of the north edge
    var north: CLLocation {
        return CLLocation(latitude: center.latitude + span.latitudeDelta / 2, longitude: center.longitude)
    }
    /// middle of the east edge
    var east: CLLocation {
        return CLLocation(latitude: center.latitude, longitude: center.longitude + span.longitudeDelta / 2)
    }
    /// middle of the west edge
    var west: CLLocation {
        return CLLocation(latitude: center.latitude, longitude: center.longitude - span.longitudeDelta / 2)
    }
    /// distance between south and north in meters. Reverse function for MKCoordinateRegionMakeWithDistance
    var latitudinalMeters: CLLocationDistance {
        return south.distance(from: north)
    }
    /// distance between east and west in meters. Reverse function for MKCoordinateRegionMakeWithDistance
    var longitudinalMeters: CLLocationDistance {
        return east.distance(from: west)
    }
}

The above snippet for MKCoordinateRegion was posted by @Gerd-Castan on this question:

Reverse function of MKCoordinateRegionMakeWithDistance?

Inequity answered 5/12, 2018 at 15:21 Comment(0)
O
-1

I've run into this very issue at work and have created something that works fairly well without setting a global limit.

The MapView delegates that I leverage are: - mapViewDidFinishRendering - mapViewRegionDidChange

The premise behind my solution is that since a satellite view renders an area with no data it is always the same thing. This dreaded image (https://i.sstatic.net/7Guev.jpg) If we can comfortably rely on that fail case we can use it as a key for determining wha the user is seeing. After the map renders, I take a screenshot of the rendered map bounds and determing an average RGB value. Based off of that RGB value, I assume that the area in question has no data. If that's the case I pop the map back out to the last span that was rendered correctly.

The only global check I have is when it starts to check the map, you can increase or decrease that setting based on your needs. Below is the raw code that will accomplish this and will be putting together a sample project for contribution. Any optimizations you can offer would be appreciated and hope it helps.

@property (assign, nonatomic) BOOL isMaxed;
@property (assign, nonatomic) MKCoordinateSpan lastDelta;

self.lastDelta = MKCoordinateSpanMake(0.006, 0.006);

- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated {
    if (mapView.mapType != MKMapTypeStandard && self.isMaxed) {
            [self checkRegionWithDelta:self.lastDelta.longitudeDelta];
    }
}


- (void)checkRegionWithDelta:(float)delta {
    if (self.mapView.region.span.longitudeDelta < delta) {
        MKCoordinateRegion region = self.mapView.region;
        region.span = self.lastDelta;
        [self.mapView setRegion:region animated:NO];
    } else if (self.mapView.region.span.longitudeDelta > delta) {
        self.isMaxed = NO;
    }
}


- (void)mapViewDidFinishRenderingMap:(MKMapView *)mapView fullyRendered:(BOOL)fullyRendered {
    if (mapView.mapType != MKMapTypeStandard && !self.isMaxed) {
        [self checkToProcess:self.lastDelta.longitudeDelta];
    }
}


- (void)checkToProcess:(float)delta {
    if (self.mapView.region.span.longitudeDelta < delta) {
        UIGraphicsBeginImageContext(self.mapView.bounds.size);
        [self.mapView.layer renderInContext:UIGraphicsGetCurrentContext()];
        UIImage *mapImage = UIGraphicsGetImageFromCurrentImageContext();
        [self processImage:mapImage];
    }
}


- (void)processImage:(UIImage *)image {
    self.mapColor = [self averageColor:image];
    const CGFloat* colors = CGColorGetComponents( self.mapColor.CGColor );
    [self handleColorCorrection:colors[0]];
}


- (void)handleColorCorrection:(float)redColor {
    if (redColor < 0.29) {
        self.isMaxed = YES;
        [self.mapView setRegion:MKCoordinateRegionMake(self.mapView.centerCoordinate, self.lastDelta) animated:YES];
    } else {
        self.lastDelta = self.mapView.region.span;
    }
}


- (UIColor *)averageColor:(UIImage *)image {
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    unsigned char rgba[4];
    CGContextRef context = CGBitmapContextCreate(rgba, 1, 1, 8, 4, colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);

    CGContextDrawImage(context, CGRectMake(0, 0, 1, 1), image.CGImage);
    CGColorSpaceRelease(colorSpace);
    CGContextRelease(context);

    if(rgba[3] > 0) {
        CGFloat alpha = ((CGFloat)rgba[3])/255.0;
        CGFloat multiplier = alpha/255.0;
        return [UIColor colorWithRed:((CGFloat)rgba[0])*multiplier
                               green:((CGFloat)rgba[1])*multiplier
                                blue:((CGFloat)rgba[2])*multiplier
                               alpha:alpha];
    }
    else {
        return [UIColor colorWithRed:((CGFloat)rgba[0])/255.0
                               green:((CGFloat)rgba[1])/255.0
                                blue:((CGFloat)rgba[2])/255.0
                               alpha:((CGFloat)rgba[3])/255.0];
    }
}
Oolite answered 5/6, 2016 at 15:38 Comment(1)
thanks for the help formatting Vladimir, apologize for that everyone.Oolite

© 2022 - 2024 — McMap. All rights reserved.