Centering a map annotation to the top quarter of the screen - swift

so I'd like to go from image 1 to image 2 when an annotation is clicked (Mapbox):
https://i.stack.imgur.com/OFIFa.png
It's fairly easy to have the map center on the annotation, by calling mapView.setCenter() inside one of the Mapbox delegate functions as follows:
func mapView(_ mapView: MGLMapView, didSelect annotation: MGLAnnotation) {
mapView.setCenter(annotation.coordinate, animated: true)
}
Obviously, this centers the annotation to the middle of the screen, but I need the annotation to be 'centered' to in the area above the view that pops up so it's still visible (ideally, it'll be equidistant from the top of 'View' and the top edge of the screen).
I was thinking of setting the zoomLevel in setCenter and then zooming to a specific distance south of the annotation, but the problem with this is various screen sizes on iOS devices will have the annotation centered differently.
I was also thinking maybe I could do some kind of coordinate conversion from the map to a CGPoint on the screen but am really unsure about how to implement this correctly (I've only ever used mapView.convert(CGPoint,toCoordinateFrom: mapView), and this won't be useful here). I'm not sure how to approach this problem. Any help would be much appreciated, whether it's just getting me started on a path or if you already have a solution that's even better. Thanks!

I don't use MapBox but if you can access the region's span (horizontal and vertical distance of the map)
You can subtract the desired amount of the latitude's span from the annotation's coordinate and set that center for the map.
///Centers the map region by placing the passed annotation in the center of the I quadrant
func centerTop(annotation: SpecialAnnot){
//Find a 4th of the span (The horizontal and vertical span representing the amount of map to display.)
let latCorrection = region.span.latitudeDelta/4 + region.span.latitudeDelta/8
self.region = MKCoordinateRegion(center: .init(latitude: annotation.coordinate.latitude - latCorrection, longitude: annotation.coordinate.longitude), span: region.span)
}
Here is some SwiftUI code.
import SwiftUI
import MapKit
class SpecialAnnot: NSObject, MKAnnotation, Identifiable{
let id: UUID = .init()
var coordinate: CLLocationCoordinate2D
init(coordinate: CLLocationCoordinate2D) {
self.coordinate = coordinate
super.init()
}
}
struct QuarterCenteredView: View {
#State var region: MKCoordinateRegion = MKCoordinateRegion()
let annotationCoordinate: [SpecialAnnot] = [SpecialAnnot(coordinate: .init(latitude: 40.748817, longitude: -73.985428))]
var body: some View {
VStack{
Button("center quadrant I", action: {
centerTop(annotation: annotationCoordinate.first!)
})
Map(coordinateRegion: $region, annotationItems: annotationCoordinate, annotationContent: { annot in
MapAnnotation(coordinate: annot.coordinate, content: {
Image(systemName: "mappin").foregroundColor(.red)
})
})
.onAppear(perform: {
DispatchQueue.main.async {
region = MKCoordinateRegion(center: annotationCoordinate.first!.coordinate, span: region.span)
}
})
}
}
///Centers the map region by placing the passed annotation in the center of the I quadrant
func centerTop(annotation: SpecialAnnot){
//Find a 4th of the span (The horizontal and vertical span representing the amount of map to display.)
let latCorrection = region.span.latitudeDelta/4 + region.span.latitudeDelta/8
self.region = MKCoordinateRegion(center: .init(latitude: annotation.coordinate.latitude - latCorrection, longitude: annotation.coordinate.longitude), span: region.span)
}
}
struct QuarterCenteredView_Previews: PreviewProvider {
static var previews: some View {
QuarterCenteredView()
}
}

You could resize the map frame to that square then center the coordinate. This would mean the view would no longer pop op over the mapview though it would appear to.
this can be done with:
(your MKMapView).frame = CGRect(x: , y: , width: , height: )
x and y are start coordinates in the superview (the view behind the mapview) coordinate system. Then the width and height parameters creates a rectangle with those dimensions starting from x and y. Be careful with the start point, it's sometimes in the lowerleft and sometimes in the upper left depending on the situation. Depending on your implementation you might have to modify the popup views frame too.
-if your map is always connected to the top of the screen you could use
(your MKMapView).frame = CGRect(x: self.view.origin.x, y: self.view.origin.y, width: self.view.frame.width, height: self.view.frame.height/2)

Related

How Do I Draw a String in an MKOverlayRender

The use case I have is one where I want to draw and label counties in a state. Annotations don't seem like the right approach to solve this problem. First of all, the label refers to region rather than a point. Second, there are far too many; so, I would have to selectively show and hide annotations based on zoom level (actually something more like the size of the MKCoordinateRegion span). Lastly, county labels are not all that relevant unless the user starts zooming in.
Just as a side note, county boundaries may be present in map tiles, but they are not emphasized. Moreover, there are a multitude of other boundaries I might want to draw that are completely absent from map tiles.
Ultimately, what I want to do is create an overlay for each county shape (counties are clickable and I can navigate to details) and another set of overlays for the labels. I separate county shapes and labels because county shapes are messy and I just use the center of the county. There is no guarantee with this approach that labels will not draw outside of county shapes, which means labels could end up getting clipped when other counties are drawn.
Drawing the county shapes was relatively easy or at least relatively well documented. I do not include any code on rendering shapes. Drawing text on the other hand is not straight forward, not well documented, and most of the posts on the subject are ancient. The lack of recent posts on the subject as well as the fact that most posts posit solutions that no longer work, use deprecated APIs, or only solve a part of the problem motivates this post. Of course, the lack of activity on this problem could be because my strategy is mind numbingly stupid.
I have posted a complete solution to the problem. If you can improve on the solution below or believe there is a better way, I would appreciate the feedback. Alternatively, if you are trying to find a solution to this problem, you will find this post more helpful than the dozens I have looked at, which on the whole got me to where I am now.
Below is a complete solution that can be run in an Xcode single view Playground. I am running Xcode 14.2. The most important bit of code is the overridden draw function of LabelOverlayRenderer. That bit of code is what I struggled to craft for more than a day. I almost gave up. Another key point is when drawing text, one uses CoreText. The APIs pertaining to drawing and managing text are many and most have had a lot of name changes and deprecation.
import UIKit
import MapKit
import SwiftUI
class LabelOverlayRenderer: MKOverlayRenderer {
let title: String
let center: CLLocationCoordinate2D
init(overlay: LabelOverlay) {
center = overlay.coordinate
title = overlay.title!
super.init(overlay: overlay)
}
override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {
context.saveGState()
// Set Drawing mode
context.setTextDrawingMode(.fillStroke)
// If I don't do this, the text is upside down.
context.textMatrix = CGAffineTransformMakeScale(1.0, -1.0);
// Text size is crazy big because label has to be miles across
// to be visible.
var attrs = [ NSAttributedString.Key : Any]()
attrs[NSAttributedString.Key.font] = UIFont(name: "Helvetica", size: 128000.0)!
attrs[NSAttributedString.Key.foregroundColor] = UIColor(Color.red)
let attributedString = NSAttributedString(string: title, attributes: attrs)
let line = CTLineCreateWithAttributedString(attributedString)
// Get the size of the whole string, so the string can
// be centered. CGSize is huge because I don't want
// to clip or wrap the string. The range setting
// is just cut and paste. Looks like a place holder.
// Ideally, it is the range of that portion
// of the string for which I want the size.
let frameSetter = CTFramesetterCreateWithAttributedString(attributedString)
let size = CTFramesetterSuggestFrameSizeWithConstraints(frameSetter, CFRangeMake(0, 0), nil, CGSize(width: 1000000, height: 1000000), nil)
// Center is lat-lon, but map is in meters (maybe? definitely
// not lat-lon). Center string and draw.
var p = point(for: MKMapPoint(center))
p.x -= size.width/2
p.y += size.height/2
// There is no "at" on CTLineDraw. The string
// is positioned in the context.
context.textPosition = p
CTLineDraw(line, context)
context.restoreGState()
}
}
class LabelOverlay: NSObject, MKOverlay {
let title: String?
let coordinate: CLLocationCoordinate2D
let boundingMapRect: MKMapRect
init(title: String, coordinate: CLLocationCoordinate2D, boundingMapRect: MKMapRect) {
self.title = title
self.coordinate = coordinate
self.boundingMapRect = boundingMapRect
}
}
class MapViewCoordinator: NSObject, MKMapViewDelegate {
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let overlay = overlay as? LabelOverlay {
return LabelOverlayRenderer(overlay: overlay)
}
fatalError("Unknown overlay type!")
}
}
struct MyMapView: UIViewRepresentable {
func makeCoordinator() -> MapViewCoordinator {
return MapViewCoordinator()
}
func updateUIView(_ view: MKMapView, context: Context){
// Center on Georgia
let center = CLLocationCoordinate2D(latitude: 32.6793, longitude: -83.62245)
let span = MKCoordinateSpan(latitudeDelta: 4.875, longitudeDelta: 5.0003)
let region = MKCoordinateRegion(center: center, span: span)
view.setRegion(region, animated: true)
view.delegate = context.coordinator
let coordinate = CLLocationCoordinate2D(latitude: 32.845084, longitude: -84.3742)
let mapRect = MKMapRect(x: 70948460.0, y: 107063759.0, width: 561477.0, height: 613908.0)
let overlay = LabelOverlay(title: "Hello World!", coordinate: coordinate, boundingMapRect: mapRect)
view.addOverlay(overlay)
}
func makeUIView(context: Context) -> MKMapView {
// Create a map with constrained zoom gestures only
let mapView = MKMapView(frame: .zero)
mapView.isPitchEnabled = false
mapView.isRotateEnabled = false
let zoomRange = MKMapView.CameraZoomRange(
minCenterCoordinateDistance: 160000,
maxCenterCoordinateDistance: 1400000
)
mapView.cameraZoomRange = zoomRange
return mapView
}
}
struct ContentView: View {
var body: some View {
VStack {
MyMapView()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Why region is changed when I am setting current region?

I have put a button so that add a circle overlay to given point. However I don't know why but while region didn't change my overlay could not be seen.
I couldn't find a func that refresh or reload map. So finally I decided to change map region so slightly that user will not be disturbed. (A little bit zoom out for example).
self.mapView.setRegion(mapView.region, animated: true)
I expect that above code do not change the map region however it does, and also I tried this,
self.mapView.setRegion(MKCoordinateRegion(mapView.visibleMapRect), animated: true)
This also changed the map's region.
What can I do ?
And This is how I add my overlays
func addCircles() {
let center = self.myPinView.center
let origin = self.mapView.convert(center, toCoordinateFrom: mapView)
let overlay1 = MKCircle(center: origin, radius: 3)
let overlay2 = MKCircle(center: origin, radius: 7.5)
let overlay3 = MKCircle(center: origin, radius: 15)
self.mapView.addOverlay(overlay1)
self.mapView.addOverlay(overlay2)
self.mapView.addOverlay(overlay3)
}
And this is my delegate func
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if overlay is MKCircle {
let circle = MKCircleRenderer(overlay: overlay)
circle.fillColor = circle.fillColor = UIColor(red: 255, green: 0, blue: 0, alpha: 0.6)
circle.strokeColor = .red
return circle
} else {
return MKOverlayRenderer()
}
}
Try to encapsulate them in a dsipatchQueue.main
DispatchQueue.main.async {
self.mapView.setRegion(mapView.region, animated: true)
}
Actually I solve my spesific problem with a different view. My initial problem was, after user pick a position I wanted to draw a circle. However my overlays don't appear until the map region changed. I was trying to draw this circles immediately. And the most user friendly solution that I found, set mapView center to overlay's center.
self.mapView.setCenter(origin, animated: false)
In this way, after user pick a position, immediately map focus on this position and overlays are displayed.

How to Place Bottom Of a Custom Annotation On a Specific Coordinate in Mapbox iOS

I'm using iOS Mapbox SDK in my app. I changed image for an annotation to a custom image (It looks like a map marker). When I add an annotation to a specific coordinate on the map view, It will be added but the center of my custom annotation image (the marker) will be set on the coordinate. I need to change the marker position to set the bottom of the marker on the coordinate. I found a way but I do not know is there a better way or not?
I converted the coordinate to a point, then changed the point y position, then converted the point to a new coordinate.
func mapView(_ mapView: MGLMapView, imageFor annotation: MGLAnnotation) -> MGLAnnotationImage? {
let reuseIdentifier = "annotationImage"
var annotationImage = mapView.dequeueReusableAnnotationImage(withIdentifier: reuseIdentifier)
if annotationImage == nil {
annotationImage = MGLAnnotationImage(image: UIImage(named: "Orange")!, reuseIdentifier: reuseIdentifier)
}
return annotationImage
}
func addDestinationMarker(coordinate: CLLocationCoordinate2D) {
guard let mapView = mapView else { return }
if let annotations = mapView.annotations {
mapView.removeAnnotations(annotations)
}
var point = mapView.convert(coordinate, toPointTo: mapView)
point.y -= markerImageView.frame.height / 2
let newCoordinate = mapView.convert(point, toCoordinateFrom: mapView)
let annotation = MGLPointAnnotation()
annotation.coordinate = newCoordinate
mapView.addAnnotation(annotation)
}
I've run into this same issue and started to think that round map pins were becoming the defacto standard so they could just be plonked onto the map with the image centre denoting the coordinate. However if you take a look at this example on the Mapbox website, they use a non-round image and solve the offset problem quite nicely.
// The anchor point of an annotation is currently always the center. To
// shift the anchor point to the bottom of the annotation, the image
// asset includes transparent bottom padding equal to the original image
// height.
//
// To make this padding non-interactive, we create another image object
// with a custom alignment rect that excludes the padding.
image = image.withAlignmentRectInsets(UIEdgeInsets(top: 0, left: 0, bottom: image.size.height/2, right: 0))
This does mean that you need to generate pin images that are twice as tall, with the lower half transparent, but that's really not a big deal.
You can solve this by leveraging the centerOffset property that MGLAnnotationView provides. Though I'm not sure if it's present in the MGLAnnotationImage you're using.
To set the anchor to the bottom of the annotation, use:
centerOffset.dy = -height / 2
If you set the frame beforehand, the height is simply frame.height.
The other answers express things correctly but both are missing the correct syntax:
annotationView?.centerOffset.y = -(annotationView?.frame.height ?? 0) / 2
This will achieve the expected result.

Google maps w/ clustering. Check whether a marker has already been rendered

I use Google Maps ios utils clustering and have set up a custom iconView for the marker & cluster like this:
func renderer(_ renderer: GMUClusterRenderer, willRenderMarker marker: GMSMarker) {
// Check if marker or cluster
if marker.userData is PlaceMarker {
if let userData = marker.userData as? PlaceMarker {
marker.iconView = MarkerView(caption: userData.caption)
}
marker.groundAnchor = CGPoint(x: 0.5, y: 1)
marker.isFlat = true
marker.appearAnimation = kGMSMarkerAnimationPop
} else {
// Apply custom view for cluster
marker.iconView = ClusterViewIcon(caption: userData.caption)
// Show clusters above markers
marker.zIndex = 1000;
marker.groundAnchor = CGPoint(x: 0.5, y: 1)
marker.isFlat = true
marker.appearAnimation = kGMSMarkerAnimationPop
}
}
func renderer(_ renderer: GMUClusterRenderer, willRenderMarker marker: GMSMarker) { }
get's called every time there was a zoom level change even if no clustering / declustering happened, and makrer.iconView is always nil there even if it was set up before.
How can one implement a guard to only setup iconView and other marker properties only when the marker is rendered for the first time? otherwise it is just a waste of resources.. (and also the animation happens on every zoom level change)
EDIT: One way I can think of it is to store already rendered markers id in an array and check against it.. but that's a dirty way..
Reference: https://github.com/googlemaps/google-maps-ios-utils/issues/96

MKMapView setRegion animated does not show animation

I have a MKMapView with annotation pins. When the view was loaded the nearest pin gets searched and the map will get zoomed so it shows both, the user's location and the nearest pin. I do that with [map setRegion:region animated:YES];. Everything works fine up to here. The same method is also called by tapping on a button which locates the user and then does exactly what I just described.
I also have a search field with which the user can search for map points. When the user taps on one of the search results the map sets the region so the searched pin is in the middle.
Now, there's something strange with that. I also set this region animated, at least I do the same command as above. But if the map point is too far away from the current visible part of the map it doesn't show the animation when changing the region.
Am I missing something? I've already had a look at Apples docs, they don't mention anything regarding any maximum distance for animations.
I'm looking forward to any help!
Update 1:
Just tested it again in the Simulator. An interesting fact is, that when I search for a MapPoint for the first time and then select a search result it doesn't animate. If I perform another search just after the first one and select a result it does animate. As soon as I tap on the locate button which brings the user back to his location and the closest point it doesn't animate for this setRegion: and the first search after that. But only in the Simulator, on my 4S it does exactly what I've described in the original question above.
Update 2:
In the comments I was asked to provide example coordinates.
So here the coordinates for the first step (searching of the own location and the nearest pin):
My Location: 47.227131 / 8.264251
Nearest pin: 47.251347 / 8.357191
The distance between them is about 22 kilometers. The center of the map is the center between the two pins. The distance from the center to the screen border is 1.5 times the distance between the two points which means about 33 kilometers in this case.
And here a set of coordinates for the second step (searching a map point and selecting it):
Searched pin: 46.790680 / 9.818824
The distance to the screen border is here fixed to 500 meters.
I've tested this issue with a simple demo application on iOS 6 and iOS 7 beta. It turns out that the map view actually not always animates the transition between regions. It depends on how far the regions lay apart. For example a transition from Paris to London is not animated. But if you first zoom out a little bit and then go to London it will be animated.
The documentation says:
animated: Specify YES if you want the map view to animate the
transition to the new region or NO if you want the map to center on
the specified region immediately.
But as we've seen, we can not rely on the animation. We can only tell the map view that the transition should be animated. MapKit decides whether an animation is appropriate. It tells the delegate if the transition will be animated in -(void)mapView:(MKMapView *)mapView regionWillChangeAnimated:(BOOL)animated.
In order to consistently animate the region change in all cases you will need to animate to a intermediate region first. Let A be the current map region and B the target region. If there is an intersection between the regions you can transition directly. (Transform the MKCoordinateRegion to an MKMapRect and use MKMapRectIntersection to find the intersection). If there is no intersection, calculate a region C that spans both regions (use MKMapRectUnion and MKCoordinateRegionForMapRect). Then first go to to region C and in regionDidChangeAnimated go to region B.
Sample code:
MKCoordinateRegion region = _destinationRegion;
MKMapRect rect = MKMapRectForCoordinateRegion(_destinationRegion);
MKMapRect intersection = MKMapRectIntersection(rect, _mapView.visibleMapRect);
if (MKMapRectIsNull(intersection)) {
rect = MKMapRectUnion(rect, _mapView.visibleMapRect);
region = MKCoordinateRegionForMapRect(rect);
_intermediateAnimation = YES;
}
[_mapView setRegion:region animated:YES];
-(void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated
{
if (_intermediateAnimation) {
_intermediateAnimation = NO;
[_mapView setRegion:_destinationRegion animated:YES];
}
}
This helper method is taken from here
MKMapRect MKMapRectForCoordinateRegion(MKCoordinateRegion region)
{
MKMapPoint a = MKMapPointForCoordinate(CLLocationCoordinate2DMake(
region.center.latitude + region.span.latitudeDelta / 2,
region.center.longitude - region.span.longitudeDelta / 2));
MKMapPoint b = MKMapPointForCoordinate(CLLocationCoordinate2DMake(
region.center.latitude - region.span.latitudeDelta / 2,
region.center.longitude + region.span.longitudeDelta / 2));
return MKMapRectMake(MIN(a.x,b.x), MIN(a.y,b.y), ABS(a.x-b.x), ABS(a.y-b.y));
}
The WWDC 2013 session 309 Putting Map Kit in Perspective explains how to do such complex transitions in iOS 7.
Here are the functions by #Felix rewritten to Swift 4:
// MARK: - MapView help properties
var destinationRegion: MKCoordinateRegion?
var intermediateAnimation = false
func center() {
// Center map
var initialCoordinates = CLLocationCoordinate2D(latitude: 49.195061, longitude: 16.606836)
var regionRadius: CLLocationDistance = 5000000
destinationRegion = MKCoordinateRegionMakeWithDistance(initialCoordinates, regionRadius * 2.0, regionRadius * 2.0)
centreMap(on: destinationRegion!)
}
private func mapRect(forCoordinateRegion region: MKCoordinateRegion) -> MKMapRect {
let topLeft = CLLocationCoordinate2D(latitude: region.center.latitude + (region.span.latitudeDelta/2), longitude: region.center.longitude - (region.span.longitudeDelta/2))
let bottomRight = CLLocationCoordinate2D(latitude: region.center.latitude - (region.span.latitudeDelta/2), longitude: region.center.longitude + (region.span.longitudeDelta/2))
let a = MKMapPointForCoordinate(topLeft)
let b = MKMapPointForCoordinate(bottomRight)
return MKMapRect(origin: MKMapPoint(x:min(a.x,b.x), y:min(a.y,b.y)), size: MKMapSize(width: abs(a.x-b.x), height: abs(a.y-b.y)))
}
func centreMap(on region: MKCoordinateRegion) {
var region = region
var rect = mapRect(forCoordinateRegion: region)
let intersection = MKMapRectIntersection(rect, mapView.visibleMapRect)
if MKMapRectIsNull(intersection) {
rect = MKMapRectUnion(rect, mapView.visibleMapRect)
region = MKCoordinateRegionForMapRect(rect)
intermediateAnimation = true
}
mapView.setRegion(region, animated: true)
}
// MARK: - MKMapViewDelegate
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
if intermediateAnimation, let region = destinationRegion {
intermediateAnimation = false
mapView.setRegion(region, animated: true)
}
}
I also had this problem where it would not always animate, sometimes it would just jump and dissolve instead. However, I noticed that if you animate the camera instead of the region, it consistently animates.
But using the camera, you have to set the eye distance/altitude instead of the lat/lon span. I have a simple calculation for that below which is rough, it basically just sets the altitude (in meters) to the same number of meters as the longitude span of the region. If you wanted exact accuracy you'd have to figure out the number of meters per degree for the region's latitude, which changes slightly because the earth is not a perfect sphere. Of course you could multiply that value to widen or narrow the view to taste.
Swift 4.1 example code:
/* -------------------------------------------------------------------------- */
func animateToMapRegion(_ region: MKCoordinateRegion) {
// Quick and dirty calculation of altitude necessary to show region.
// 111 kilometers per degree longitude.
let metersPerDegree: Double = 111 * 1_000
let altitude = region.span.longitudeDelta * metersPerDegree
let camera = MKMapCamera(lookingAtCenter: region.center, fromEyeCoordinate: region.center, eyeAltitude: altitude)
self.mapView.setCamera(camera, animated: true)
}
You simply have to get your current location and then call this function:
◙ import MapKit
◙ var appleMapView = MKMapView()
◙ var currentCoordinate: CLLocationCoordinate2D?
currentCoordinate must be your current location coordinates:
◙ if let currentLoc = self.currentCoordinate {
let center = CLLocationCoordinate2D(latitude: currentLoc.latitude, longitude: currentLoc.longitude)
let region = MKCoordinateRegion(center: center, span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))
appleMapView.setRegion(region, animated: true)
}
To anyone who has the same question and is using Swift and is using a tableView:
I called setRegion after dismissing the tableView, and it did not show animation. This is my code before editing:
dismiss(animated: true, completion: nil)
a function that calls setRegion
Then I changed it to:
dismiss(animated: true, completion: {
a function that calls setRegion
})
This time it works.