I have custom map annotations that will display a label when they are positioned near the center of the screen. I am not sure how to calculate the physical position of the map annotations on the map. Doing this with a scroll view and geometry reader is quite simple, but how can this be done with a Map View?
I tried to use this SO solution but it didn't work as I would need to use a MKMapView view controller. And I was not able to find a way to use custom annotations with a MKMapView view controller. Additionally I would like to calculate the zoom or scale of the map to modify the size of the annotations. Any pointers would be greatly appreciated.
Copy Paste-able code
import SwiftUI
import MapKit
struct LocationsView: View {
@EnvironmentObject private var vm: LocationsViewModel
@State var scale: CGFloat = 0.0
var body: some View {
ZStack {
mapLayer.ignoresSafeArea()
}
}
}
// --- WARNINGS HERE --
extension LocationsView {
private var mapLayer: some View {
Map(coordinateRegion: $vm.mapRegion,
annotationItems: vm.locations,
annotationContent: { location in
MapAnnotation(coordinate: location.coordinates) {
//my custom map annotations
Circle().foregroundStyle(.red).frame(width: 50, height: 50)
.onTapGesture {
vm.showNextLocation(location: location)
}
}
})
}
}
class LocationsViewModel: ObservableObject {
@Published var locations: [LocationMap] // All loaded locations
@Published var mapLocation: LocationMap { // Current location on map
didSet {
updateMapRegion(location: mapLocation)
}
}
@Published var mapRegion: MKCoordinateRegion = MKCoordinateRegion() // Current region on map
let mapSpan = MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
init() {
let locations = LocationsDataService.locations
self.locations = locations
self.mapLocation = locations.first!
self.updateMapRegion(location: locations.first!)
}
private func updateMapRegion(location: LocationMap) {
withAnimation(.easeInOut) {
mapRegion = MKCoordinateRegion(
center: location.coordinates,
span: mapSpan)
}
}
func showNextLocation(location: LocationMap) {
withAnimation(.easeInOut) {
mapLocation = location
}
}
}
struct LocationMap: Identifiable {
var id: String = UUID().uuidString
let name: String
let cityName: String
let coordinates: CLLocationCoordinate2D
}
class LocationsDataService {
static let locations: [LocationMap] = [
LocationMap(name: "Colosseum", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8902, longitude: 12.4922)),
LocationMap(name: "Pantheon", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8986, longitude: 12.4769)),
LocationMap(name: "Trevi Fountain", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.9009, longitude: 12.4833))
]
}
struct SwiftfulMapAppApp: View {
@StateObject private var vm = LocationsViewModel()
var body: some View {
VStack {
LocationsView().environmentObject(vm)
}
}
}
#Preview(body: {
SwiftfulMapAppApp()
})
You should migrate to the new Map
APIs added in iOS 17.
First, change mapRegion
to a MapCameraPosition
:
@Published var mapCameraPosition = MapCameraPosition.automatic
so that you can do Map(position: $vm.mapCameraPosition) { ... }
You can set this to a MKCoordinateRegion
like this (in updateMapRegion
):
withAnimation(.easeInOut) {
mapCameraPosition = .region(MKCoordinateRegion(
center: location.coordinates,
span: mapSpan))
}
Second, I would add a new property in LocationMap
to indicate whether its label should be shown.
struct LocationMap: Identifiable {
let id: String = UUID().uuidString
let name: String
let cityName: String
let coordinates: CLLocationCoordinate2D
var shouldShowName = false // <---
}
This new property can then be set in onMapCameraChange
:
private var mapLayer: some View {
MapReader { mapProxy in
Map(position: $vm.mapCameraPosition) {
ForEach(vm.locations) { location in
Annotation(
location.shouldShowName ? location.name : "",
coordinate: location.coordinates) {
Circle().foregroundStyle(.red).frame(width: 50, height: 50)
.onTapGesture {
vm.showNextLocation(location: location)
}
}
}
}
.onMapCameraChange(frequency: .continuous) { context in
guard let center = mapProxy.convert(context.region.center, to: .local) else { return }
for i in vm.locations.indices {
if let point = mapProxy.convert(vm.locations[i].coordinates, to: .local) {
// the label should be shown when the annotation is within 50 points from the centre of the map
vm.locations[i].shouldShowName = abs(point.x - center.x) < 50 && abs(point.y - center.y) < 50
} else {
vm.locations[i].shouldShowName = false
}
}
}
}
}
Note that I am passing location.shouldShowName ? location.name : ""
as the label of the annotation. MapKit will automatically decide where and when to show this label, in addition to your own logic. If this is undesirable, build your own label e.g. as an overlay of the circle.
Full code:
struct LocationsView: View {
@EnvironmentObject private var vm: LocationsViewModel
var body: some View {
ZStack {
mapLayer.ignoresSafeArea()
}
}
private var mapLayer: some View {
MapReader { mapProxy in
Map(position: $vm.mapCameraPosition) {
ForEach(vm.locations) { location in
Annotation(
location.shouldShowName ? location.name : "",
coordinate: location.coordinates) {
Circle().foregroundStyle(.red).frame(width: 50, height: 50)
.onTapGesture {
vm.showNextLocation(location: location)
}
}
}
}
.onMapCameraChange(frequency: .continuous) { context in
guard let center = mapProxy.convert(context.region.center, to: .local) else { return }
for i in vm.locations.indices {
if let point = mapProxy.convert(vm.locations[i].coordinates, to: .local) {
vm.locations[i].shouldShowName = abs(point.x - center.x) < 250 && abs(point.y - center.y) < 250
} else {
vm.locations[i].shouldShowName = false
}
}
}
}
}
}
class LocationsViewModel: ObservableObject {
@Published var locations: [LocationMap] // All loaded locations
@Published var mapLocation: LocationMap { // Current location on map
didSet {
updateMapRegion(location: mapLocation)
}
}
@Published var mapCameraPosition = MapCameraPosition.automatic
let mapSpan = MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
init() {
let locations = LocationsDataService.locations
self.locations = locations
self.mapLocation = locations.first!
self.updateMapRegion(location: locations.first!)
}
private func updateMapRegion(location: LocationMap) {
withAnimation(.easeInOut) {
mapCameraPosition = .region(MKCoordinateRegion(
center: location.coordinates,
span: mapSpan))
}
}
func showNextLocation(location: LocationMap) {
withAnimation(.easeInOut) {
mapLocation = location
}
}
}
struct LocationMap: Identifiable {
let id: String = UUID().uuidString
let name: String
let cityName: String
let coordinates: CLLocationCoordinate2D
var shouldShowName = false
}
class LocationsDataService {
static let locations: [LocationMap] = [
LocationMap(name: "Colosseum", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8902, longitude: 12.4922)),
LocationMap(name: "Pantheon", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.8986, longitude: 12.4769)),
LocationMap(name: "Trevi Fountain", cityName: "Rome", coordinates: CLLocationCoordinate2D(latitude: 41.9009, longitude: 12.4833))
]
}
struct SwiftfulMapAppApp: View {
@StateObject private var vm = LocationsViewModel()
var body: some View {
VStack {
LocationsView().environmentObject(vm)
}
}
}