How Do I Draw a String in an MKOverlayRender - swift

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()
}
}

Related

Adding over 14000 apple MapKit multipolyline overlays as MKOverlays crashes app

I am trying to add an array of multipolylines as MKOverlay to a map view as show in code below:
import SwiftUI
import MapKit
struct PolylineMapView: UIViewRepresentable {
func makeCoordinator() -> MapViewCoordinator{
return MapViewCoordinator(self)
}
func updateUIView(_ view: MKMapView, context: Context){
view.mapType = MKMapType.standard
}
func makeUIView(context: Context) -> MKMapView{
let view = MKMapView(frame: .zero)
view.delegate = context.coordinator
view.showsUserLocation = true
view.showsScale = true
view.showsCompass = true
view.setUserTrackingMode(MKUserTrackingMode.followWithHeading, animated: true)
let overlays: [MKOverlay] = [MKOverlay]() // this overlay will contain 27,000 overlays
view.addOverlays(overlays)
return view
}
}
// MARK: - Coordinator
class MapViewCoordinator: NSObject, MKMapViewDelegate {
var parent: PolylineMapView
init(_ parent: PolylineMapView) {
self.parent = parent
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let multiPolyline = overlay as? MKMultiPolyline{
let polylineRenderer = MKMultiPolylineRenderer(multiPolyline: multiPolyline)
polylineRenderer.strokeColor = .magenta
polylineRenderer.lineWidth = 2
polylineRenderer.shouldRasterize = true
return polylineRenderer
}
return MKOverlayRenderer(overlay: overlay)
}
}
The issue I face while adding the huge number of overlays at a time is that the app runs out of memory and crashes.
My question is how can I add the overlays only in the mapRect that is visible in order to avoid adding all at once? What is the best way to handle this? It would also be interesting to know how to only render the overlays at a certain zoom level on the map.
Adding over 14000 apple MapKit - You are going beyond limit. There is limit set to everything.
To avoid the issue, do below steps.
Show only overlay which are visible to the current region but not to exceed 50. If they are more then 50, show note on the map that "Zoom in to see more properties"
Once user zoom in, based on the region of the visible map, repeat step 1.
Check Zillow app for the same.
https://apps.apple.com/us/app/zillow-real-estate-rentals/id310738695
Only add overlays that make sense at the moment:
MKOverlay has a method that tells you if it intersects with a MKMapRect.
let isVisible = overlay.intersects(mapView.boundingMapRect)
Then add only those overlays that intersect.
If there are still too many overlays left, you can use.
let visible = mapView.boundingMapRect.contains(overly.boundingMapRect)
If you zoom out, you might have to choose which are the most important polylines and show only those.
If this works: write a diff algorithm:
It is probably a bad idea to remove all previous overlays and add all new ones after the region of the mapView changes.
Instead, remove all overlays from mapView that aren't shown any more, add the new overlays, and don't change the overlays that are part of the old and new mapView region.
One more idea: you could merge multiple MKMultiPolyline objects that are near each other (and share the same color...) into one MKMultiPolyline. This can be combined with the idea above.

Centering a map annotation to the top quarter of the screen

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)

custom overlay(renderer) is getting cut of by map tiles (in some cases)

I wrote a custom renderer to represent a min and a max Radius. In some cases the renderer is not working as expected. It looks like the overlay is getting cut of by the map tiles.
See the full video
Here is how I did it. Did I miss something?
class RadiusOverlayRenderer: MKOverlayRenderer {
override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {
guard let overlay = self.overlay as? RadiusOverlay else {
return
}
let maxRadiusRect = self.rect(for: overlay.boundingMapRect)
.offsetBy(
dx: CGFloat(-overlay.boundingMapRect.height)/2,
dy: CGFloat(-overlay.boundingMapRect.width)/2
)
let minRadiusRect = CGRect(
x: Double(maxRadiusRect.midX)-overlay.minRadRect.width/2,
y: Double(maxRadiusRect.midY)-overlay.minRadRect.height/2,
width: overlay.minRadRect.width,
height: overlay.minRadRect.height)
let aPath = CGMutablePath()
aPath.addEllipse(in: maxRadiusRect)
aPath.addEllipse(in: minRadiusRect)
aPath.closeSubpath()
context.setFillColor(overlay.color.cgColor)
context.setAlpha(overlay.alpha)
context.addPath(aPath)
context.drawPath(using: .eoFillStroke)
}
}
Notice that only the upper left parts are clipped?
with .offsetBy you are drawing outside of the .boundingMapRect.
Remove the .offsetBy...
If you want to draw your circle at a different place, then adjust coordinate and / or boundingMapRect of your MKOverlay.

How can I draw two MKPolygons on MapView without having them connect?

For some reason, when I try to draw two MKPolygons on a mapView (MKMapView) I end up with the two polygons connected. Drawing each polygon individually works fine. And I've verified that each of the polygons don't contain any of the coordinates to form the connection between the two. I've attached an image with the two polygons connected
For reference, here's where I call to add the polygons.
func addPeakTimePolygon(from coordinatesArray: [CLLocationCoordinate2D], title: Int){
let polygon = MKPolygon(coordinates: coordinatesArray, count: coordinatesArray.count)
polygon.title = String(title)
//Should refactor to use .contains(where:
var shouldAdd = true
for polygon in self.currentPolygons{
if polygon.title == String(title){
shouldAdd = false
}
}
if shouldAdd{
self.currentPolygons.append(polygon)
self.mapView.add(polygon)
}
}
And here's my rendererFor code:
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if overlay is MKPolyline {
let renderer = MKPolylineRenderer(overlay: overlay)
renderer.strokeColor = #colorLiteral(red: 0, green: 0.6862745098, blue: 0.7607843137, alpha: 1)
renderer.lineWidth = 5.0
return renderer
}
else if overlay is MKPolygon {
let renderer = MKPolygonRenderer(overlay: overlay)
renderer.fillColor = UIColor.red.withAlphaComponent(0.5)
renderer.strokeColor = UIColor.red
renderer.lineWidth = 2
return renderer
}
return MKOverlayRenderer()
}
It seems like you're making one overlay consisting of two polygons. You can't do that with an MKPolygonRenderer; you will get one polygon, as you are observing.
You will need separate overlays, one for each polygon. Unless you are using iOS 13! In that case, you are in luck: New in iOS 13, multiple polygons or polylines can be combined into an MKMultiPolygon or MKMultiPolyline and drawn by an MKMultiPolygonRenderer or MKMultiPolylineRenderer.
I forgot to check / post the code that was calling addPeakTimePolygon. Here is the problematic code below:
var locationList: [CLLocationCoordinate2D] = []
var title = 0
if let peakTimeCampaignList = data["PeakTimeRewardCampaignList"] as? [[AnyHashable:Any]]{
for campaign in peakTimeCampaignList{
if let polygonPoints = campaign["CampaignPolygon"] as? [[AnyHashable:Any]]{
for polygonPoint in polygonPoints{
let polygonPoint = CLLocationCoordinate2D(latitude: polygonPoint["Latitude"] as! CLLocationDegrees, longitude: polygonPoint["Longitude"] as! CLLocationDegrees)
locationList.append(polygonPoint)
}
}
if let id = campaign["Id"] as? Int{
title = id
}
mapBundle.addPeakTimePolygon(from: locationList, title: title)
}
}
As you can see locationList wasn't being cleared out within the loop, causing whatever we sent over to addPeakTimePolygon to have coordinates from two polygons and MapKit was trying it's best to form a polygon between them.
This was a dumb mistake, but hoping someone else sees this with the same problem!

Altering Existing MKOverlays - How to identify which to alter?

I am trying to change the color of an existing MKOverlay on a map.
I am add several MKPolygons as unique overlays to mapView. As they are rendered, the overlay's color is applied by the mapView rendererFor function.
Periodically I would like to change the color of an existing overlay; ideally without removingAll and re-adding. I have code that will handle the color change, but I do not know how to identify different overlays other than by their type (MKPolygon, MKCircle) - but all overlays in my situation are MKPolygon, so executing the code simply changes all.
I have tried converting the MKPolygon into an MKOverlay before using addOverlay and manipulating the title, but title is "get-only"
I have tried subclassing MKPolygon, but my reading says this is bad in Swift.
I add the overlay like this.
var shapeNW:MKMapPoint = MKMapPoint( CLLocationCoordinate2D(latitude: 48.313319, longitude: -124.109715))
var shapeNE:MKMapPoint = MKMapPoint( CLLocationCoordinate2D(latitude: 48.313312, longitude: -124.108695))
var shapeSW:MKMapPoint = MKMapPoint( CLLocationCoordinate2D(latitude: 48.312661, longitude: -124.109767))
var shapeSE:MKMapPoint = MKMapPoint( CLLocationCoordinate2D(latitude: 48.312626, longitude: -124.108679))
var myShapePoints:[MKMapPoint] = [shapeNW,shapeNE,shapeSE,shapeSW]
// addAnItemToMap(title: "Circle", locationName: "CircleName", type: "SS", coordinate: feiLocation, horizontalAccuracy: 20)
var myShape:MKPolygon = MKPolygon(points: myShapePoints, count: 4)
mapView.addOverlay(myShape)
Here is my function that I use to change the color after it is created. But obviously this function changes the color of all items as it cannot identify which overlay to change. This is my issue, I would like to identify the specific overlay and change just it.
func changeColor(identifier: String) {
let overlays = self.mapView.overlays
let duration:TimeInterval = 5.0
for overlay in overlays {
if identifier == "on"{
let renderer = mapView.renderer(for: overlay) as! MKPolygonRenderer
DispatchQueue.main.async{
renderer.alpha = 1.0
renderer.fillColor = UIColor.blue
}
}
else {
let renderer = mapView.renderer(for: overlay) as! MKPolygonRenderer
DispatchQueue.main.async{
renderer.alpha = 0.0
}
}
}
}
You can just subclass MKPolygon:
class CustomPolygon: MKPolygon {
var identifier: String? = nil
}
Then you can configure your renderer accordingly:
func configureColor(of renderer: MKPolygonRenderer, for overlay: MKOverlay) {
let baseColor: UIColor
if let polygon = overlay as? CustomPolygon, polygon.identifier == "on" {
baseColor = .cyan
} else {
baseColor = .red
}
renderer.strokeColor = baseColor.withAlphaComponent(0.75)
renderer.fillColor = baseColor.withAlphaComponent(0.25)
}
Then your main renderer(for:) can use that:
extension ViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
let renderer = MKPolygonRenderer(overlay: overlay)
renderer.lineWidth = 5
configureColor(of: renderer, for: overlay)
return renderer
}
}
As can your update routine can:
func updateColors() {
for overlay in mapView.overlays {
if let renderer = mapView.renderer(for: overlay) as? MKPolygonRenderer {
configureColor(of: renderer, for: overlay)
}
}
}
Personally, rather than having overlays whose identifier is not "on" have an alpha of 0, I just would remove those overlays from the map, and then you don’t have to deal with any of this.