Maps have been a core feature of iPhone since the very first device shipped way back in 2007, and the underlying framework has been available to developers for almost as long. Even better, Apple provides a SwiftUI Map
view that wraps up the underlying map framework beautifully, letting us place maps, annotations, and more alongside the rest of our SwiftUI view hierarchy.
Let’s start with something simple: just showing a map. Maps and all their configuration data come from a dedicated framework called MapKit, so our first step is to import that framework:
import MapKit
And now we can place a map in our SwiftUI view, with just this:
Map()
That's enough to show a map on the screen, so try running the app and take a moment to learn some key shortcuts in the simulator:
Once you're in control of the map, there are stacks of options to customize it further.
For example, you can use the mapStyle()
modifier to control how the map looks. You can get a satellite map like this:
Map()
.mapStyle(.imagery)
Or combine both satellite and street map like this:
Map()
.mapStyle(.hybrid)
Or you can get both maps along with realistic elevation, creating a 3D map, like this:
.mapStyle(.hybrid(elevation: .realistic))
You can adjust how the user can work with your map, such as whether they can zoom or rotate their position. For example, we could make a map that always remains centered on a particular location, but users can still adjust the rotation and zoom:
Map(interactionModes: [.rotate, .zoom])
Or we could specify no interaction modes, meaning that the map is always exactly fixed:
Map(interactionModes: [])
Those are all the easy customization options, but there are three that take a little more thinking: controlling the position, placing annotations, and handling taps.
First, you can customize the position of the camera. This can be done as an initial position, where you're setting how the map should start, or as a binding to its current position, which tracks its position over time.
For example, we could create a constant property storing the location of London, with a span specified as 1 degree by 1 degree:
let position = MapCameraPosition.region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275),
span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
)
)
We could then use that for the initial position of our map:
Map(initialPosition: position)
That value is only an initial position. If you want to change the position over time you'll need to mark it as @State
then pass it in as a binding.
So, first make it use @State
:
@State private var position = MapCameraPosition.region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275),
span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
)
)
Then pass it in as a binding:
Map(position: $position)
Now that's it's stored as program state, we can change it by adding some buttons to jump to other locations. For example, we could wrap the map in a VStack
, then place this below it:
HStack(spacing: 50) {
Button("Paris") {
position = MapCameraPosition.region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 48.8566, longitude: 2.3522),
span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
)
)
}
Button("Tokyo") {
position = MapCameraPosition.region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 35.6897, longitude: 139.6922),
span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
)
)
}
}
Although we're now passing a binding to the map, we can't just read the location as the map moves around. Instead, we have a separate onMapCameraChange()
modifier that tells us when the position changes, either immediately or once movement has ended.
For example, we could write get an update when they have finished dragging the map, then print it out:
Map(position: $position)
.onMapCameraChange { context in
print(context.region)
}
Alternatively, you can have it post continuous updates like this:
Map(position: $position)
.onMapCameraChange(frequency: .continuous) { context in
print(context.region)
}
You might think continuous mode is always preferable, but it's not that simple – if you're running a search on where the user has positioned the map, that's the kind of thing you'd want to do only when they have finished moving.
The second customizable thing I want to look at is placing annotations.
To do this takes at least three steps depending on your goal: defining a new data type that contains your location, creating an array of those containing all your locations, then adding them as annotations in the map. Whatever new data type you create to store locations, it must conform to the Identifiable
protocol so that SwiftUI can identify each map marker uniquely.
For example, we might start with this kind of Location
struct:
struct Location: Identifiable {
let id = UUID()
var name: String
var coordinate: CLLocationCoordinate2D
}
Now we can go ahead and define an array of locations, wherever we want map annotations to appear:
let locations = [
Location(name: "Buckingham Palace", coordinate: CLLocationCoordinate2D(latitude: 51.501, longitude: -0.141)),
Location(name: "Tower of London", coordinate: CLLocationCoordinate2D(latitude: 51.508, longitude: -0.076))
]
Step three is the important part: we can feed that array of locations into the Map
view as its content. SwiftUI provides us with a couple of different content types, but a simple one is called Marker
: a balloon with a title and latitude/longitude coordinate attached.
For example, we could place markers at both our locations like so:
Map {
ForEach(locations) { location in
Marker(location.name, coordinate: location.coordinate)
}
}
When that runs you’ll see two red balloons on the map, and even better you'll see the map adjusts its position and scale so the two markers are visible.
If you want more control over the way your markers look on the map, use an Annotation
instead. This lets you provide a completely custom view to use instead of the standard system marker balloon, and if you prefer you can hide the default title so you can replace it with your own, like this:
Annotation(location.name, coordinate: location.coordinate) {
Text(location.name)
.font(.headline)
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(.capsule)
}
.annotationTitles(.hidden)
And finally, you can handle taps on the map using onTapGesture()
. This tells us where on the map the user tapped, but it does so in screen coordinates – e.g., 50 points from the top, and 100 points from the left.
In order to get an actual location on the map, we need a special view called MapReader
. When you wrap one of these around your map, you'll be handed a special MapProxy
object that is able to convert screen locations to map locations and back the other way.
Use it like this:
MapReader { proxy in
Map()
.onTapGesture { position in
if let coordinate = proxy.convert(position, from: .local) {
print(coordinate)
}
}
}
Tip: The .local
part means we're converting that position in the map's local coordinate space, meaning that the tap location we're working with is relative to the top-left corner of the map rather than the whole screen or some other coordinate space.
SPONSORED Alex is the iOS & Mac developer’s ultimate AI assistant. It integrates with Xcode, offering a best-in-class Swift coding agent. Generate modern SwiftUI from images. Fast-apply suggestions from Claude 3.5 Sonnet, o3-mini, and DeepSeek R1. Autofix Swift 6 errors and warnings. And so much more. Start your 7-day free trial today!
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.