NavigationOverlayKit is a Swift Package that provides a ready-to-use SwiftUI overlay component for displaying navigation instructions derived from MKRoute and MKDirections. The overlay shows a navigation arrow, a textual instruction, and distance information to help users stay on track during turn-by-turn navigation experiences.
- 📍 SwiftUI overlay view designed for in-app navigation experiences.
- 🧭 Support for
MKRouteandMKRoute.Stepinstructions out-of-the-box. - 🔁 Observable view model that can react to navigation progress updates.
- 🧰 Lightweight Swift Package ready for integration into other projects, including ToiletFinder.
- iOS 15.0+
- Xcode 15+
- In Xcode, open File → Add Packages...
- Enter the repository URL of NavigationOverlayKit.
- Select Add Package and ensure the
NavigationOverlayKitlibrary is added to your target.
import NavigationOverlayKitYou can construct the provided NavigationOverlayViewModel with a MapKit MKRoute. The view model keeps track of the current instruction and updates the overlay automatically when you move between steps.
let directionsRequest = MKDirections.Request()
directionsRequest.source = MKMapItem(placemark: MKPlacemark(coordinate: startCoordinate))
directionsRequest.destination = MKMapItem(placemark: MKPlacemark(coordinate: destinationCoordinate))
let directions = MKDirections(request: directionsRequest)
directions.calculate { response, error in
guard let route = response?.routes.first else { return }
let viewModel = NavigationOverlayViewModel(route: route, unit: .kilometers)
// Store viewModel for later use in your SwiftUI view hierarchy.
}Attach the overlay to any SwiftUI view (for example, a map) and provide the shared view model:
struct ContentView: View {
@StateObject private var viewModel: NavigationOverlayViewModel
init(route: MKRoute) {
_viewModel = StateObject(wrappedValue: NavigationOverlayViewModel(route: route, unit: .kilometers))
}
var body: some View {
ZStack {
Map(route: route)
NavigationOverlayView(viewModel: viewModel, alignment: .top)
}
}
}When you receive live navigation updates (e.g. from CLLocationManager callbacks), call the appropriate methods on the view model to refresh the overlay:
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let currentRoute = currentRoute else { return }
let stepIndex = navigationEngine.currentStepIndex(for: locations.last, on: currentRoute)
viewModel.update(step: stepIndex)
}Alternatively, construct custom instructions directly:
let instruction = NavigationInstruction(
text: "Turn right onto Market Street",
distance: Measurement(value: 120, unit: .meters),
symbol: .right
)
viewModel.update(with: instruction)NavigationOverlayViewModel publishes the current symbol so apps can subscribe and trigger haptics/audio cues whenever a new instruction is decoded:
@StateObject private var viewModel = NavigationOverlayViewModel()
@State private var cancellables = Set<AnyCancellable>()
func bindHaptics() {
viewModel.$symbol
.compactMap { $0 }
.sink { symbol in
let generator: UIImpactFeedbackGenerator
switch symbol {
case .uTurn, .arrive:
generator = UIImpactFeedbackGenerator(style: .heavy)
case .left, .right, .sharpLeft, .sharpRight:
generator = UIImpactFeedbackGenerator(style: .medium)
case .slightLeft, .slightRight:
generator = UIImpactFeedbackGenerator(style: .light)
case .start, .straight, .cross, .tunnel, .bridge, .stairs, .escalator:
generator = UIImpactFeedbackGenerator(style: .soft)
}
generator.impactOccurred()
}
.store(in: &cancellables)
}- Prefer
@StateObjectforNavigationOverlayViewModelwhen the overlay is owned by a view, so the model persists across view reloads. - If you need to share the overlay across multiple views, inject the view model via
@EnvironmentObjector a custom dependency container. - Use
updateRemainingDistance(to:)for frequent distance refreshes without changing the current instruction, andupdate(step:)when you advance between route steps. - In UIKit-backed projects, host the overlay via
UIHostingControllerand keep the view model in your coordinator or view controller.
NavigationOverlayKit follows Semantic Versioning (MAJOR.MINOR.PATCH).
MAJORfor breaking API changes.MINORfor backwards-compatible feature additions.PATCHfor backwards-compatible bug fixes.
The current library version is exposed via NavigationOverlayKitVersion.current.
Note: Swift Package Manager does not support declaring a version inside
Package.swift. Versions are published via Git tags, so keep tags in sync withNavigationOverlayKitVersion.current.
Xcode relies on Swift Package Manager to resolve dependency versions based on your package requirement
(exact, branch, or version range). When you choose File → Packages → Update to Latest Package Versions
or trigger a dependency resolve, SwiftPM fetches the latest matching Git tags for each package, updates
Package.resolved, and Xcode uses that lockfile to decide whether a newer compatible version is available.
An end-to-end example showing how to use NavigationOverlayKit within a simple SwiftUI view:
struct RoutePreview: View {
@State private var route: MKRoute?
@StateObject private var viewModel = NavigationOverlayViewModel()
var body: some View {
ZStack {
MapRouteView(route: $route)
NavigationOverlayView(viewModel: viewModel)
}
.task {
await loadRoute()
}
}
private func loadRoute() async {
do {
let calculatedRoute = try await RouteLoader().route(from: startCoordinate, to: destinationCoordinate)
route = calculatedRoute
viewModel.update(step: 0)
} catch {
print("Failed to fetch route: \(error)")
}
}
}ℹ️
MapRouteViewandRouteLoaderare placeholders representing your map view and routing logic.
NavigationOverlayKit/
├── Package.swift
├── README.md
├── Sources/
│ └── NavigationOverlayKit/
│ ├── NavigationInstruction.swift
│ ├── NavigationOverlayView.swift
│ ├── NavigationOverlayViewModel.swift
│ └── Resources/
└── Tests/
└── NavigationOverlayKitTests/
NavigationOverlayKit derives maneuver symbols (left/right/slight/sharp/U‑turn/arrive) from MKRoute/MKRoute.Step.
- MapKit provides localized, free‑text step instructions but no typed maneuver enum.
- To support multiple locales, the kit:
- First tries a multilingual keyword map (English, German, French, Spanish, Italian, Japanese; easy to extend)
- Then falls back to geometry‑based angle detection between step polylines
- Finally defaults to straight when neither approach is decisive
Why this exists: Without a typed maneuver in MapKit, mapping across all locales requires heuristics. This hybrid approach is practical but community help improves coverage and accuracy.
- Add keywords for your language in
NavigationInstruction.symbolFromLocalizedInstruction(_:).- Include variants, gendered/plural forms, and common synonyms.
- Keep tokens lowercase; accents are fine. Prefer short substrings that are unlikely to conflict.
- Add unit tests in
NavigationOverlayKitTestswith real instructions captured from MapKit for your locale. - If you see a mis‑classified turn, open an issue and include:
- Locale and full instruction text
- Expected maneuver (e.g., sharpLeft)
- Optional: previous/current step coordinates for geometry fallback validation
If you need guaranteed correctness across many languages, consider using a routing SDK that exposes typed maneuvers (e.g., Mapbox or HERE) and feed its steps into NavigationOverlayViewModel.
NavigationOverlayKit is provided with BSD 2-Clause "Simplified" License. See LICENSE file.
