@baeckerherz/expo-mapbox-navigation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # @baeckerherz/expo-mapbox-navigation
2
+
3
+ > **WARNING: This is a prototype and actively under development.** APIs may change without notice. Not recommended for production use yet. Contributions and feedback are welcome.
4
+
5
+ Expo module wrapping [Mapbox Navigation SDK v3](https://docs.mapbox.com/ios/navigation/guides/) for iOS and Android. A clean, maintainable alternative to existing community wrappers.
6
+
7
+ ## Why this exists
8
+
9
+ Existing React Native / Expo wrappers for Mapbox Navigation have significant issues:
10
+
11
+ - **@badatgil/expo-mapbox-navigation**: Bundles vendored `.xcframework` files for iOS (fragile, requires manual rebuild for every Mapbox SDK update). Android assembles the navigation UI from ~30 individual components in ~1100 lines of Kotlin instead of using Mapbox's drop-in view.
12
+ - **@homee/react-native-mapbox-navigation**: Abandoned (no activity since 2022), locked to Nav SDK v2.1.1, no Expo support, crashes on Android 13+.
13
+
14
+ ## Architecture
15
+
16
+ ### iOS: SPM via Config Plugin (not vendored xcframeworks)
17
+
18
+ Mapbox Navigation SDK v3 for iOS dropped CocoaPods and only supports Swift Package Manager. Since Expo uses CocoaPods, we use an **Expo config plugin** that injects SPM package references into the Xcode project's `.pbxproj` at prebuild time. Version bumps are a single string change -- no manual xcframework rebuilding.
19
+
20
+ ### Android: Drop-in NavigationView
21
+
22
+ Android Nav SDK v3 provides a `NavigationView` drop-in component. We wrap it directly instead of rebuilding the UI from scratch.
23
+
24
+ ### Both platforms: Expo Module API
25
+
26
+ Uses `expo-modules-core` for native bridging, giving us:
27
+ - Fabric / New Architecture compatibility
28
+ - Type-safe props and events in Swift/Kotlin
29
+ - Clean `EventDispatcher` pattern
30
+ - Works in Expo and bare RN projects
31
+
32
+ ## API
33
+
34
+ ```tsx
35
+ import { MapboxNavigation } from '@baeckerherz/expo-mapbox-navigation';
36
+
37
+ <MapboxNavigation
38
+ coordinates={[
39
+ { latitude: 47.2692, longitude: 11.4041 },
40
+ { latitude: 48.2082, longitude: 16.3738 },
41
+ ]}
42
+ locale="de"
43
+ onRouteProgressChanged={(e) => {
44
+ console.log(e.nativeEvent.distanceRemaining);
45
+ }}
46
+ onCancelNavigation={() => navigation.goBack()}
47
+ onFinalDestinationArrival={() => console.log('Arrived!')}
48
+ style={{ flex: 1 }}
49
+ />
50
+ ```
51
+
52
+ ### Props
53
+
54
+ | Prop | Type | Description |
55
+ |------|------|-------------|
56
+ | `coordinates` | `Array<{ latitude, longitude }>` | Route waypoints (min 2). First = origin, last = destination. |
57
+ | `waypointIndices` | `number[]` | Which coordinates are full waypoints vs. via-points. |
58
+ | `locale` | `string` | Language for voice guidance and UI. Default: device locale. |
59
+ | `routeProfile` | `string` | Routing profile. Default: `"mapbox/driving-traffic"`. |
60
+ | `mute` | `boolean` | Mute voice guidance. |
61
+
62
+ ### Events
63
+
64
+ | Event | Payload | Description |
65
+ |-------|---------|-------------|
66
+ | `onRouteProgressChanged` | `{ distanceRemaining, durationRemaining, distanceTraveled, fractionTraveled }` | Fires as the user progresses along the route. |
67
+ | `onCancelNavigation` | — | User tapped cancel / back. |
68
+ | `onWaypointArrival` | `{ waypointIndex }` | Arrived at an intermediate waypoint. |
69
+ | `onFinalDestinationArrival` | — | Arrived at the final destination. |
70
+ | `onRouteChanged` | — | Route was recalculated (reroute). |
71
+ | `onUserOffRoute` | — | User went off the planned route. |
72
+ | `onError` | `{ message }` | Navigation error occurred. |
73
+
74
+ ## Installation (for consumers)
75
+
76
+ ```bash
77
+ npx expo install @baeckerherz/expo-mapbox-navigation
78
+ ```
79
+
80
+ Add the plugin to `app.config.ts`:
81
+
82
+ ```ts
83
+ plugins: [
84
+ ["@baeckerherz/expo-mapbox-navigation/plugin", {
85
+ mapboxAccessToken: "pk.eyJ1...",
86
+ mapboxSecretToken: "sk.eyJ1...", // for SPM download auth
87
+ navigationSdkVersion: "3.5.0",
88
+ }],
89
+ ]
90
+ ```
91
+
92
+ Rebuild native:
93
+
94
+ ```bash
95
+ npx expo prebuild --clean
96
+ npx expo run:ios
97
+ ```
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ cd @baeckerherz/expo-mapbox-navigation
103
+ yarn install
104
+ cd example && npx expo run:ios
105
+ ```
106
+
107
+ ## Project Structure
108
+
109
+ ```
110
+ src/ TypeScript API (component, types, exports)
111
+ ios/ Swift native module + podspec
112
+ android/ Kotlin native module + build.gradle
113
+ plugin/ Expo config plugins (SPM injection, Gradle setup)
114
+ example/ Test app
115
+ ```
116
+
117
+ ## Prerequisites
118
+
119
+ 1. A [Mapbox account](https://account.mapbox.com/) with Navigation SDK access
120
+ 2. A public access token (`pk.xxx`) and a secret/download token (`sk.xxx`)
121
+ 3. For iOS: `~/.netrc` must contain Mapbox credentials for SPM package resolution:
122
+
123
+ ```
124
+ machine api.mapbox.com
125
+ login mapbox
126
+ password sk.eyJ1...YOUR_SECRET_TOKEN
127
+ ```
128
+
129
+ ## How to test the example app
130
+
131
+ ```bash
132
+ cd @baeckerherz/expo-mapbox-navigation/example
133
+ yarn install
134
+ npx expo prebuild --clean
135
+ npx expo run:ios --device
136
+ ```
137
+
138
+ The example app navigates from your current location to Innsbruck Hauptbahnhof with German voice guidance.
139
+
140
+ ## Publishing to npm
141
+
142
+ ```bash
143
+ npm login
144
+ npm publish --access public
145
+ ```
146
+
147
+ Consumers install with:
148
+
149
+ ```bash
150
+ npx expo install @baeckerherz/expo-mapbox-navigation
151
+ ```
152
+
153
+ ## Key differences from existing wrappers
154
+
155
+ | | This module | @badatgil/expo-mapbox-navigation | @homee/react-native-mapbox-navigation |
156
+ |---|---|---|---|
157
+ | iOS SDK integration | SPM via config plugin (clean version bumps) | Vendored .xcframeworks (manual rebuild per update) | CocoaPods pinned to Nav SDK v2 |
158
+ | Android approach | Drop-in NavigationView | Custom UI from ~30 components (~1100 LOC) | Custom UI (~500 LOC) |
159
+ | Nav SDK version | v3 (current) | v3 | v2 (legacy) |
160
+ | Expo Module API | Yes (Fabric-ready) | Yes | No (legacy bridge) |
161
+ | Multi-waypoint | Yes | Yes | No (origin + destination only) |
162
+ | Maintenance | Active | Semi-active | Abandoned |
163
+
164
+ ## Status
165
+
166
+ **Prototype** — not production-ready. This is a proof of concept to validate:
167
+
168
+ 1. SPM injection via config plugin works reliably with Xcode + CocoaPods
169
+ 2. Drop-in NavigationView/NavigationViewController integration is sufficient
170
+ 3. Event bridging covers the required use cases
171
+
172
+ ### Known risks
173
+
174
+ - **SPM + CocoaPods coexistence**: Xcode may have trouble resolving both dependency managers. If this fails, fallback is to vendor `.xcframework` files (Approach A from the plan).
175
+ - **Android NavigationView completeness**: The drop-in `NavigationView` in Android Nav SDK v3 may require additional configuration for full feature parity with iOS.
176
+ - **Mapbox licensing**: The Navigation SDK requires a commercial Mapbox license for production use. This wrapper does not change that requirement.
@@ -0,0 +1,74 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'kotlin-android'
3
+ apply plugin: 'maven-publish'
4
+
5
+ group = 'expo.modules.mapboxnavigation'
6
+ version = '0.1.0'
7
+
8
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
9
+ if (expoModulesCorePlugin.exists()) {
10
+ apply from: expoModulesCorePlugin
11
+ applyKotlinExpoModulesCorePlugin()
12
+ }
13
+
14
+ buildscript {
15
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
16
+ if (expoModulesCorePlugin.exists()) {
17
+ apply from: expoModulesCorePlugin
18
+ applyKotlinExpoModulesCorePlugin()
19
+ }
20
+ }
21
+
22
+ android {
23
+ namespace "expo.modules.mapboxnavigation"
24
+
25
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
26
+
27
+ defaultConfig {
28
+ minSdkVersion safeExtGet("minSdkVersion", 23)
29
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
30
+ }
31
+
32
+ publishing {
33
+ singleVariant("release") {
34
+ withSourcesJar()
35
+ }
36
+ }
37
+
38
+ lintOptions {
39
+ abortOnError false
40
+ }
41
+
42
+ compileOptions {
43
+ sourceCompatibility JavaVersion.VERSION_17
44
+ targetCompatibility JavaVersion.VERSION_17
45
+ }
46
+
47
+ kotlinOptions {
48
+ jvmTarget = JavaVersion.VERSION_17.majorVersion
49
+ }
50
+ }
51
+
52
+ repositories {
53
+ mavenCentral()
54
+ maven {
55
+ url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
56
+ authentication {
57
+ basic(BasicAuthentication)
58
+ }
59
+ credentials {
60
+ username = "mapbox"
61
+ password = project.findProperty('MAPBOX_DOWNLOADS_TOKEN') ?: System.getenv('MAPBOX_DOWNLOADS_TOKEN') ?: ""
62
+ }
63
+ }
64
+ }
65
+
66
+ dependencies {
67
+ implementation project(':expo-modules-core')
68
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
69
+
70
+ // Mapbox Navigation SDK v3 - drop-in UI
71
+ implementation "com.mapbox.navigationcore:android:3.5.0"
72
+ implementation "com.mapbox.navigationcore:dropin:3.5.0"
73
+ implementation "com.mapbox.navigationcore:ui-maps:3.5.0"
74
+ }
@@ -0,0 +1,66 @@
1
+ package expo.modules.mapboxnavigation
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+
6
+ class ExpoMapboxNavigationModule : Module() {
7
+ override fun definition() = ModuleDefinition {
8
+ Name("ExpoMapboxNavigation")
9
+
10
+ View(ExpoMapboxNavigationView::class) {
11
+ Events(
12
+ "onRouteProgressChanged",
13
+ "onCancelNavigation",
14
+ "onWaypointArrival",
15
+ "onFinalDestinationArrival",
16
+ "onRouteChanged",
17
+ "onUserOffRoute",
18
+ "onError"
19
+ )
20
+
21
+ Prop("coordinates") { view: ExpoMapboxNavigationView, coordinates: List<Map<String, Double>> ->
22
+ view.setCoordinates(coordinates)
23
+ }
24
+
25
+ Prop("waypointIndices") { view: ExpoMapboxNavigationView, indices: List<Int> ->
26
+ view.waypointIndices = indices
27
+ }
28
+
29
+ Prop("locale") { view: ExpoMapboxNavigationView, locale: String ->
30
+ view.navigationLocale = locale
31
+ }
32
+
33
+ Prop("routeProfile") { view: ExpoMapboxNavigationView, profile: String ->
34
+ view.routeProfile = profile
35
+ }
36
+
37
+ Prop("mute") { view: ExpoMapboxNavigationView, mute: Boolean ->
38
+ view.isMuted = mute
39
+ }
40
+
41
+ Prop("mapStyle") { view: ExpoMapboxNavigationView, style: String ->
42
+ view.mapStyleURL = style
43
+ }
44
+
45
+ Prop("themeMode") { view: ExpoMapboxNavigationView, mode: String ->
46
+ view.themeMode = mode
47
+ }
48
+
49
+ Prop("accentColor") { view: ExpoMapboxNavigationView, color: String ->
50
+ view.accentColorHex = color
51
+ }
52
+
53
+ Prop("routeColor") { view: ExpoMapboxNavigationView, color: String ->
54
+ view.routeColorHex = color
55
+ }
56
+
57
+ Prop("bannerBackgroundColor") { view: ExpoMapboxNavigationView, color: String ->
58
+ view.bannerBackgroundColorHex = color
59
+ }
60
+
61
+ Prop("bannerTextColor") { view: ExpoMapboxNavigationView, color: String ->
62
+ view.bannerTextColorHex = color
63
+ }
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,164 @@
1
+ package expo.modules.mapboxnavigation
2
+
3
+ import android.content.Context
4
+ import android.widget.FrameLayout
5
+ import expo.modules.kotlin.AppContext
6
+ import expo.modules.kotlin.viewevent.EventDispatcher
7
+ import expo.modules.kotlin.views.ExpoView
8
+ import com.mapbox.geojson.Point
9
+ import com.mapbox.navigation.base.route.NavigationRoute
10
+ import com.mapbox.navigation.core.MapboxNavigation
11
+ import com.mapbox.navigation.core.lifecycle.MapboxNavigationApp
12
+ import com.mapbox.navigation.core.trip.session.RouteProgressObserver
13
+ import com.mapbox.navigation.core.trip.session.OffRouteObserver
14
+ import com.mapbox.navigation.core.directions.session.RoutesObserver
15
+ import com.mapbox.navigation.dropin.NavigationView
16
+ import com.mapbox.api.directions.v5.DirectionsCriteria
17
+ import com.mapbox.api.directions.v5.models.RouteOptions
18
+
19
+ class ExpoMapboxNavigationView(
20
+ context: Context,
21
+ appContext: AppContext
22
+ ) : ExpoView(context, appContext) {
23
+
24
+ private val onRouteProgressChanged by EventDispatcher()
25
+ private val onCancelNavigation by EventDispatcher()
26
+ private val onWaypointArrival by EventDispatcher()
27
+ private val onFinalDestinationArrival by EventDispatcher()
28
+ private val onRouteChanged by EventDispatcher()
29
+ private val onUserOffRoute by EventDispatcher()
30
+ private val onError by EventDispatcher()
31
+
32
+ private var coordinates: List<Point> = emptyList()
33
+ var waypointIndices: List<Int>? = null
34
+ var navigationLocale: String? = null
35
+ var routeProfile: String? = null
36
+ var isMuted: Boolean = false
37
+ var mapStyleURL: String? = null
38
+ var themeMode: String? = null
39
+ var accentColorHex: String? = null
40
+ var routeColorHex: String? = null
41
+ var bannerBackgroundColorHex: String? = null
42
+ var bannerTextColorHex: String? = null
43
+
44
+ private var navigationView: NavigationView? = null
45
+ private var hasStartedNavigation = false
46
+
47
+ fun setCoordinates(raw: List<Map<String, Double>>) {
48
+ coordinates = raw.mapNotNull { map ->
49
+ val lat = map["latitude"] ?: return@mapNotNull null
50
+ val lng = map["longitude"] ?: return@mapNotNull null
51
+ Point.fromLngLat(lng, lat)
52
+ }
53
+ startNavigationIfReady()
54
+ }
55
+
56
+ private fun startNavigationIfReady() {
57
+ if (coordinates.size < 2 || hasStartedNavigation) return
58
+ hasStartedNavigation = true
59
+
60
+ try {
61
+ val navView = NavigationView(context)
62
+ navView.layoutParams = FrameLayout.LayoutParams(
63
+ FrameLayout.LayoutParams.MATCH_PARENT,
64
+ FrameLayout.LayoutParams.MATCH_PARENT
65
+ )
66
+ addView(navView)
67
+ navigationView = navView
68
+
69
+ val profile = routeProfile ?: DirectionsCriteria.PROFILE_DRIVING_TRAFFIC
70
+ val routeOptions = RouteOptions.builder()
71
+ .coordinatesList(coordinates)
72
+ .profile(profile)
73
+ .alternatives(true)
74
+ .continueStraight(true)
75
+ .overview(DirectionsCriteria.OVERVIEW_FULL)
76
+ .steps(true)
77
+ .voiceInstructions(true)
78
+ .bannerInstructions(true)
79
+ .apply {
80
+ waypointIndices?.let { indices ->
81
+ waypointIndices(indices.joinToString(separator = ";"))
82
+ }
83
+ navigationLocale?.let { locale ->
84
+ language(locale)
85
+ }
86
+ }
87
+ .build()
88
+
89
+ MapboxNavigationApp.current()?.let { navigation ->
90
+ registerObservers(navigation)
91
+
92
+ navigation.requestRoutes(routeOptions, object :
93
+ com.mapbox.navigation.core.directions.session.RoutesRequestCallback {
94
+ override fun onRoutesReady(routes: List<NavigationRoute>) {
95
+ if (routes.isNotEmpty()) {
96
+ navigation.setNavigationRoutes(routes)
97
+ } else {
98
+ onError(mapOf("message" to "No routes found"))
99
+ }
100
+ }
101
+
102
+ override fun onRoutesRequestFailure(
103
+ throwable: Throwable,
104
+ routeOptions: RouteOptions
105
+ ) {
106
+ onError(mapOf("message" to "Route request failed: ${throwable.message}"))
107
+ }
108
+
109
+ override fun onRoutesRequestCanceled(routeOptions: RouteOptions) {
110
+ onError(mapOf("message" to "Route request cancelled"))
111
+ }
112
+ })
113
+ } ?: run {
114
+ onError(mapOf("message" to "MapboxNavigation not initialized"))
115
+ }
116
+ } catch (e: Exception) {
117
+ onError(mapOf("message" to "Navigation setup failed: ${e.message}"))
118
+ }
119
+ }
120
+
121
+ private fun registerObservers(navigation: MapboxNavigation) {
122
+ navigation.registerRouteProgressObserver(RouteProgressObserver { routeProgress ->
123
+ onRouteProgressChanged(mapOf(
124
+ "distanceRemaining" to routeProgress.distanceRemaining,
125
+ "durationRemaining" to routeProgress.durationRemaining,
126
+ "distanceTraveled" to routeProgress.distanceTraveled,
127
+ "fractionTraveled" to routeProgress.fractionTraveled
128
+ ))
129
+
130
+ val currentLegProgress = routeProgress.currentLegProgress
131
+ if (currentLegProgress != null) {
132
+ val distanceToEnd = currentLegProgress.distanceRemaining
133
+ if (distanceToEnd <= 30.0) {
134
+ val legIndex = routeProgress.currentLegProgress?.legIndex ?: 0
135
+ val totalLegs = routeProgress.route.legs()?.size ?: 1
136
+
137
+ if (legIndex == totalLegs - 1) {
138
+ onFinalDestinationArrival(emptyMap<String, Any>())
139
+ } else {
140
+ onWaypointArrival(mapOf("waypointIndex" to legIndex))
141
+ }
142
+ }
143
+ }
144
+ })
145
+
146
+ navigation.registerOffRouteObserver(OffRouteObserver { isOffRoute ->
147
+ if (isOffRoute) {
148
+ onUserOffRoute(emptyMap<String, Any>())
149
+ }
150
+ })
151
+
152
+ navigation.registerRoutesObserver(RoutesObserver { result ->
153
+ if (result.navigationRoutes.isNotEmpty()) {
154
+ onRouteChanged(emptyMap<String, Any>())
155
+ }
156
+ })
157
+ }
158
+
159
+ override fun onDetachedFromWindow() {
160
+ super.onDetachedFromWindow()
161
+ navigationView?.let { removeView(it) }
162
+ navigationView = null
163
+ }
164
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["ios", "android"],
3
+ "ios": {
4
+ "modules": ["ExpoMapboxNavigationModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.mapboxnavigation.ExpoMapboxNavigationModule"]
8
+ }
9
+ }
@@ -0,0 +1,26 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ExpoMapboxNavigation'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = 'Bäckerherz'
12
+ s.homepage = 'https://github.com/baeckerherz/expo-mapbox-navigation'
13
+ s.platforms = { ios: '15.0' }
14
+ s.source = { git: '' }
15
+ s.static_framework = true
16
+
17
+ s.dependency 'ExpoModulesCore'
18
+
19
+ s.source_files = '**/*.{h,m,mm,swift}'
20
+ s.exclude_files = 'Tests/'
21
+
22
+ s.pod_target_xcconfig = {
23
+ 'DEFINES_MODULE' => 'YES',
24
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule',
25
+ }
26
+ end
@@ -0,0 +1,66 @@
1
+ import ExpoModulesCore
2
+
3
+ public class ExpoMapboxNavigationModule: Module {
4
+ public func definition() -> ModuleDefinition {
5
+ Name("ExpoMapboxNavigation")
6
+
7
+ View(ExpoMapboxNavigationView.self) {
8
+ Events(
9
+ "onRouteProgressChanged",
10
+ "onCancelNavigation",
11
+ "onWaypointArrival",
12
+ "onFinalDestinationArrival",
13
+ "onRouteChanged",
14
+ "onUserOffRoute",
15
+ "onError"
16
+ )
17
+
18
+ // Route
19
+ Prop("coordinates") { (view, coordinates: [[String: Double]]) in
20
+ view.setCoordinates(coordinates)
21
+ }
22
+
23
+ Prop("waypointIndices") { (view, indices: [Int]) in
24
+ view.waypointIndices = indices
25
+ }
26
+
27
+ Prop("routeProfile") { (view, profile: String) in
28
+ view.routeProfile = profile
29
+ }
30
+
31
+ // Localization
32
+ Prop("locale") { (view, locale: String) in
33
+ view.navigationLocale = locale
34
+ }
35
+
36
+ Prop("mute") { (view, mute: Bool) in
37
+ view.isMuted = mute
38
+ }
39
+
40
+ // Appearance
41
+ Prop("mapStyle") { (view, style: String) in
42
+ view.mapStyleURL = style
43
+ }
44
+
45
+ Prop("themeMode") { (view, mode: String) in
46
+ view.themeMode = mode
47
+ }
48
+
49
+ Prop("accentColor") { (view, color: String) in
50
+ view.accentColorHex = color
51
+ }
52
+
53
+ Prop("routeColor") { (view, color: String) in
54
+ view.routeColorHex = color
55
+ }
56
+
57
+ Prop("bannerBackgroundColor") { (view, color: String) in
58
+ view.bannerBackgroundColorHex = color
59
+ }
60
+
61
+ Prop("bannerTextColor") { (view, color: String) in
62
+ view.bannerTextColorHex = color
63
+ }
64
+ }
65
+ }
66
+ }