Building a POI Search Using Mapbox and Foursquare Places Search on iOS

As anyone who engages in geospatial analysis knows, studying a single location on a map can quickly turn into exploring places across the world. The limiting factor for such analyses is the quality of the dataset, including the scope, number of locations, and much more. Foursquare’s Places API is up to such a task, providing a selection from 100M+ POIs that reveal insight about the world around us.

Creating an immersive map experience within an application can help users easily discover their surroundings. In this tutorial, we’ll demonstrate how to enhance a native iOS app with a map using the Mapbox SDK and the Foursquare Places API. By leveraging the device’s location, we’ll be able to search for and annotate nearby points of interest on the map. 

Annotating a MapBoxMapView using Foursquare Place Search API

Adding a Mapbox Map to SwiftUI

A Mapbox API account and access token is required to insert maps into an application. If you do not have a Mapbox account, sign up here

Once you have a Mapbox account, follow the installation instructions for generating access tokens and installing a Mapbox Map on iOS here.  

Integrating Mapbox and Places API

Now, let’s integrate Mapbox with the Foursquare Places API. First, jump over to the Foursquare Developer Console and generate an API key for a new project. Be sure to save the key in a safe place – it won’t be accessible again in the console. 

Now, we will set up the Mapbox Map by creating a MapBoxViewController Swift class in our project. To keep things organized, we will also create the class MapBoxView to contain functions that control the map. We create the MapView object inside the controller class and override the viewDidLoad function with some initialization boilerplate code. The CameraLocationConsumer lets us control the view of the map and the PointAnnotationManager allows us to put markers on the MapView object. 

MapBoxViewController.swift class to manage MapboxMapView



final class MapBoxMapViewController: UIViewController, AnnotationViewDelegate, LocationPermissionsDelegate, AnnotationInteractionDelegate {

​​    internal var mapView: MapView!
    private var cameraLocationConsumer: CameraLocationConsumer!
    private var pointAnnotationManager: PointAnnotationManager!


  override public func viewDidLoad() {
        super.viewDidLoad()
        
        mapView = MapView(frame: view.bounds)
        mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        cameraLocationConsumer = CameraLocationConsumer(mapView: mapView)
        
        mapView.mapboxMap.onNext(event: .mapLoaded) { [weak self] _ in
            guard let self = self else { return }
            // Register the location consumer with the map
            // Note that the location manager holds weak references to consumers, which should be retained
            self.mapView.location.addLocationConsumer(newConsumer: self.cameraLocationConsumer)
        }
        
        pointAnnotationManager = mapView.annotations.makePointAnnotationManager()
        pointAnnotationManager.delegate = self
        
        mapView.location.options.puckType = .puck2D(Puck2DConfiguration.makeDefault(showBearing: true))
        mapView.location.delegate = self
        
        self.view.addSubview(mapView)
        
        let followPuckViewportState = mapView.viewport.makeFollowPuckViewportState(options: 
        FollowPuckViewportStateOptions())
        mapView.viewport.transition(to: followPuckViewportState,
                                    transition: mapView.viewport.makeImmediateViewportTransition())
        
        prepareStyle()
    }

MapBoxView.swift class to hold logic for managing map


struct MapBoxMapView: UIViewControllerRepresentable {
    @Binding var places: [Place]
    
    func makeUIViewController(context: Context) -> MapBoxMapViewController {
        return MapBoxMapViewController()
    }
      
    func updateUIViewController(_ uiViewController: MapBoxMapViewController, context: Context) {
        //Map update code goes here
        
    }
}

Next, we will add the MapboxMapView to the ContentView, which will show the map when our SwiftUI application is started. We will use a ZStack to add a search form that supports the user who is searching and also passes the search query to the API. 

ContentView.swift class to display MapViewMap and search bar



struct ContentView: View {
    @ObservedObject private var locationManager = LocationManager()
    @State private var search = ""
    @State private var places = [Place]()
    @State private var showLocationError = false
    @State private var showApiError = false


    
    var body: some View {
        MapBoxMapView(places: $places)
        ZStack(alignment: .bottom) {
            TextField(NSLocalizedString("Search", comment: ""), text: $search) {
                Task { await search() }
            }
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .padding()
            .offset(y: 0)
            .alert("Unable to get location", isPresented: $showLocationError) {
                Button("OK", role: .cancel) { }
            }
        }
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Run the application and you should see the Mapbox Map appear with a search bar at the bottom of the screen. Now that we have the UI component set up, we just need to connect search to the Places API call and add the annotations to the mapview. 

User’s location displayed as blue puck on Mapbox Map

Integrating the Place Search API


Next, let’s build a class to retrieve POIs from the Place Search API using the user’s query input and current location. Create a new Swift file called Api.swift to contain the following code to call the Place Search endpoint. Be sure to replace ‘[YOUR_API_KEY]’ with an API generated for the Foursquare Developer Console. We are adding a public function that accepts a query and location. This function should be async and return an array of Places. 

Api.swift class to call Foursquare Places API



final class Api {
    enum Error: Swift.Error {
        case httpError(statusCode: Int)
        case serializationError
    }


    private init() { }


    class func getPlaces(query: String, location: CLLocation) async throws -> [Place] {
        let url = {
            var comps = URLComponents(string: "https://api.foursquare.com/v3/places/search")!
            comps.queryItems = [
                URLQueryItem(name: "query", value: query.self),
                URLQueryItem(name: "ll", value: "\(location.coordinate.latitude),\(location.coordinate.longitude)"),
                URLQueryItem(name: "limit", value: "15")
            ]
            comps.percentEncodedQuery = comps.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
            return comps.url!
        }()
        let request = {
            var request = URLRequest(url: url)
            request.httpMethod = "GET"
            request.allHTTPHeaderFields = [
                "accept": "application/json",
                "Authorization" : "[YOUR_API_KEY]"
            ]
            return request
        }()
        let (data, response) = try await URLSession.shared.data(for: request)
        let statusCode = (response as? HTTPURLResponse)!.statusCode
        if (statusCode != 200) {
            throw Error.httpError(statusCode: statusCode)
        }
        guard let response = try? JSONDecoder().decode(PlacesResponse.self, from: data) else {
            throw Error.serializationError
        }
        return response.results
    }
}

Build your application again to confirm that it builds successfully. If the build is successful, return to the ContentView file to integrate the Places search with the search text box. To do so, we must take the input of the search box, then pass it to the API search function. In this section, we will add a method to conduct the search in the ContentView. The locationManager is an @ObservedObject, which publishes the user’s location, and is passed as a parameter to the getPlaces method. 

ContentView.sift file



@State private var places = [Place]()

private func search() async {
        do {
            guard let location = locationManager.location else {
                showLocationError = true
                return
            }
            places = try await Api.getPlaces(query: search, location: location)
        } catch {
            print(error)
        }
    }

Placing the Annotations

The MapboxMapView class binds to the places array via the @State property wrapper. In our MapboxMapView class, we will use the functions from the MapBoxMapViewController class to update the map with our Place Search results. 

MapBoxMapView.swift class updated to modify the map after the user’s query


func updateUIViewController(_ uiViewController: MapBoxMapViewController, context: Context) {
        //remove previous annotations
        uiViewController.removeAllAnnotations()
      
       var closest: Place?
        var closestDistance: Int = 0
       
        //Add new annotations
       for place in places {
            guard let main = place.geocodes["main"] else {
                continue
            }
            uiViewController.addPointAndViewAnnotation(at CLLocationCoordinate2D(latitude: main.latitude, longitude: main.longitude), title: place.name)
            
            if (place.distance < closestDistance || closestDistance == 0) {
                closest = place
                closestDistance = place.distance
            }
        }


        guard let closest = closest,
            let latitude = closest.geocodes["main"]?.latitude,
            let longitude = closest.geocodes["main"]?.longitude else {
            return
        }
        let centerCoodinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
        uiViewController.updateCameraView(center: centerCoodinate)
    }

We use a few methods from the MapboxMapViewController class to take actions on the map. 

  1. Remove any previous annotations. 

func removeAllAnnotations(){
        mapView.viewAnnotations.removeAll()
        pointAnnotationManager.annotations.removeAll()
    }
  1. Add the new markers and annotations with the title of each POI.

public func addPointAndViewAnnotation(at coordinate: CLLocationCoordinate2D, title: String) {
        addPointAnnotation(at: coordinate)
        addViewAnnotation(at: coordinate, title: title)
    }
    
    
    private func addPointAnnotation(at coordinate: CLLocationCoordinate2D) {
        var pointAnnotation = PointAnnotation(id: Constants.markerId, coordinate: coordinate)
        pointAnnotation.iconImage = Constants.RED_ICON_ID
        pointAnnotation.iconAnchor = .bottom
        
        pointAnnotationManager.annotations.append(pointAnnotation)
    }
    
    private func addViewAnnotation(at coordinate: CLLocationCoordinate2D, title: String) {
        let options = ViewAnnotationOptions(
            geometry: Point(coordinate),
            width: 128,
            height: 64,
            associatedFeatureId: Constants.markerId,
            allowOverlap: false,
            anchor: .bottom,
            offsetY: markerHeight
        )
        let annotationView = AnnotationView(frame: CGRect(x: 0, y: 0, width: 128, height: 64))
        annotationView.title = title
        annotationView.delegate = self
        try? mapView.viewAnnotations.add(annotationView, options: options)
    }
  1. Update the camera view to move toward the closest POI.

public func updateCameraView(center: CLLocationCoordinate2D){
        
        let camOptions = CameraOptions(center: center)
        mapView.camera.ease(to: camOptions, duration: 3)
       }

Try running your application again and testing the search functionality. If everything is set up correctly, upon searching in your application, it should look like the image below.

Screen after successful location search

You are done! This example only uses Place Search, but other Foursquare offerings can be integrated with a Mapbox Map as well, such as Autocomplete or Address Details. Mapbox also has many examples and documentation for their SDK’s use cases. 

Check out this sample code as well as others in our examples Github repository. 

Hopefully this gets you started on the journey to building a location-aware app! If you have any questions or feedback, join our Discord Developer Community to engage with other engineers using Foursquare products. 

More on developer

How Foursquare and AWS are modernizing geospatial analysis and visualization

Learn More

From Fuzzy Joins to Hexagonal Clarity: Happy Anniversary Hex Tiles

Learn More

A Closer Look at Hex Tiles: How it’s Changing Geospatial Analysis

Learn More