While working on an iOS app that uses Apple Maps, I was surprised to discover that there’s no built-in option to prevent map annotations (markers) from rotating with the map, nor is there a standard way to keep the user’s location and heading visible at all times.
This short blog post demonstrates a practical approach to always displaying the user’s position and showing device rotation alongside it.
import SwiftUI
import SwiftData
import MapKit
import CoreLocation
struct AppleMapDemoView: View {
@State private var mapCameraPosition: MapCameraPosition = .userLocation(fallback: .automatic)
@State private var model = AppleMapDemoViewModel()
@State private var cameraHeading: Double = 0
var body: some View {
VStack {
Map(position: $mapCameraPosition) {
// User Annotation is empty to prevent the standard user marker from being displayed
UserAnnotation { }
if let userLocation = model.deviceLocation?.coordinate {
Annotation("User", coordinate: userLocation) {
Image(systemName: "arrow.up")
.rotationEffect(.degrees((model.deviceHeading?.trueHeading ?? 0.0) - cameraHeading))
}
}
}
.onMapCameraChange(frequency: .continuous) { context in
cameraHeading = context.camera.heading
}
.mapStyle(.standard)
}
.onAppear {
model.start()
}
.onDisappear {
model.stop()
}
}
}
@Observable
class AppleMapDemoViewModel: NSObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
private(set) var state: State = .notInitialized
private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined
private(set) var deviceHeading: CLHeading?
private(set) var deviceLocation: CLLocation?
override init() {
super.init()
locationManager.delegate = self
}
func start() {
locationManager.requestWhenInUseAuthorization()
state = .authorizationRequested
}
private func startLocationUpdates() {
state = .active
locationManager.startUpdatingLocation()
locationManager.startUpdatingHeading()
}
func stop() {
locationManager.stopUpdatingLocation()
locationManager.stopUpdatingHeading()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.first {
deviceLocation = location
}
}
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
deviceHeading = newHeading
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
self.authorizationStatus = status
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
startLocationUpdates()
break
default:
state = .authorizationDenied
break
}
}
enum State {
case notInitialized
case authorizationRequested
case active
case authorizationDenied
}
}
How It Works
In this example, the ViewModel uses CLLocationManager to receive updates about the device’s location and heading.
We also track changes to the map camera and store the camera’s heading angle.
The View then displays the user’s marker (represented here as an arrow) and updates its rotation by combining the map’s rotation with the device’s heading, ensuring the marker always points in the correct direction.
To prevent annotations (map markers) from rotating with the map, simply omit the device heading from this calculation.
Note: This is a simplified example. In real world:
- Location authorization should be managed in a dedicated manager.
- The UI should provide feedback if authorization is denied.
- Screen orientation (portrait top/bottom, landscape left/right) should also be taken into account.
- Etc.
Technologies Used: iOS, Swift, SwiftUI, Apple Maps