Searching for points of interest in MapKit with SwiftUI

Searching for points of interest in MapKit with SwiftUI

Learn how to use MapKit to perform a search for points of interest and display them on a map.

The power of MapKit comes to light when searching for points of interest. Performing a search for a specific type of location is made simple by the combination of the MKLocalSearch and MKMapItem objects.

In this short tutorial, you will learn how to use MKLocalSearch to search for points of interest using MapKit based on a search query and a region and display them on a Map view.

Before we start

To go through this tutorial you need a basic understanding of SwiftUI and the Swift Language. You don’t need any particular assets to go through it.

Start by creating a new app project with Xcode.

As a starting point, edit your ContentView view to add the following:

import SwiftUI
// 1.
import MapKit

struct ContentView: View {
    // 2.
    let garage = CLLocationCoordinate2D(
        latitude: 40.83657722488077,
        longitude: 14.306896671048852
    )
    
    // 3. View properties
    @State private var searchQuery: String = ""
    
    var body: some View {
        
        NavigationStack {
		    // 4.
            Map {
                Marker("Garage", coordinate: garage)
            }
            // 5. Map modifiers
            .mapStyle(.hybrid(elevation: .realistic))
            
            // 6. Search modifiers
            .searchable(text: $searchQuery, prompt: "Locations")
            .onSubmit(of: .search) {
                // Add the search action here
            }
            
            // 7. Navigation modifiers
            .navigationTitle("Search")
        }
        
    }
}
  1. Import the MapKit frameworks
  2. Create a local variable that will be used as a reference point for our search. In other scenarios, you could use the current user location
  3. Create a local state property that will store the search query to be used to perform the search later on
  4. Replace the body content with a NavigationStack with a Map view inside. In the Map view create a Marker for our reference coordinate.
  5. Set up the map style (you can choose any style of your preference here)
  6. Set up the search modifiers to be used to trigger the search later on
  7. Assign a navigation title to our view

With our starting point set up, we can start implementing the search functionality of our view.

Step 1 - Performing a search with MKLocalSearch

In this step, we will create the method responsible for using the user input to perform a local search using MapKit. The results will then be stored on a local property that will be used to display the results on the Map view in future steps.

struct ContentView: View {
    
    let garage = CLLocationCoordinate2D(latitude: 40.83657722488077, longitude: 14.306896671048852)
    
    @State private var searchQuery: String = ""
    
    // 1.
    @State private var searchResults: [MKMapItem] = []
    
    var body: some View {
        ...
    }
    
    // 2.
    private func search(for query: String) {
		    // 3.
        let request = MKLocalSearch.Request()
        // 4.
        request.naturalLanguageQuery = query
        request.resultTypes = .pointOfInterest
        request.region = MKCoordinateRegion(
            center: garage,
            span: MKCoordinateSpan(
                latitudeDelta: 0.0125,
                longitudeDelta: 0.0125
            )
        )
        
        // 5.
        Task {
		        // 6.
            let search = MKLocalSearch(request: request)
            // 7.
            let response = try? await search.start()
            // 8.
            searchResults = response?.mapItems ?? []
        }
    }
}
  1. Create a property called searchResults which will be responsible for storing an array of MKMapItem objects and initialize it as an empty array
  2. Create a function that will receive the search query as a parameter to perform the search
  3. Create a new MKLocalSearch.Request object to be used for searching for map locations based on a natural language string
  4. Set up the search properties:
    1. naturalLanguageQuery: the query to be used to perform the search
    2. resultTypes: the types of items to include in the search results
    3. region: the map region where to perform the search
  5. Since the search is performed asynchronously, create a Task object to perform it
  6. Create a MKLocalSearch object based on the request object created previously
  7. Start the search and store the resulting response
  8. If the response successfully returns a list of map items, update the search results property accordingly

Step 2 - Presenting the search results on the map

Now that we have the methods to perform the search set, we need to:

  • Trigger the search
  • Present the results on the map
NavigationStack {
    Map {
        Marker("Garage", coordinate: garage)
        // 2.
        ForEach(searchResults, id: \\.self) { result in
            Marker(item: result)
        }
    }
    /// Map modifiers
    .mapStyle(.hybrid(elevation: .realistic))
    
    /// Search modifiers
    .searchable(text: $searchQuery, prompt: "Locations")
    .onSubmit(of: .search) {
		    // 1.
        self.search(for: searchQuery)
    }
    /// Navigation modifiers
    .navigationTitle("Searching")
}
  1. Call the search method when the user submits the input in the search text field
  2. Present a Marker for each of the results

Now we can already perform a search and see the results on the map!

But at the moment the search is always being performed by taking a pre-determined area of the map as the region to be used. Let’s allow the search to be performed in the current region the user is looking at.

Step 3 - Searching on the visible region

To configure the search to happen in the map's currently visible area, we need to track its camera position and use that information in our search request.

struct ContentView: View {
    
    let garage = CLLocationCoordinate2D(
        latitude: 40.83657722488077,
        longitude: 14.306896671048852
    )
    
    /// View properties
    @State private var searchQuery: String = ""
    @State private var searchResults: [MKMapItem] = []
    
    // 1.
    @State private var position: MapCameraPosition = .automatic
    // 2.
    @State private var visibleRegion: MKCoordinateRegion?
    
    var body: some View {
        
        NavigationStack {
            // 3.
            Map(position: $position) {
                Marker("Garage", coordinate: garage)
                
                ForEach(searchResults, id: \\.self) { result in
                    Marker(item: result)
                }
            }
            /// Map modifiers
            .mapStyle(.hybrid(elevation: .realistic))
            
            // 4.
            .onMapCameraChange { context in
                self.visibleRegion = context.region
            }
            
            /// Search modifiers
            .searchable(text: $searchQuery, prompt: "Locations")
            .onSubmit(of: .search) {
                self.search(for: searchQuery)
            }
            /// Navigation modifiers
            .navigationTitle("Search")
        }
        
    }
    
    private func search(for query: String) {
		    ...
    }
}
  1. Create a property called position of type MapCameraPosition to be used to update the current map position based on the results of the search
  2. Create a property called visibleRegion of type MKCoordinateRegion to keep track of the currently visible map area to be used when searching
  3. Bind the Map view position to the position property
  4. We use the onMapCameraChange(frequency:_:) modifier to update the visible region to be used on the search every time the user moves the map around

Now let’s update the search method to use the visible region of the map as the region to be used to perform the search.

private func search(for query: String) {
    
    // 1.
    let defaultRegion = MKCoordinateRegion(
        center: garage,
        span: MKCoordinateSpan(
            latitudeDelta: 0.0125,
            longitudeDelta: 0.0125
        )
    )
    
    let request = MKLocalSearch.Request()
    
    request.naturalLanguageQuery = query
    request.resultTypes = .pointOfInterest
    // 2.
    request.region = visibleRegion ?? defaultRegion
    
    print("Visible region: \\(visibleRegion.debugDescription)")
    
    Task {
        let search = MKLocalSearch(request: request)
        let response = try? await search.start()
        searchResults = response?.mapItems ?? []
        
        // 3.
        self.position = .region(request.region)
    }
}
  1. Create a property to be the default region used on the search in case the visible region is nil
  2. Assign the currently visible region of the map to the search request
  3. Add a line of code to update the current map position, so it is the same region of the performed search once the search is completed

With this step complete, now the region where the search is performed is no longer fixed to a specific area but is defined by the currently visible area of the map.

Step 4 - Selecting an item on the map

If the user interacts with the markers on the map nothing happens. To add interactivity to them is simple.

The first step is to create a property called selectedResult of the type MKMapItem which will be responsible for storing the currently selected map item.

struct ContentView: View {
    
    let garage = CLLocationCoordinate2D(latitude: 40.83657722488077, longitude: 14.306896671048852)
    
    @State private var searchQuery: String = ""
    @State private var searchResults: [MKMapItem] = []
    @State private var position: MapCameraPosition = .automatic
    @State private var visibleRegion: MKCoordinateRegion?

    // 1.
    @State private var selectedResult: MKMapItem?
    
    ...
    
}

Then bind the selectedResult property to the map with its view initializer.

Map(position: $position, selection: $selectedResult) {
    ...
}

With just these two small changes, now when the user selects a marker on the map it does a little scale animation as visual feedback for the selection.

Final Result

Now you have a view that presents a map and allows the user to search for points of interest based on a natural language query with a search bar. Here is what the app should look like:

0:00
/0:27

Here is the complete code of the ContentView created during this tutorial:

import SwiftUI
import MapKit

struct ContentView: View {
    /// View properties
    let garage = CLLocationCoordinate2D(
        latitude: 40.83657722488077,
        longitude: 14.306896671048852
    )
    
    /// Search properties
    @State private var searchQuery: String = ""
    @State private var searchResults: [MKMapItem] = []
    
    /// Map properties
    @State private var position: MapCameraPosition = .automatic
    @State private var visibleRegion: MKCoordinateRegion?
    @State private var selectedResult: MKMapItem?
    
    var body: some View {
        
        NavigationStack {

            Map(position: $position, selection: $selectedResult) {
                /// Reference point
                Marker("Garage", coordinate: garage)
                
                /// Search results on the map
                ForEach(searchResults, id: \\.self) { result in
                    Marker(item: result)
                }
            }
            
            /// Map modifiers
            .mapStyle(.hybrid(elevation: .realistic))
            .onMapCameraChange { context in
                self.visibleRegion = context.region
            }
            
            /// Search modifiers
            .searchable(text: $searchQuery, prompt: "Locations")
            .onSubmit(of: .search) {
                self.search(for: searchQuery)
            }
            
            /// Navigation modifiers
            .navigationTitle("Search")
        }
        
    }
    
    /// Search method
    private func search(for query: String) {
        
        let defaultRegion = MKCoordinateRegion(
            center: garage,
            span: MKCoordinateSpan(
                latitudeDelta: 0.0125,
                longitudeDelta: 0.0125
            )
        )
        
        let request = MKLocalSearch.Request()
        
        request.naturalLanguageQuery = query
        request.resultTypes = .pointOfInterest
        request.region = visibleRegion ?? defaultRegion
        
        Task {
            let search = MKLocalSearch(request: request)
            let response = try? await search.start()
            searchResults = response?.mapItems ?? []
            position = .region(request.region)
        }
    }
}