Building a searchable map with SwiftUI and MapKit

Go from confusion to confidence with a step-by-step Swift Concurrency course, helping you smoothly migrate to Swift 6 and fully leverage its features.
There are some very exciting improvements coming to MapKit in iOS 17 that will make working with maps in SwiftUI much easier. To get to grips with the new APIs, I decided to build a small searchable map UI component that lets users search for locations, see them on a map, and then select one to take a closer look around:
Creating a map view
The first step to creating the searchable map component above is to create a new SwiftUI view with MapKit’s Map view inside it:
import SwiftUI
import MapKit
struct SearchableMap: View {
@State private var position = MapCameraPosition.automatic
var body: some View {
Map(position: $position)
.ignoresSafeArea()
}
}
The Map view is initialised with an automatic camera position, which means that it will automatically zoom and pan to show all of the annotations that are added to the map. This is perfect for our use case, as we’ll be adding pins for the locations that the user searches for.
Furthermore and to make things look a bit nicer, we can also use the .ignoresSafeArea() modifier to make the map view extend to the top and bottom of the screen.
Adding a sheet overlay
Let’s now add a sheet overlay with a custom view that allows the user to search for locations.
import SwiftUI
import MapKit
struct SearchableMap: View {
@State private var position = MapCameraPosition.automatic
@State private var isSheetPresented: Bool = true
var body: some View {
Map(position: $position)
.ignoresSafeArea()
.sheet(isPresented: $isSheetPresented) {
SheetView()
}
}
}
And this is what the contents of the sheet view look like for now:
import SwiftUI
import MapKit
struct SheetView: View {
@State private var search: String = ""
var body: some View {
VStack {
// 1
HStack {
Image(systemName: "magnifyingglass")
TextField("Search for a restaurant", text: $search)
.autocorrectionDisabled()
}
.modifier(TextFieldGrayBackgroundColor())
Spacer()
}
.padding()
// 2
.interactiveDismissDisabled()
// 3
.presentationDetents([.height(200), .large])
// 4
.presentationBackground(.regularMaterial)
// 5
.presentationBackgroundInteraction(.enabled(upThrough: .large))
}
}
struct TextFieldGrayBackgroundColor: ViewModifier {
func body(content: Content) -> some View {
content
.padding(12)
.background(.gray.opacity(0.1))
.cornerRadius(8)
.foregroundColor(.primary)
}
}
Let’s go through the code above step by step:
- The sheet view has a search bar (
TextField) at the top, with a binding to thesearchstate property. - The user can not dismiss the sheet view by swiping it down.
- The sheet view has two possible sizes: a small one (200 points tall) and a large one (the default size).
- The sheet view has a regular material background, giving it a nice blur effect.
- The sheet view’s background is interactive, meaning that the user can interact with the map view behind it.
The great thing about this code is that we don’t have to write any custom code to make the sheet resize when the user is searching for a location. The sheet view will automatically resize itself to the small size when the user starts typing in the search bar, and then back to the large size when the submits the search with the return key.
Location search completion
Let’s now provide the user with a list of search results as they type in the search bar.
To do this, we’ll use the MKLocalSearchCompleter class from MapKit, which we’ll use in a custom LocationService decorated with the new @Observable macro:
import MapKit
struct SearchCompletions: Identifiable {
let id = UUID()
let title: String
let subTitle: String
}
@Observable
class LocationService: NSObject, MKLocalSearchCompleterDelegate {
private let completer: MKLocalSearchCompleter
var completions = [SearchCompletions]()
init(completer: MKLocalSearchCompleter) {
self.completer = completer
super.init()
self.completer.delegate = self
}
func update(queryFragment: String) {
completer.resultTypes = .pointOfInterest
completer.queryFragment = queryFragment
}
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
completions = completer.results.map { .init(title: $0.title, subTitle: $0.subtitle) }
}
}
Working with MKLocalSearchCompleter is rather straightforward. You just need to:
- Create an instance of
MKLocalSearchCompleter. - Set the delegate to an
NSObjectclass that conforms to theMKLocalSearchCompleterDelegateprotocol where you get notified about completion results as they come in through thecompleterDidUpdateResultsmethod. - Update the
queryFragmentproperty to trigger a new request for completion results.
As opposed to its MKLocalSearch counterpart, MKLocalSearchCompleter has no rate limit, so you can update the queryFragment property as often as you want and there is no need to throttle the requests yourself.
Displaying search completions
Now that we have a way of getting search completions, let’s update the location service with the textfield’s value and display the results as they come in in the sheet view:
import SwiftUI
import MapKit
struct SheetView: View {
// 1
@State private var locationService = LocationService(completer: .init())
@State private var search: String = ""
var body: some View {
VStack {
HStack {
Image(systemName: "magnifyingglass")
TextField("Search for a restaurant", text: $search)
.autocorrectionDisabled()
}
.modifier(TextFieldGrayBackgroundColor())
Spacer()
// 2
List {
ForEach(locationService.completions) { completion in
Button(action: { }) {
VStack(alignment: .leading, spacing: 4) {
Text(completion.title)
.font(.headline)
.fontDesign(.rounded)
Text(completion.subTitle)
}
}
// 3
.listRowBackground(Color.clear)
}
}
// 4
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
// 5
.onChange(of: search) {
locationService.update(queryFragment: search)
}
.padding()
.interactiveDismissDisabled()
.presentationDetents([.height(200), .large])
.presentationBackground(.regularMaterial)
.presentationBackgroundInteraction(.enabled(upThrough: .large))
}
}
Let’s break down the code above step by step:
- We create a new state property to hold the location service instance.
- We display the search completions in a list.
- We set the row background to
.clearto remove the default background styles. - We set the list style to
.plainand thescrollContentBackgroundto.hiddento remove the default list styles. - We update the location service’s query fragment to trigger an update to the completions list whenever the search text changes.
🤫 Private APIs
The responses from the MKLocalSearchCompleter contain a limited amount of information about the locations that are returned. If you inspect the runtime headers for the MKLocalSearchCompletion class you will see that there is a _mapItem property available which surfaces a lot more information.
Let’s use this property on the private API to get the location’s URL in the LocationService class:
import MapKit
struct SearchCompletions: Identifiable {
let id = UUID()
let title: String
let subTitle: String
// New property to hold the URL if it exists
var url: URL?
}
@Observable
class LocationService: NSObject, MKLocalSearchCompleterDelegate {
// ...
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
completions = completer.results.map { completion in
// Get the private _mapItem property
let mapItem = completion.value(forKey: "_mapItem") as? MKMapItem
return .init(
title: completion.title,
subTitle: completion.subtitle,
url: mapItem?.url
)
}
}
}
We can then update the SheetView to display the URL along with the location’s title and subtitle:
import SwiftUI
import MapKit
struct SheetView: View {
@State private var locationService = LocationService(completer: .init())
@State private var search: String = ""
var body: some View {
VStack {
HStack {
Image(systemName: "magnifyingglass")
TextField("Search for a restaurant", text: $search)
.autocorrectionDisabled()
}
.modifier(TextFieldGrayBackgroundColor())
Spacer()
List {
ForEach(locationService.completions) { completion in
Button(action: { }) {
VStack(alignment: .leading, spacing: 4) {
Text(completion.title)
.font(.headline)
.fontDesign(.rounded)
Text(completion.subTitle)
// Show the URL if it's present
if let url = completion.url {
Link(url.absoluteString, destination: url)
.lineLimit(1)
}
}
}
.listRowBackground(Color.clear)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
.onChange(of: search) {
locationService.update(queryFragment: search)
}
.padding()
.interactiveDismissDisabled()
.presentationDetents([.height(200), .large])
.presentationBackground(.regularMaterial)
.presentationBackgroundInteraction(.enabled(upThrough: .large))
}
}
Location search results
Now that we have a way of showing locations as the user types, we need to add a way for the user to submit the search and see one or more locations on the map.
We will do this by making a request to MKLocalSearch from the LocationService and searching for the location that the user has typed in the search bar, filtering by the pointOfInterest result type:
import MapKit
struct SearchResult: Identifiable, Hashable {
let id = UUID()
let location: CLLocationCoordinate2D
static func == (lhs: SearchResult, rhs: SearchResult) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
@Observable
class LocationService: NSObject, MKLocalSearchCompleterDelegate {
// ...
func search(with query: String, coordinate: CLLocationCoordinate2D? = nil) async throws -> [SearchResult] {
let mapKitRequest = MKLocalSearch.Request()
mapKitRequest.naturalLanguageQuery = query
mapKitRequest.resultTypes = .pointOfInterest
if let coordinate {
mapKitRequest.region = .init(.init(origin: .init(coordinate), size: .init(width: 1, height: 1)))
}
let search = MKLocalSearch(request: mapKitRequest)
let response = try await search.start()
return response.mapItems.compactMap { mapItem in
guard let location = mapItem.placemark.location?.coordinate else { return nil }
return .init(location: location)
}
}
}
Displaying search results
We now need to call the new search method from the SheetView when:
- The user taps on a search result.
- The user submits the search by pressing the return key.
import SwiftUI
import MapKit
struct SheetView: View {
@State private var locationService = LocationService(completer: .init())
@State private var search: String = ""
// 1
@Binding var searchResults: [SearchResult]
var body: some View {
VStack {
HStack {
Image(systemName: "magnifyingglass")
TextField("Search for a restaurant", text: $search)
.autocorrectionDisabled()
// 2
.onSubmit {
Task {
searchResults = (try? await locationService.search(with: search)) ?? []
}
}
}
.modifier(TextFieldGrayBackgroundColor())
Spacer()
List {
ForEach(locationService.completions) { completion in
// 3
Button(action: { didTapOnCompletion(completion) }) {
VStack(alignment: .leading, spacing: 4) {
Text(completion.title)
.font(.headline)
.fontDesign(.rounded)
Text(completion.subTitle)
// What can we show?
if let url = completion.url {
Link(url.absoluteString, destination: url)
.lineLimit(1)
}
}
}
.listRowBackground(Color.clear)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
.onChange(of: search) {
locationService.update(queryFragment: search)
}
.padding()
.interactiveDismissDisabled()
.presentationDetents([.height(200), .large])
.presentationBackground(.regularMaterial)
.presentationBackgroundInteraction(.enabled(upThrough: .large))
}
// 4
private func didTapOnCompletion(_ completion: SearchCompletions) {
Task {
if let singleLocation = try? await locationService.search(with: "\(completion.title) \(completion.subTitle)").first {
searchResults = [singleLocation]
}
}
}
}
Let’s break down the code above step by step:
- We add a new
@Bindingproperty to update the search results. This property will be held as a@Stateproperty in the parent view so that the map can be updated accordingly. - We add an
onSubmitmodifier to the text field to trigger a search when the user presses the return key. - We add a
didTapOnCompletionmethod to trigger a search when the user taps on a completion result. - We add logic to the
didTapOnCompletionmethod to search for the location’s title and subtitle when the user taps on a completion result.
Let’s now update our SearchableMap view to hold search results, display them on the map and allow the user to select the location they want:
import SwiftUI
import MapKit
struct SearchableMap: View {
@State private var position = MapCameraPosition.automatic
// 1
@State private var searchResults = [SearchResult]()
//2
@State private var selectedLocation: SearchResult?
@State private var isSheetPresented: Bool = true
var body: some View {
// 3
Map(position: $position, selection: $selectedLocation) {
// 4
ForEach(searchResults) { result in
Marker(coordinate: result.location) {
Image(systemName: "mappin")
}
.tag(result)
}
}
.ignoresSafeArea()
// 5
.onChange(of: selectedLocation) {
isSheetPresented = selectedLocation == nil
}
// 6
.onChange(of: searchResults) {
if let firstResult = searchResults.first, searchResults.count == 1 {
selectedLocation = firstResult
}
}
.sheet(isPresented: $isSheetPresented) {
SheetView(searchResults: $searchResults)
}
}
}
Let’s break down the code above step by step:
- We add a new
@Stateproperty to hold the search results. - We add a new
@Stateproperty to hold the selected location. - We update the
Mapview to take a selection binding. - We add a
Markerview to show each search result on the map. - We update the
SearchableMapview to dismiss the sheet when a location is selected. - We update the
SearchableMapview to select the first location if there is only one result.
Look around scenes
iOS 17 has made it very easy to create look-around scenes with the new LookAroundPreview view. To create one, we just need to retrieve a scene from a coordinate and pass it through to the new view:
import SwiftUI
import MapKit
struct SearchableMap: View {
@State private var position = MapCameraPosition.automatic
@State private var searchResults = [SearchResult]()
@State private var selectedLocation: SearchResult?
@State private var isSheetPresented: Bool = true
@State private var scene: MKLookAroundScene?
var body: some View {
Map(position: $position, selection: $selectedLocation) {
ForEach(searchResults) { result in
Marker(coordinate: result.location) {
Image(systemName: "mappin")
}
.tag(result)
}
}
.overlay(alignment: .bottom) {
if selectedLocation != nil {
LookAroundPreview(scene: $scene, allowsNavigation: false, badgePosition: .bottomTrailing)
.frame(height: 150)
.clipShape(RoundedRectangle(cornerRadius: 12))
.safeAreaPadding(.bottom, 40)
.padding(.horizontal, 20)
}
}
.ignoresSafeArea()
.onChange(of: selectedLocation) {
if let selectedLocation {
Task {
scene = try? await fetchScene(for: selectedLocation.location)
}
}
isSheetPresented = selectedLocation == nil
}
.onChange(of: searchResults) {
if let firstResult = searchResults.first, searchResults.count == 1 {
selectedLocation = firstResult
}
}
.sheet(isPresented: $isSheetPresented) {
SheetView(searchResults: $searchResults)
}
}
private func fetchScene(for coordinate: CLLocationCoordinate2D) async throws -> MKLookAroundScene? {
let lookAroundScene = MKLookAroundSceneRequest(coordinate: coordinate)
return try await lookAroundScene.scene
}
}