Building a searchable map with SwiftUI and MapKit
Runway handles the release coordination and busywork so you can focus on building great apps. You do the building, we'll do the shipping.
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 thesearch
state 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
NSObject
class that conforms to theMKLocalSearchCompleterDelegate
protocol where you get notified about completion results as they come in through thecompleterDidUpdateResults
method. - Update the
queryFragment
property 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
.clear
to remove the default background styles. - We set the list style to
.plain
and thescrollContentBackground
to.hidden
to 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
@Binding
property to update the search results. This property will be held as a@State
property in the parent view so that the map can be updated accordingly. - We add an
onSubmit
modifier to the text field to trigger a search when the user presses the return key. - We add a
didTapOnCompletion
method to trigger a search when the user taps on a completion result. - We add logic to the
didTapOnCompletion
method 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
@State
property to hold the search results. - We add a new
@State
property to hold the selected location. - We update the
Map
view to take a selection binding. - We add a
Marker
view to show each search result on the map. - We update the
SearchableMap
view to dismiss the sheet when a location is selected. - We update the
SearchableMap
view 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
}
}