Maps with MapKit in SwiftUI in Swift, SwiftUI & iOS Apps
Building location-aware features into iOS apps is no longer niche—users expect maps, directions, and location search as baseline functionality. With SwiftUI's integration of MapKit, we can build sophisticated map interfaces without wrestling with UIView bridging or legacy MapKit patterns. In this guide, we'll walk through implementing maps with markers, controlling the camera, searching locations, and calculating directions—all the patterns you'll use in production applications.
Map view with markers and annotations
The foundation of any map feature is displaying a Map view with markers that represent points of interest. In SwiftUI, this is straightforward thanks to the native Map view introduced in iOS 17. Let's build a real-world scenario: displaying coffee shops on a map with custom annotations.
import SwiftUI
import MapKit
struct CoffeeShop: Identifiable {
let id = UUID()
let name: String
let latitude: Double
let longitude: Double
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
}
struct CoffeeMapView: View {
let coffeeShops = [
CoffeeShop(name: "Brew Haven", latitude: 40.7128, longitude: -74.0060),
CoffeeShop(name: "Espresso Corner", latitude: 40.7260, longitude: -74.0050),
CoffeeShop(name: "Daily Grind", latitude: 40.7489, longitude: -73.9680)
]
var body: some View {
Map {
ForEach(coffeeShops) { shop in
Marker(shop.name, coordinate: shop.coordinate)
.tag(shop.id)
}
}
.mapStyle(.standard)
}
}
The Map initializer with a closure lets us add markers declaratively. Each Marker requires a label and coordinate. For more control over appearance, use Annotation instead of Marker:
Map {
ForEach(coffeeShops) { shop in
Annotation("", coordinate: shop.coordinate) {
VStack(spacing: 4) {
Image(systemName: "cup.and.saucer.fill")
.font(.system(size: 24))
.foregroundStyle(.brown)
Text(shop.name)
.font(.caption2)
.fontWeight(.semibold)
}
.padding(4)
.background(Color.white)
.cornerRadius(8)
}
}
}
Annotations let you build custom SwiftUI views for each marker. This is essential when you need to display badges, images, or interactive elements beyond a simple pin. The view you provide gets laid out at the marker's coordinate position, so you control the entire visual presentation.
MapCameraPosition for control
Showing markers is step one, but controlling where the map is centered and at what zoom level is critical for user experience. The MapCameraPosition enum gives us fine-grained control over the map's viewport. In a ride-hailing app, for example, you might want to zoom to show both the rider and driver on screen simultaneously.
struct MapControlView: View {
@State private var position: MapCameraPosition = .automatic
let riderLocation = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060)
let driverLocation = CLLocationCoordinate2D(latitude: 40.7260, longitude: -74.0050)
var body: some View {
ZStack {
Map(position: $position) {
Marker("Rider", coordinate: riderLocation)
.tint(.blue)
Marker("Driver", coordinate: driverLocation)
.tint(.green)
}
.mapStyle(.standard)
VStack {
HStack {
Button(action: { position = .automatic }) {
Label("Auto", systemImage: "location.viewfinder")
}
.buttonStyle(.bordered)
Button(action: { centerOnBoth() }) {
Label("Fit Both", systemImage: "rectangle.2.swap")
}
.buttonStyle(.bordered)
}
.padding()
Spacer()
}
}
}
private func centerOnBoth() {
let region = MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: (riderLocation.latitude + driverLocation.latitude) / 2,
longitude: (riderLocation.longitude + driverLocation.longitude) / 2
),
span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
)
position = .region(region)
}
}
MapCameraPosition has several cases: .automatic (system decides), .region(_:) (define center and span), and .item(_:) (focus on a specific annotation). Binding the position state to the Map view lets you react to user interactions or programmatically update the view. When you need to zoom to a bounding box containing multiple points, calculate the region's center and adjust the span (latitude and longitude deltas) to fit both points comfortably on screen.
Search with MKLocalSearch
Maps without search are incomplete. Users want to find restaurants, hotels, or services near them. MKLocalSearch is MapKit's built-in search API—it's performant, requires no API key, and integrates seamlessly with your map workflow. Here's how to search for nearby places and display results:
import MapKit
@MainActor
class SearchViewModel: NSObject, ObservableObject {
@Published var searchResults: [MKMapItem] = []
@Published var isSearching = false
@Published var searchError: String?
private var searchTask: Task?
func search(for query: String, in region: MKCoordinateRegion) {
searchTask?.cancel()
searchTask = Task {
isSearching = true
defer { isSearching = false }
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = query
request.region = region
let search = MKLocalSearch(request: request)
do {
let response = try await search.start()
self.searchResults = response.mapItems
self.searchError = nil
} catch {
self.searchError = error.localizedDescription
self.searchResults = []
}
}
}
}
struct SearchMapView: View {
@StateObject private var viewModel = SearchViewModel()
@State private var searchText = ""
let mapRegion = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060),
span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
)
var body: some View {
ZStack {
Map {
ForEach(viewModel.searchResults, id: \.self) { item in
Marker(item.name ?? "Location", coordinate: item.placemark.coordinate)
.tint(.red)
}
}
.mapStyle(.standard)
VStack(spacing: 12) {
HStack {
Image(systemName: "magnifyingglass")
TextField("Search cafes, restaurants...", text: $searchText)
.textFieldStyle(.roundedBorder)
.onSubmit {
viewModel.search(for: searchText, in: mapRegion)
}
}
.padding()
.background(Color.white)
.cornerRadius(8)
.padding()
if viewModel.isSearching {
ProgressView()
}
if let error = viewModel.searchError {
Text("Error: \(error)")
.font(.caption)
.foregroundStyle(.red)
}
Spacer()
}
}
}
}
MKLocalSearch.Request accepts a natural language query (e.g., "coffee shops"), so users can type naturally without strict keywords. Constrain the search to a region to return geographically relevant results. The async/await pattern keeps your UI responsive—always search on a background thread. Store the search task so you can cancel previous searches if the user types rapidly; this prevents race conditions where an old search completes after a new one starts.
Directions with MKDirections
The final piece is calculating and displaying turn-by-turn directions. MKDirections computes routes between two points and returns step-by-step instructions. In a delivery app, you'd use this to show drivers their route to the delivery address.
@MainActor
class DirectionsViewModel: ObservableObject {
@Published var polylines: [MKPolyline] = []
@Published var distance: String = ""
@Published var duration: String = ""
@Published var directionError: String?
func calculateDirections(from source: CLLocationCoordinate2D, to destination: CLLocationCoordinate2D) {
let sourceItem = MKMapItem(placemark: MKPlacemark(coordinate: source))
let destItem = MKMapItem(placemark: MKPlacemark(coordinate: destination))
let request = MKDirections.Request()
request.source = sourceItem
request.destination = destItem
request.transportType = .automobile
let directions = MKDirections(request: request)
directions.calculate { [weak self] response, error in
guard let self = self else { return }
if let error = error {
self.directionError = error.localizedDescription
return
}
guard let route = response?.routes.first else {
self.directionError = "No route found"
return
}
DispatchQueue.main.async {
self.polylines = [route.polyline]
self.distance = Self.formatDistance(route.distance)
self.duration = Self.formatDuration(route.expectedTravelTime)
}
}
}
private static func formatDistance(_ meters: CLLocationDistance) -> String {
let km = meters / 1000
return String(format: "%.1f km", km)
}
private static func formatDuration(_ seconds: TimeInterval) -> String {
let minutes = Int(seconds / 60)
if minutes < 60 {
return "\(minutes) min"
}
let hours = minutes / 60
let remainingMinutes = minutes % 60
return "\(hours)h \(remainingMinutes)m"
}
}
struct DirectionsView: View {
@StateObject private var viewModel = DirectionsViewModel()
let startCoordinate = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060)
let endCoordinate = CLLocationCoordinate2D(latitude: 40.7489, longitude: -73.9680)
var body: some View {
ZStack {
Map {
Marker("Start", coordinate: startCoordinate)
.tint(.green)
Marker("End", coordinate: endCoordinate)
.tint(.red)
ForEach(viewModel.polylines, id: \.self) { polyline in
MapPolyline(polyline)
.stroke(.blue, lineWidth: 4)
}
}
.mapStyle(.standard)
VStack(spacing: 12) {
HStack(spacing: 16) {
VStack(alignment: .leading) {
Text("Distance")
.font(.caption)
.foregroundStyle(.secondary)
Text(viewModel.distance)
.font(.headline)
}
VStack(alignment: .leading) {
Text("Duration")
.font(.caption)
.foregroundStyle(.secondary)
Text(viewModel.duration)
.font(.headline)
}
Spacer()
}
.padding()
.background(Color.white)
.cornerRadius(8)
.padding()
Spacer()
if let error = viewModel.directionError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding()
}
}
}
.onAppear {
viewModel.calculateDirections(from: startCoordinate, to: endCoordinate)
}
}
}
The MKDirections API returns a MKRoute object containing polylines (the path on the map), distance, and expected travel time. Render the polyline using MapPolyline and wrap it in a .stroke() modifier to draw a colored line on the map. You can also iterate over route.steps to build turn-by-turn directions; each step has a description and instruction.
Common mistakes
- Forgetting to handle CLLocationCoordinate2D validation: Always check that latitude is between -90 and 90, and longitude between -180 and 180. Invalid coordinates will cause silent failures or crashes.
- Not canceling previous searches: Rapid user input can trigger multiple
MKLocalSearchcalls. Cancel the previous task before starting a new one to prevent race conditions. - Using
.automaticcamera position with no visible annotations: If your map has no markers,.automaticwon't center anywhere meaningful. Always provide an explicit region or ensure markers are present. - Ignoring the request region in local search: Searches without a region constraint may return irrelevant results from distant locations. Always set
request.regionto limit results geographically. - Not handling permissions: MapKit requires location permissions on iOS. Use
CLLocationManagerto request user permission before displaying maps with location data. - Blocking the main thread with directions calculation:
MKDirections.calculateis asynchronous. Never call it synchronously or on the main thread in a loop.
Key takeaways
- Use
MapwithMarkerorAnnotationto display points of interest; annotations give you full control over custom view layouts. MapCameraPositionbinding lets you programmatically control zoom level and center point—essential for multi-point scenarios like ride-hailing.MKLocalSearchprovides zero-friction location search without requiring API keys; always set the region constraint and handle async results cleanly.MKDirectionscalculates routes and returns polylines for rendering; use it to show distance, duration, and visual paths between locations.- Always validate coordinates, cancel stale search tasks, and respect user location permissions to build trustworthy location features.






Comments
Loading comments…