10

I'm trying to add a custom image to an MKMapView as an MKOverlayView - I need to restrict users from being able to scroll outside the bounds of the overlay. Are there any existing functions to do this? Or any other suggestions?

Thanks, Matt

Venk
  • 5,861
  • 8
  • 35
  • 49
mag725
  • 665
  • 2
  • 9
  • 21

6 Answers6

20

If you just want to freeze the map view at the overlay, you could set the map view's region to the overlay's bounds and set scrollEnabled and zoomEnabled to NO.

But that won't let the user scroll or zoom inside the overlay's bounds.

There aren't built-in ways to restrict the map view to the overlay's bounds so you'd have to do it manually. First, make sure your MKOverlay object implements the boundingMapRect property. That can then be used in the regionDidChangeAnimated delegate method to manually adjust the view as needed.

Here's an example of how this could be done.
Code below should be in the class that has the MKMapView.
Make sure the map view is initially set to a region where the overlay is visible.

//add two ivars to the .h...
MKMapRect lastGoodMapRect;
BOOL manuallyChangingMapRect;

//in the .m...
- (void)mapView:(MKMapView *)mapView regionWillChangeAnimated:(BOOL)animated
{
    if (manuallyChangingMapRect)
        return;     
    lastGoodMapRect = mapView.visibleMapRect;
}

- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated
{
    if (manuallyChangingMapRect) //prevents possible infinite recursion when we call setVisibleMapRect below
        return;     

    // "theOverlay" below is a reference to your MKOverlay object.
    // It could be an ivar or obtained from mapView.overlays array.

    BOOL mapContainsOverlay = MKMapRectContainsRect(mapView.visibleMapRect, theOverlay.boundingMapRect);

    if (mapContainsOverlay)
    {
        // The overlay is entirely inside the map view but adjust if user is zoomed out too much...
        double widthRatio = theOverlay.boundingMapRect.size.width / mapView.visibleMapRect.size.width;
        double heightRatio = theOverlay.boundingMapRect.size.height / mapView.visibleMapRect.size.height;
        if ((widthRatio < 0.6) || (heightRatio < 0.6)) //adjust ratios as needed
        {
            manuallyChangingMapRect = YES;
            [mapView setVisibleMapRect:theOverlay.boundingMapRect animated:YES];
            manuallyChangingMapRect = NO;
        }
    }
    else
        if (![theOverlay intersectsMapRect:mapView.visibleMapRect])
        {
            // Overlay is no longer visible in the map view.
            // Reset to last "good" map rect...
            [mapView setVisibleMapRect:lastGoodMapRect animated:YES];
        }   
}

I tried this with the built-in MKCircle overlay and seems to work well.


EDIT:

It does work well 95% of the time, however, I have confirmed through some testing that it might oscillate between two locations, then enter an infinite loop. So, I edited it a bit, I think this should solve the problem:

// You can safely delete this method:
- (void)mapView:(MKMapView *)mapView regionWillChangeAnimated:(BOOL)animated {

}

- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated {
     // prevents possible infinite recursion when we call setVisibleMapRect below
    if (manuallyChangingMapRect) {
        return;
    }

    // "theOverlay" below is a reference to your MKOverlay object.
    // It could be an ivar or obtained from mapView.overlays array.

    BOOL mapContainsOverlay = MKMapRectContainsRect(mapView.visibleMapRect, theOverlay.boundingMapRect);

    if (mapContainsOverlay) {
        // The overlay is entirely inside the map view but adjust if user is zoomed out too much...
        double widthRatio = theOverlay.boundingMapRect.size.width / mapView.visibleMapRect.size.width;
        double heightRatio = theOverlay.boundingMapRect.size.height / mapView.visibleMapRect.size.height;
        // adjust ratios as needed
        if ((widthRatio < 0.6) || (heightRatio < 0.6)) {
            manuallyChangingMapRect = YES;
            [mapView setVisibleMapRect:theOverlay.boundingMapRect animated:YES];
            manuallyChangingMapRect = NO;
        }
    } else if (![theOverlay intersectsMapRect:mapView.visibleMapRect]) {
        // Overlay is no longer visible in the map view.
        // Reset to last "good" map rect...
        manuallyChangingMapRect = YES;
        [mapView setVisibleMapRect:lastGoodMapRect animated:YES];
        manuallyChangingMapRect = NO;
    } else {
        lastGoodMapRect = mapView.visibleMapRect;
    }
}

And just in case someone is looking for a quick MKOverlay solution, here is one:

- (void)viewDidLoad {
    [super viewDidLoad];

    MKCircle* circleOverlay = [MKCircle circleWithMapRect:istanbulRect];
    [_mapView addOverlay:circleOverlay];

    theOverlay = circleOverlay;
}

- (MKOverlayView *)mapView:(MKMapView *)mapView viewForOverlay:(id<MKOverlay>)overlay {
    MKCircleView* circleOverlay = [[MKCircleView alloc] initWithCircle:overlay];
    [circleOverlay setStrokeColor:[UIColor mainColor]];
    [circleOverlay setLineWidth:4.f];

    return circleOverlay;
}
Venk
  • 5,861
  • 8
  • 35
  • 49
  • worked quite well. doesn't handle cases where you pan (swipe, scroll, whatever) the view outside of your bounds however. – horseshoe7 Jul 20 '11 at 19:07
  • @horseshoe7, does the view snap back into the overlay after you end the gesture? Also, the map has to start inside the overlay for the above to work. If you need to restrict _while_ the user is moving the map, you _might_ be able to do it using a UIPanGestureRecognizer and checking the new map region in the gesture handler. –  Jul 20 '11 at 19:29
  • 2
    @AnnaKarenina Your code is awesome, especially since it is very well commented. However, there are some cases you missed and caused the code to oscillate between to locations, and then do an infinite loop (kind of like a stunt, if you ask me). So, I'll add my code, and if you find it useful, please merge it with your code. – Mazyod Jan 01 '13 at 15:47
  • Is there a way to intercept scroll view inside the mapview so it doesn't scroll outside to begin with? Right now the map is brought back to the overlay region, but I would like it not to scroll outside to begin with. – huggie Nov 05 '13 at 03:21
  • 1
    @huggie: I don't know of any documented way of getting the scroll view inside and I have not explored (and would not like to rely on) any undocumented approaches. Alternative which may work (have not tried it), is to intercept the map region changes using pan and pinch gesture recognizers which fire more frequently than the map's delegate method (see http://stackoverflow.com/a/6849362/467105). There is also [this other silly option](http://stackoverflow.com/a/8683569/467105) of simply hiding or covering up the world outside the region you're interested in. –  Nov 05 '13 at 03:40
  • 1
    One might prefer to consider MKMapView's regionThatFits: as an alternative to dealing with aspect ratios https://developer.apple.com/library/ios/documentation/MapKit/Reference/MKMapView_Class/index.html#//apple_ref/occ/instm/MKMapView/regionThatFits: – yuzisee Dec 06 '14 at 02:14
4

In my case, I needed to restrict bounds to tiled overlay which has an upperleft / lowerRight coordinates. Code above still works well, but substituted theOverlay.boundingMapRect for MKMapRect paddedBoundingMapRect

- (void)mapView:(MKMapView *)_mapView regionDidChangeAnimated:(BOOL)animated
{
if (manuallyChangingMapRect) //prevents possible infinite recursion when we call setVisibleMapRect below
    return;     

[self updateDynamicPaddedBounds];

MKMapPoint pt =  MKMapPointForCoordinate( mapView.centerCoordinate);

BOOL mapInsidePaddedBoundingRect = MKMapRectContainsPoint(paddedBoundingMapRect,pt );

if (!mapInsidePaddedBoundingRect)
{
    // Overlay is no longer visible in the map view.
    // Reset to last "good" map rect...

    manuallyChangingMapRect = YES;
    [mapView setVisibleMapRect:lastGoodMapRect animated:YES];
    manuallyChangingMapRect = NO;


}


-(void)updateDynamicPaddedBounds{

ENTER_METHOD;

CLLocationCoordinate2D  northWestPoint= CLLocationCoordinate2DMake(-33.841171,151.237318 );
CLLocationCoordinate2D  southEastPoint= CLLocationCoordinate2DMake(-33.846127, 151.245058);



MKMapPoint upperLeft = MKMapPointForCoordinate(northWestPoint);
MKMapPoint lowerRight = MKMapPointForCoordinate(southEastPoint);
double width = lowerRight.x - upperLeft.x;
double height = lowerRight.y - upperLeft.y;


MKMapRect mRect = mapView.visibleMapRect;
MKMapPoint eastMapPoint = MKMapPointMake(MKMapRectGetMinX(mRect), MKMapRectGetMidY(mRect));
MKMapPoint westMapPoint = MKMapPointMake(MKMapRectGetMaxX(mRect), MKMapRectGetMidY(mRect));
MKMapPoint northMapPoint = MKMapPointMake(MKMapRectGetMidX(mRect), MKMapRectGetMaxY(mRect));
MKMapPoint southMapPoint = MKMapPointMake(MKMapRectGetMidX(mRect), MKMapRectGetMinY(mRect));

double xMidDist = abs(eastMapPoint.x -  westMapPoint.x)/2;
double yMidDist = abs(northMapPoint.y -  southMapPoint.y)/2;


upperLeft.x = upperLeft.x + xMidDist;
upperLeft.y = upperLeft.y + yMidDist;


double paddedWidth =  width - (xMidDist*2); 
double paddedHeight = height - (yMidDist*2);

paddedBoundingMapRect= MKMapRectMake(upperLeft.x, upperLeft.y, paddedWidth, paddedHeight);

}

johndpope
  • 4,223
  • 2
  • 33
  • 37
1

A Good Answer for Swift 4

with following code you can detect bound limit for scroll

NOTE: in following code 5000 number is amount of restriced area in terms of meters. so you can use like this > let restricedAreaMeters = 5000

func detectBoundingBox(location: CLLocation) {
        let latRadian = degreesToRadians(degrees: CGFloat(location.coordinate.latitude))
        let degLatKm = 110.574235
        let degLongKm = 110.572833 * cos(latRadian)
        let deltaLat = 5000 / 1000.0 / degLatKm 
        let deltaLong = 5000 / 1000.0 / degLongKm

        southLimitation = location.coordinate.latitude - deltaLat
        westLimitation = Double(CGFloat(location.coordinate.longitude) - deltaLong)
        northLimitation =  location.coordinate.latitude + deltaLat
        eastLimitation = Double(CGFloat(location.coordinate.longitude) + deltaLong)
    }

    func degreesToRadians(degrees: CGFloat) -> CGFloat {
        return degrees * CGFloat(M_PI) / 180
    }

and finally with overrided method at bellow if user got out from bounded area will be returned to latest allowed coordinate.

 var lastCenterCoordinate: CLLocationCoordinate2D!
 extension UIViewController: MKMapViewDelegate {
        func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { 
           let coordinate = CLLocationCoordinate2DMake(mapView.region.center.latitude, mapView.region.center.longitude) 
           let latitude = mapView.region.center.latitude
           let longitude = mapView.region.center.longitude

            if latitude < northLimitation && latitude > southLimitation && longitude < eastLimitation && longitude > westLimitation {
                lastCenterCoordinate = coordinate
            } else {
                span = MKCoordinateSpanMake(0, 360 / pow(2, Double(16)) * Double(mapView.frame.size.width) / 256)
                let region = MKCoordinateRegionMake(lastCenterCoordinate, span)
                mapView.setRegion(region, animated: true)
            }
        }
 }
Saeed
  • 322
  • 4
  • 14
1

MapKit now does this natively in iOS 13

You can explicitly set a boundary to restrict panning.

let boundaryRegion = MKCoordinateRegion(...) // the region you want to restrict
let cameraBoundary = CameraBoundary(region: boundaryRegion)
mapView.setCameraBoundary(cameraBoundary: cameraBoundary, animated: true)

See WWDC 2019 video at 2378 seconds for a demonstration.

You can also restrict zoom levels

let zoomRange = CameraZoomRange(minCenterCoordinateDistance: 100,
    maxCenterCoordinateDistance: 500)
mapView.setCameraZoomRange(zoomRange, animated: true)

References

RP-3
  • 564
  • 2
  • 17
0

Anna's (https://stackoverflow.com/a/4126011/3191130) solution in Swift 3.0, I added to an extension:

extension HomeViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
    if manuallyChangingMapRect {
        return
    }
    guard let overlay = self.mapOverlay else {
        print("Overlay is nil")
        return
    }
    guard let lastMapRect = self.lastGoodMapRect else {
        print("LastGoodMapRect is nil")
        return
    }

    let mapContainsOverlay = MKMapRectContainsRect(mapView.visibleMapRect, overlay.boundingMapRect)
    if mapContainsOverlay {
        let widthRatio: Double = overlay.boundingMapRect.size.width / mapView.visibleMapRect.size.width
        let heightRatio: Double = overlay.boundingMapRect.size.height / mapView.visibleMapRect.size.height
        // adjust ratios as needed
        if (widthRatio < 0.9) || (heightRatio < 0.9) {
            manuallyChangingMapRect = true
            mapView.setVisibleMapRect(overlay.boundingMapRect, animated: true)
            manuallyChangingMapRect = false
        }
    } else if !overlay.intersects(mapView.visibleMapRect) {
            // Overlay is no longer visible in the map view.
            // Reset to last "good" map rect...
            manuallyChangingMapRect = true
            mapView.setVisibleMapRect(lastMapRect, animated: true)
            manuallyChangingMapRect = false
        }
        else {
            lastGoodMapRect = mapView.visibleMapRect
    }
}
}

To setup the map use this:

override func viewDidLoad() {
    super.viewDidLoad()
    setupMap()
}

func setupMap() {
    mapView.delegate = self
    let radius:CLLocationDistance = 1000000
    mapOverlay = MKCircle(center: getCenterCoord(), radius: radius)
    if let mapOverlay = mapOverlay  {
        mapView.add(mapOverlay)
    }
    mapView.setRegion(MKCoordinateRegionMake(getCenterCoord(), getSpan()), animated: true)
    lastGoodMapRect = mapView.visibleMapRect
}

func getCenterCoord() -> CLLocationCoordinate2D {
    return CLLocationCoordinate2DMake(LAT, LON)
}
func getSpan() -> MKCoordinateSpan {
    return MKCoordinateSpanMake(10, 10)
}
Community
  • 1
  • 1
IuryPainelli
  • 325
  • 4
  • 10
0

SWIFT 5

simple solution for use inside mapViewDidFinishLoadingMap:

    func mapViewDidFinishLoadingMap(_ mapView: MKMapView) {

        //center of USA, roughly. for example
        let center = CLLocationCoordinate2D(latitude: 38.573936, longitude: -92.603760) 
        let latMeters = CLLocationDistance(10_000_000.00) //left and right pan
        let longMeters = CLLocationDistance(5_000_000.00) //up and down pan
        
        let coordinateRegion = MKCoordinateRegion(
            center: center,
            latitudinalMeters: latMeters,
            longitudinalMeters: longMeters)
        
        let cameraBoundary = MKMapView.CameraBoundary(coordinateRegion: coordinateRegion)
        mapView.setCameraBoundary(cameraBoundary, animated: true)
    }
TrevPennington
  • 279
  • 3
  • 10