Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 060 / 100

Maps with MapKit in SwiftUI in Swift, SwiftUI & iOS Apps

Maps and locations.

Expona Admin10 min read

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.

swift
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:

swift
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.

swift
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:

swift
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.

swift
@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 MKLocalSearch calls. Cancel the previous task before starting a new one to prevent race conditions.
  • Using .automatic camera position with no visible annotations: If your map has no markers, .automatic won'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.region to limit results geographically.
  • Not handling permissions: MapKit requires location permissions on iOS. Use CLLocationManager to request user permission before displaying maps with location data.
  • Blocking the main thread with directions calculation: MKDirections.calculate is asynchronous. Never call it synchronously or on the main thread in a loop.

Key takeaways

  • Use Map with Marker or Annotation to display points of interest; annotations give you full control over custom view layouts.
  • MapCameraPosition binding lets you programmatically control zoom level and center point—essential for multi-point scenarios like ride-hailing.
  • MKLocalSearch provides zero-friction location search without requiring API keys; always set the region constraint and handle async results cleanly.
  • MKDirections calculates 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles