Search code examples
swiftswiftuimapkitmkmapviewmkannotation

Get Map Annotations in center of screen Swift UI


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

Solution

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