@atomiqlab/react-native-mapbox-navigation 1.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.
@@ -0,0 +1,298 @@
1
+ import ExpoModulesCore
2
+ import MapboxNavigation
3
+ import MapboxDirections
4
+ import MapboxCoreNavigation
5
+ import CoreLocation
6
+ import UIKit
7
+
8
+ class MapboxNavigationView: ExpoView {
9
+ var startOrigin: [String: Double]? {
10
+ didSet { startNavigationIfReady() }
11
+ }
12
+ var destination: [String: Any]? {
13
+ didSet { startNavigationIfReady() }
14
+ }
15
+ var waypoints: [[String: Any]]? {
16
+ didSet { startNavigationIfReady() }
17
+ }
18
+ var shouldSimulateRoute: Bool = false {
19
+ didSet { startNavigationIfReady() }
20
+ }
21
+ var showCancelButton: Bool = true
22
+ var mute: Bool = false
23
+ var voiceVolume: Double = 1
24
+ var cameraPitch: Double?
25
+ var cameraZoom: Double?
26
+ var cameraMode: String = "following"
27
+ var mapStyleUri: String?
28
+ var routeAlternatives: Bool = false
29
+ var showsSpeedLimits: Bool = true
30
+ var showsWayNameLabel: Bool = true
31
+ var distanceUnit: String = "metric"
32
+ var language: String = "en"
33
+
34
+ private var navigationViewController: NavigationViewController?
35
+ private var hostViewController: UIViewController?
36
+ private var isRouteCalculationInProgress = false
37
+
38
+ let onLocationChange = EventDispatcher()
39
+ let onRouteProgressChange = EventDispatcher()
40
+ let onBannerInstruction = EventDispatcher()
41
+ let onArrive = EventDispatcher()
42
+ let onCancelNavigation = EventDispatcher()
43
+ let onError = EventDispatcher()
44
+
45
+ required init(appContext: AppContext? = nil) {
46
+ super.init(appContext: appContext)
47
+ setupView()
48
+ }
49
+
50
+ private func setupView() {
51
+ backgroundColor = .black
52
+ }
53
+
54
+ override func didMoveToWindow() {
55
+ super.didMoveToWindow()
56
+
57
+ if window != nil {
58
+ startNavigationIfReady()
59
+ }
60
+ }
61
+
62
+ private func startNavigationIfReady() {
63
+ guard navigationViewController == nil else {
64
+ return
65
+ }
66
+ guard !isRouteCalculationInProgress else {
67
+ return
68
+ }
69
+ guard let origin = startOrigin,
70
+ let dest = destination,
71
+ let originLat = origin["latitude"],
72
+ let originLng = origin["longitude"],
73
+ let destLat = dest["latitude"] as? Double,
74
+ let destLng = dest["longitude"] as? Double else {
75
+ return
76
+ }
77
+
78
+ let originCoord = CLLocationCoordinate2D(latitude: originLat, longitude: originLng)
79
+ let destCoord = CLLocationCoordinate2D(latitude: destLat, longitude: destLng)
80
+
81
+ var waypointsList = [Waypoint(coordinate: originCoord)]
82
+
83
+ // Add intermediate waypoints
84
+ if let intermediateWaypoints = waypoints {
85
+ for wp in intermediateWaypoints {
86
+ if let lat = wp["latitude"] as? Double,
87
+ let lng = wp["longitude"] as? Double {
88
+ let coord = CLLocationCoordinate2D(latitude: lat, longitude: lng)
89
+ let waypoint = Waypoint(coordinate: coord)
90
+ waypoint.name = wp["name"] as? String
91
+ waypointsList.append(waypoint)
92
+ }
93
+ }
94
+ }
95
+
96
+ // Add final destination
97
+ let finalWaypoint = Waypoint(coordinate: destCoord)
98
+ finalWaypoint.name = (dest["name"] as? String) ?? (dest["title"] as? String) ?? "Destination"
99
+ waypointsList.append(finalWaypoint)
100
+
101
+ let routeOptions = NavigationRouteOptions(waypoints: waypointsList)
102
+ routeOptions.locale = Locale(identifier: language)
103
+ routeOptions.distanceMeasurementSystem = distanceUnit == "imperial" ? .imperial : .metric
104
+ routeOptions.includesAlternativeRoutes = routeAlternatives
105
+
106
+ isRouteCalculationInProgress = true
107
+ Directions.shared.calculate(routeOptions) { [weak self] (_, result) in
108
+ guard let self = self else { return }
109
+ self.isRouteCalculationInProgress = false
110
+
111
+ switch result {
112
+ case .success(let response):
113
+ guard response.routes?.first != nil else {
114
+ self.onError([
115
+ "code": "NO_ROUTE",
116
+ "message": "No route found"
117
+ ])
118
+ return
119
+ }
120
+
121
+ DispatchQueue.main.async {
122
+ self.embedNavigation(response: response, routeOptions: routeOptions)
123
+ }
124
+
125
+ case .failure(let error):
126
+ self.onError([
127
+ "code": "ROUTE_ERROR",
128
+ "message": error.localizedDescription
129
+ ])
130
+ }
131
+ }
132
+ }
133
+
134
+ private func embedNavigation(response: RouteResponse, routeOptions: NavigationRouteOptions) {
135
+ let indexedRouteResponse = IndexedRouteResponse(routeResponse: response, routeIndex: 0)
136
+ let navigationService = MapboxNavigationService(
137
+ indexedRouteResponse: indexedRouteResponse,
138
+ credentials: Directions.shared.credentials,
139
+ simulating: shouldSimulateRoute ? .always : nil
140
+ )
141
+
142
+ let navigationOptions = buildNavigationOptions(navigationService: navigationService)
143
+
144
+ let viewController = NavigationViewController(
145
+ for: indexedRouteResponse,
146
+ navigationOptions: navigationOptions
147
+ )
148
+
149
+ viewController.delegate = self
150
+
151
+ NavigationSettings.shared.distanceUnit = distanceUnit == "imperial" ? .mile : .kilometer
152
+ NavigationSettings.shared.voiceMuted = mute
153
+ NavigationSettings.shared.voiceVolume = Float(max(0, min(voiceVolume, 1)))
154
+ viewController.showsSpeedLimits = showsSpeedLimits
155
+ applyCameraConfiguration(to: viewController)
156
+
157
+ // Find the parent view controller
158
+ var parentVC: UIViewController? = self.window?.rootViewController
159
+ while let presented = parentVC?.presentedViewController {
160
+ parentVC = presented
161
+ }
162
+
163
+ if let parent = parentVC {
164
+ // Add as child view controller
165
+ parent.addChild(viewController)
166
+ addSubview(viewController.view)
167
+ viewController.view.frame = bounds
168
+ viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
169
+ viewController.didMove(toParent: parent)
170
+
171
+ navigationViewController = viewController
172
+ hostViewController = parent
173
+ }
174
+ }
175
+
176
+ override func layoutSubviews() {
177
+ super.layoutSubviews()
178
+ navigationViewController?.view.frame = bounds
179
+ }
180
+
181
+ deinit {
182
+ cleanupNavigation()
183
+ }
184
+
185
+ private func cleanupNavigation() {
186
+ navigationViewController?.willMove(toParent: nil)
187
+ navigationViewController?.view.removeFromSuperview()
188
+ navigationViewController?.removeFromParent()
189
+ navigationViewController = nil
190
+ }
191
+
192
+ private func applyCameraConfiguration(to viewController: NavigationViewController) {
193
+ guard
194
+ let navigationMapView = viewController.navigationMapView,
195
+ let viewportDataSource = navigationMapView.navigationCamera
196
+ .viewportDataSource as? NavigationViewportDataSource else {
197
+ return
198
+ }
199
+
200
+ let normalizedMode = cameraMode.lowercased()
201
+
202
+ if normalizedMode == "overview" {
203
+ viewportDataSource.options.followingCameraOptions.zoomUpdatesAllowed = false
204
+ viewportDataSource.followingMobileCamera.zoom = CGFloat(cameraZoom ?? 10)
205
+ viewportDataSource.options.followingCameraOptions.pitchUpdatesAllowed = false
206
+ viewportDataSource.followingMobileCamera.pitch = 0
207
+ } else {
208
+ // Keep dynamic camera updates in following mode so turn-by-turn camera behavior
209
+ // (zoom/pitch/bearing adaptation) remains managed by the SDK.
210
+ viewportDataSource.options.followingCameraOptions.pitchUpdatesAllowed = true
211
+ viewportDataSource.options.followingCameraOptions.zoomUpdatesAllowed = true
212
+ viewportDataSource.options.followingCameraOptions.bearingUpdatesAllowed = true
213
+
214
+ if let pitch = cameraPitch {
215
+ viewportDataSource.followingMobileCamera.pitch = CGFloat(max(0, min(pitch, 85)))
216
+ }
217
+
218
+ if let zoom = cameraZoom {
219
+ viewportDataSource.followingMobileCamera.zoom = CGFloat(max(1, min(zoom, 22)))
220
+ }
221
+ }
222
+
223
+ navigationMapView.navigationCamera.follow()
224
+ }
225
+
226
+ private func buildNavigationOptions(navigationService: NavigationService) -> NavigationOptions {
227
+ guard
228
+ let styleUri = mapStyleUri?.trimmingCharacters(in: .whitespacesAndNewlines),
229
+ !styleUri.isEmpty,
230
+ let styleURL = URL(string: styleUri)
231
+ else {
232
+ return NavigationOptions(navigationService: navigationService)
233
+ }
234
+
235
+ let dayStyle = DayStyle()
236
+ dayStyle.mapStyleURL = styleURL
237
+
238
+ let nightStyle = NightStyle()
239
+ nightStyle.mapStyleURL = styleURL
240
+
241
+ return NavigationOptions(styles: [dayStyle, nightStyle], navigationService: navigationService)
242
+ }
243
+ }
244
+
245
+ // MARK: - NavigationViewControllerDelegate
246
+ extension MapboxNavigationView: NavigationViewControllerDelegate {
247
+ func navigationViewController(
248
+ _ navigationViewController: NavigationViewController,
249
+ didUpdate progress: RouteProgress,
250
+ with location: CLLocation,
251
+ rawLocation: CLLocation
252
+ ) {
253
+ if cameraMode.lowercased() == "following" {
254
+ navigationViewController.navigationMapView?.navigationCamera.follow()
255
+ }
256
+
257
+ onLocationChange([
258
+ "latitude": location.coordinate.latitude,
259
+ "longitude": location.coordinate.longitude,
260
+ "bearing": location.course,
261
+ "speed": location.speed,
262
+ "altitude": location.altitude,
263
+ "accuracy": location.horizontalAccuracy
264
+ ])
265
+
266
+ onRouteProgressChange([
267
+ "distanceTraveled": progress.distanceTraveled,
268
+ "distanceRemaining": progress.distanceRemaining,
269
+ "durationRemaining": progress.durationRemaining,
270
+ "fractionTraveled": progress.fractionTraveled
271
+ ])
272
+
273
+ onBannerInstruction([
274
+ "primaryText": progress.currentLegProgress.currentStep.instructions,
275
+ "stepDistanceRemaining": progress.currentLegProgress.currentStepProgress.distanceRemaining
276
+ ])
277
+ }
278
+
279
+ func navigationViewController(
280
+ _ navigationViewController: NavigationViewController,
281
+ didArriveAt waypoint: Waypoint
282
+ ) -> Bool {
283
+ onArrive([
284
+ "name": waypoint.name ?? ""
285
+ ])
286
+ return true
287
+ }
288
+
289
+ func navigationViewControllerDidDismiss(
290
+ _ navigationViewController: NavigationViewController,
291
+ byCanceling canceled: Bool
292
+ ) {
293
+ if canceled {
294
+ onCancelNavigation([:])
295
+ }
296
+ cleanupNavigation()
297
+ }
298
+ }
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@atomiqlab/react-native-mapbox-navigation",
3
+ "version": "1.1.0",
4
+ "description": "Native Mapbox turn-by-turn navigation for Expo and React Native (iOS + Android)",
5
+ "main": "src/index.tsx",
6
+ "types": "src/index.tsx",
7
+ "react-native": "src/index.tsx",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.tsx",
11
+ "default": "./src/index.tsx"
12
+ },
13
+ "./app.plugin.js": "./app.plugin.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "sideEffects": false,
17
+ "files": [
18
+ "android/src",
19
+ "android/build.gradle",
20
+ "android/src/main/AndroidManifest.xml",
21
+ "ios/ExpoMapboxNavigationNative.podspec",
22
+ "ios/MapboxNavigationModule.swift",
23
+ "ios/MapboxNavigationView.swift",
24
+ "src",
25
+ "app.plugin.js",
26
+ "expo-module.config.json",
27
+ "README.md",
28
+ "QUICKSTART.md",
29
+ "CHANGELOG.md",
30
+ "docs",
31
+ "scripts"
32
+ ],
33
+ "scripts": {
34
+ "build": "expo-module build",
35
+ "clean": "expo-module clean",
36
+ "lint": "expo-module lint",
37
+ "test": "expo-module test",
38
+ "verify": "node ./scripts/verify-release.mjs"
39
+ },
40
+ "keywords": [
41
+ "react-native",
42
+ "expo",
43
+ "mapbox",
44
+ "navigation",
45
+ "turn-by-turn"
46
+ ],
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/ATOMIQTECH/react-native-mapbox-navigation"
51
+ },
52
+ "homepage": "https://github.com/ATOMIQTECH/react-native-mapbox-navigation#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/ATOMIQTECH/react-native-mapbox-navigation/issues"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "engines": {
60
+ "node": ">=18"
61
+ },
62
+ "expo": {
63
+ "plugins": [
64
+ "./app.plugin.js"
65
+ ]
66
+ },
67
+ "peerDependencies": {
68
+ "expo": ">=50",
69
+ "react": "*",
70
+ "react-native": "*"
71
+ },
72
+ "devDependencies": {
73
+ "expo-module-scripts": "^3.0.0"
74
+ }
75
+ }
@@ -0,0 +1,115 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const moduleDir = path.resolve(path.dirname(__filename), "..");
8
+ const repoRoot = path.resolve(moduleDir, "..", "..");
9
+ const androidDir = path.join(repoRoot, "android");
10
+ const envFilePath = path.join(repoRoot, ".env");
11
+
12
+ function loadDotEnv(filePath) {
13
+ if (!existsSync(filePath)) {
14
+ return;
15
+ }
16
+
17
+ const content = readFileSync(filePath, "utf8");
18
+ for (const line of content.split(/\r?\n/)) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
21
+ continue;
22
+ }
23
+
24
+ const separatorIndex = trimmed.indexOf("=");
25
+ const key = trimmed.slice(0, separatorIndex).trim();
26
+ const rawValue = trimmed.slice(separatorIndex + 1).trim();
27
+ if (!key || process.env[key]) {
28
+ continue;
29
+ }
30
+
31
+ let value = rawValue;
32
+ if (
33
+ (value.startsWith('"') && value.endsWith('"')) ||
34
+ (value.startsWith("'") && value.endsWith("'"))
35
+ ) {
36
+ value = value.slice(1, -1);
37
+ }
38
+ process.env[key] = value;
39
+ }
40
+ }
41
+
42
+ function run(command, cwd = moduleDir, options = {}) {
43
+ const { optionalOnOfflineGradle = false } = options;
44
+ console.log(`\n> ${command} (${cwd})`);
45
+ try {
46
+ const output = execSync(command, {
47
+ cwd,
48
+ stdio: "pipe",
49
+ env: {
50
+ ...process.env,
51
+ GRADLE_USER_HOME:
52
+ process.env.GRADLE_USER_HOME || "/tmp/gradle-user-home-react-native-mapbox-navigation",
53
+ },
54
+ });
55
+ if (output?.length) {
56
+ process.stdout.write(output);
57
+ }
58
+ } catch (error) {
59
+ const stdout = String(error?.stdout || "");
60
+ const stderr = String(error?.stderr || "");
61
+ if (stdout) {
62
+ process.stdout.write(stdout);
63
+ }
64
+ if (stderr) {
65
+ process.stderr.write(stderr);
66
+ }
67
+
68
+ const combined = `${stdout}\n${stderr}\n${String(error?.message || "")}`;
69
+ const skippableGradleEnvironmentIssue =
70
+ combined.includes("UnknownHostException") ||
71
+ combined.includes("FileLockContentionHandler") ||
72
+ combined.includes("SocketException: Operation not permitted");
73
+
74
+ if (optionalOnOfflineGradle && skippableGradleEnvironmentIssue) {
75
+ console.warn(`\nSkipping optional Gradle check due to restricted/offline environment: ${command}`);
76
+ return;
77
+ }
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ function runWithFallback(commands, cwd, options = {}) {
83
+ let lastError;
84
+ for (const command of commands) {
85
+ try {
86
+ run(command, cwd, options);
87
+ return;
88
+ } catch (error) {
89
+ lastError = error;
90
+ console.warn(`\nCommand failed, trying fallback: ${command}`);
91
+ }
92
+ }
93
+ throw lastError;
94
+ }
95
+
96
+ loadDotEnv(envFilePath);
97
+
98
+ run("npx tsc --noEmit", repoRoot);
99
+
100
+ if (existsSync(path.join(androidDir, "gradlew"))) {
101
+ runWithFallback(
102
+ [
103
+ "./gradlew :react-native-mapbox-navigation:compileDebugKotlin",
104
+ "./gradlew :mapbox-navigation-native:compileDebugKotlin",
105
+ ],
106
+ androidDir,
107
+ { optionalOnOfflineGradle: true }
108
+ );
109
+ } else {
110
+ console.log("\n> Skipping Android compile check (android/gradlew not found).");
111
+ }
112
+
113
+ run("npm pack --dry-run --cache /tmp/npm-cache-react-native-mapbox-navigation", moduleDir);
114
+
115
+ console.log("\nRelease verification completed.");
@@ -0,0 +1,136 @@
1
+ export type Coordinate = {
2
+ latitude: number;
3
+ longitude: number;
4
+ };
5
+
6
+ export type Waypoint = Coordinate & {
7
+ name?: string;
8
+ };
9
+
10
+ export type NavigationOptions = {
11
+ startOrigin?: Coordinate;
12
+ destination: Waypoint;
13
+ waypoints?: Waypoint[];
14
+ shouldSimulateRoute?: boolean;
15
+ uiTheme?: 'system' | 'light' | 'dark' | 'day' | 'night';
16
+ routeAlternatives?: boolean;
17
+ distanceUnit?: 'metric' | 'imperial';
18
+ language?: string;
19
+ mute?: boolean;
20
+ voiceVolume?: number;
21
+ cameraPitch?: number;
22
+ cameraZoom?: number;
23
+ cameraMode?: 'following' | 'overview';
24
+ mapStyleUri?: string;
25
+ mapStyleUriDay?: string;
26
+ mapStyleUriNight?: string;
27
+ showsSpeedLimits?: boolean;
28
+ showsWayNameLabel?: boolean;
29
+ showsTripProgress?: boolean;
30
+ showsManeuverView?: boolean;
31
+ showsActionButtons?: boolean;
32
+ };
33
+
34
+ export type NavigationSettings = {
35
+ isNavigating: boolean;
36
+ mute: boolean;
37
+ voiceVolume: number;
38
+ distanceUnit: 'metric' | 'imperial';
39
+ language: string;
40
+ };
41
+
42
+ export type LocationUpdate = {
43
+ latitude: number;
44
+ longitude: number;
45
+ bearing?: number;
46
+ speed?: number;
47
+ altitude?: number;
48
+ accuracy?: number;
49
+ };
50
+
51
+ export type RouteProgress = {
52
+ distanceTraveled: number;
53
+ distanceRemaining: number;
54
+ durationRemaining: number;
55
+ fractionTraveled: number;
56
+ };
57
+
58
+ export type ArrivalEvent = {
59
+ index?: number;
60
+ name?: string;
61
+ };
62
+
63
+ export type NavigationError = {
64
+ code: string;
65
+ message: string;
66
+ };
67
+
68
+ export type BannerInstruction = {
69
+ primaryText: string;
70
+ secondaryText?: string;
71
+ stepDistanceRemaining?: number;
72
+ };
73
+
74
+ export type Subscription = {
75
+ remove: () => void;
76
+ };
77
+
78
+ export interface MapboxNavigationModule {
79
+ // Start navigation with options
80
+ startNavigation(options: NavigationOptions): Promise<void>;
81
+
82
+ // Stop/cancel navigation
83
+ stopNavigation(): Promise<void>;
84
+
85
+ // Mute/unmute voice guidance
86
+ setMuted(muted: boolean): Promise<void>;
87
+
88
+ // Set voice volume (0.0 - 1.0)
89
+ setVoiceVolume(volume: number): Promise<void>;
90
+
91
+ // Set distance unit used by spoken and visual instructions
92
+ setDistanceUnit(unit: 'metric' | 'imperial'): Promise<void>;
93
+
94
+ // Set route instruction language (BCP-47, e.g. 'en', 'fr')
95
+ setLanguage(language: string): Promise<void>;
96
+
97
+ // Check if navigation is active
98
+ isNavigating(): Promise<boolean>;
99
+
100
+ // Get current native navigation settings
101
+ getNavigationSettings(): Promise<NavigationSettings>;
102
+ }
103
+
104
+ export interface MapboxNavigationViewProps {
105
+ style?: any;
106
+ startOrigin?: Coordinate;
107
+ destination: Waypoint;
108
+ waypoints?: Waypoint[];
109
+ shouldSimulateRoute?: boolean;
110
+ showCancelButton?: boolean;
111
+ uiTheme?: 'system' | 'light' | 'dark' | 'day' | 'night';
112
+ distanceUnit?: 'metric' | 'imperial';
113
+ language?: string;
114
+ mute?: boolean;
115
+ voiceVolume?: number;
116
+ cameraPitch?: number;
117
+ cameraZoom?: number;
118
+ cameraMode?: 'following' | 'overview';
119
+ mapStyleUri?: string;
120
+ mapStyleUriDay?: string;
121
+ mapStyleUriNight?: string;
122
+ routeAlternatives?: boolean;
123
+ showsSpeedLimits?: boolean;
124
+ showsWayNameLabel?: boolean;
125
+ showsTripProgress?: boolean;
126
+ showsManeuverView?: boolean;
127
+ showsActionButtons?: boolean;
128
+
129
+ // Event callbacks
130
+ onLocationChange?: (location: LocationUpdate) => void;
131
+ onRouteProgressChange?: (progress: RouteProgress) => void;
132
+ onArrive?: (point: ArrivalEvent) => void;
133
+ onCancelNavigation?: () => void;
134
+ onError?: (error: NavigationError) => void;
135
+ onBannerInstruction?: (instruction: BannerInstruction) => void;
136
+ }