Getting Started on iOS

This section of the guide covers how to integrate Ferrostar into an iOS app. We'll cover the "batteries included" approach, but flag areas for customization and overrides along the way.

Add the Swift package dependency

If you’re not familiar with adding Swift Package dependencies to apps, Apple has some helpful documentation. You can search for the repository via its URL: https://github.com/stadiamaps/ferrostar.

Unless you are sure you know what you’re doing, you should use a tag (rather than a branch) and update along with releases. Since auto-generated bindings have to be checked in to source control (due to how SPM works), it’s possible to have intra-release breakage if you track master.

Configure location services

To access the user’s real location, you first need to set a key in your Info.plist or similar file. This is something you can set in Xcode by going to your project, selecting the target, and going to the Info tab.

You need to add row for “Privacy - Location When In Use Usage Description” (right-click any of the existing rows and click “Add row”) or, if you’re using raw keys, NSLocationWhenInUseUsageDescription. Fill in a description of why your app needs access to their location. Presumably something related to navigation ;)

Location providers

You'll need to configure a provider to get location updates. We bundle a few implementations to get you started, or you can create your own. The broad steps are the same regardless of which provider you use: create an instance of the class, store it in an instance variable where it makes sense, and (if simulating a route) set the location manually or enter a simulated route.

The API is similar to the iOS location APIs you may already know, and you can start or stop updates at will.

You should store your location provider in a place that is persistent for as long as you need it. Most often this makes sense as a private @StateObject if you’re using SwiftUI.

CoreLocationProvider

The CoreLocationProvider provides a ready-to-go wrapper around a CLLocationManager for getting location updates from GNSS. It will automatically request permissions for you as part of initialization.

@StateObject private var locationProvider = CoreLocationProvider(activityType: .otherNavigation, allowBackgroundLocationUpdates: true)

NOTE: If you want to access the user’s location while the app is in the background, you need to declare the location updates background mode in your Info.plist. You can find more details in the Apple documentation.

SimulatedLocationProvider

The SimulatedLocationProvider allows for simulating location within Ferrostar without needing GPX files or complicated environment setup. This is great for testing and development without stepping outside.

First, instantiate the class. This is usually saved as an instance variable.

@StateObject private var locationProvider = SimulatedLocationProvider(location: initialLocation)

You can set a new location using the lastLocation property at any time. Optionally, once you have a route, simulate the replay of the route. You can set a warpFactor to play it back faster.

locationProvider.warpFactor = 2
locationProvider.setSimulatedRoute(route)

Configure the FerrostarCore instance

Next, you’ll want to create a FerrostarCore instance. This is your interface into Ferrostar. You’ll also want to keep this around as a persistent property and use it in SwiftUI using a private @StateObject or @ObservedObject in most situations.

FerrostarCore provides several initializers, including convenient initializers for Valhalla routing backends and the ability to provide your own custom routing (ex: for local/offline use).

Route Providers

You’ll need to decide on a route provider when you set up your FerrostarCore instance. For limited testing, FOSSGIS maintains a public server with the URL https://valhalla1.openstreetmap.de/route. For production use, you’ll need another solution like a commercial vendor or self-hosting.

Set up Voice Guidance

If your routes include spoken instructions, Ferrostar can trigger the speech synthesis at the right time. Ferrostar includes the SpokenInstructionObserver class, which can use AVSpeechSynthesizer or your own speech synthesis.

The SpeechSynthesizer protocol specifies the required interface, and you can build your own implementation on this, such as a local AI model or cloud service like Amazon Polly. PRs welcome to add other publicly accessible speech API implementations.

Your navigation view can store the spoken instruction observer as an instance variable:

@State private var spokenInstructionObserver = SpokenInstructionObserver.initAVSpeechSynthesizer()

Then, you'll need to configure FerrostarCore to use it.

ferrostarCore.spokenInstructionObserver = spokenInstructionObserver

Finally, you can use this to drive state on navigation view. DynamicallyOrientingNavigationView has constructor arguments to configure the mute button UI. See the demo app for an example.

Configure annotation parsing

FerrostarCore includes support for parsing arbitrary annotations from the route. This technique is a de facto standard from OSRM, and has been adopted by a wide range of open-source and proprietary solutions. The routing APIs from Stadia Maps, Mapbox, and others use this to include detailed information like speed limits, expected travel speed, and more.

Ferrostar includes a Valhalla extended OSRM annotation parser, which works with Valhalla-powered APIs including Stadia Maps. The implementation is completely generic, so you can define your own model to include custom parameters. PRs welcome for other public API annotation models.

To set up annotation parsing, simply pass the optional annotation: parameter to the FerrostarCore constructor. You can create a Valhalla extended OSRM annotation publisher like so:

AnnotationPublisher<ValhallaExtendedOSRMAnnotation>.valhallaExtendedOSRM()

Getting a route

Before getting routes, you’ll need the user’s current location. You can get this from the location provider (which is part of why you’ll want to hang on to it). Next, you’ll need a set of waypoints to visit. Finally, you can use the asynchronous getRoutes method on FerrostarCore. Here’s an example:

Task {
    do {
        routes = try await ferrostarCore.getRoutes(initialLocation: userLocation, waypoints: [Waypoint(coordinate: GeographicCoordinate(cl: loc.location), kind: .break)])

        errorMessage = nil
        
        // TODO: Let the user select a route, or pick one programmatically
    } catch {
        // Communicate the error to the user.
        errorMessage = "Error: \(error)"
    }
}

Starting a navigation session

Once you or the user has selected a route, it’s time to start navigating!

try ferrostarCore.startNavigation(route: route, config: SwiftNavigationControllerConfig(stepAdvance: .relativeLineStringDistance(minimumHorizontalAccuracy: 32, automaticAdvanceDistance: 10), routeDeviationTracking: .staticThreshold(minimumHorizontalAccuracy: 25, maxAcceptableDeviation: 25)))

From this point, FerrostarCore automatically starts the LocationProvider updates, and will use Combine, the SwiftUI observation framework, to publish state changes.

Using the DynamicallyOrientingNavigationView

So now navigation is “started” but what does that mean? Let’s turn these state updates into a familiar map-centric experience!

We’ll use the DynamicallyOrientingNavigationView together with a map style, the state from the core, and many more configurable properties See the class documentation for details on each parameter, but you have the ability to customize most of the camera behavior.

// You can get a free Stadia Maps API key at https://client.stadiamaps.com
// See https://stadiamaps.github.io/ferrostar/vendors.html for additional vendors
let styleURL = URL(string: "https://tiles.stadiamaps.com/styles/outdoors.json?api_key=\(stadiaMapsAPIKey)")!
DynamicallyOrientingNavigationView(
    styleURL: styleURL,
    navigationState: state,
    camera: $camera,
    snappedZoom: .constant(18),
    useSnappedCamera: .constant(true))

Preventing the screen from sleeping

If you’re navigating, you probably don’t want the screen to go to sleep. You can prevent this by setting the isIdleTimerDisabled property on the UIApplication.shared object.

UIApplication.shared.isIdleTimerDisabled = true

// Don't forget to re-enable it when you're done!
UIApplication.shared.isIdleTimerDisabled = false

Refer to the Apple documentation for more information.

Demo app

We've put together a minimal demo app with an example integration.

Going deeper

This covers the basic “batteries included” configuration and pre-built UI. But there’s a lot of room for customization! Skip on over to the customization chapters that interest you.