@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,613 @@
1
+ import ExpoModulesCore
2
+ import MapboxNavigation
3
+ import MapboxDirections
4
+ import MapboxCoreNavigation
5
+ import CoreLocation
6
+ import UIKit
7
+
8
+ public class MapboxNavigationModule: Module {
9
+ private var navigationViewController: NavigationViewController?
10
+ private var isCurrentlyNavigating = false
11
+ private var currentLanguage = Locale.preferredLanguages.first ?? "en"
12
+ private var currentCameraMode = "following"
13
+ private var currentLocationResolver: CurrentLocationResolver?
14
+
15
+ public func definition() -> ModuleDefinition {
16
+ Name("MapboxNavigationModule")
17
+
18
+ // Events that can be sent to JS
19
+ Events(
20
+ "onLocationChange",
21
+ "onRouteProgressChange",
22
+ "onBannerInstruction",
23
+ "onArrive",
24
+ "onCancelNavigation",
25
+ "onError"
26
+ )
27
+
28
+ // Start navigation
29
+ AsyncFunction("startNavigation") { (options: NavigationStartOptions, promise: Promise) in
30
+ DispatchQueue.main.async {
31
+ self.startNavigation(options: options, promise: promise)
32
+ }
33
+ }
34
+
35
+ // Stop navigation
36
+ AsyncFunction("stopNavigation") { (promise: Promise) in
37
+ DispatchQueue.main.async {
38
+ self.stopNavigation(promise: promise)
39
+ }
40
+ }
41
+
42
+ // Set muted state
43
+ AsyncFunction("setMuted") { (muted: Bool, promise: Promise) in
44
+ DispatchQueue.main.async {
45
+ self.setMuted(muted: muted, promise: promise)
46
+ }
47
+ }
48
+
49
+ AsyncFunction("setVoiceVolume") { (volume: Double, promise: Promise) in
50
+ DispatchQueue.main.async {
51
+ self.setVoiceVolume(volume: volume, promise: promise)
52
+ }
53
+ }
54
+
55
+ AsyncFunction("setDistanceUnit") { (unit: String, promise: Promise) in
56
+ DispatchQueue.main.async {
57
+ self.setDistanceUnit(unit: unit, promise: promise)
58
+ }
59
+ }
60
+
61
+ AsyncFunction("setLanguage") { (language: String, promise: Promise) in
62
+ DispatchQueue.main.async {
63
+ self.setLanguage(language: language, promise: promise)
64
+ }
65
+ }
66
+
67
+ // Check if navigating
68
+ AsyncFunction("isNavigating") { (promise: Promise) in
69
+ promise.resolve(self.isCurrentlyNavigating)
70
+ }
71
+
72
+ AsyncFunction("getNavigationSettings") { (promise: Promise) in
73
+ let unit: String = NavigationSettings.shared.distanceUnit == .mile ? "imperial" : "metric"
74
+ promise.resolve([
75
+ "isNavigating": self.isCurrentlyNavigating,
76
+ "mute": NavigationSettings.shared.voiceMuted,
77
+ "voiceVolume": NavigationSettings.shared.voiceVolume,
78
+ "distanceUnit": unit,
79
+ "language": self.currentLanguage
80
+ ])
81
+ }
82
+
83
+ // View for embedded navigation
84
+ View(MapboxNavigationView.self) {
85
+ Events(
86
+ "onLocationChange",
87
+ "onRouteProgressChange",
88
+ "onBannerInstruction",
89
+ "onArrive",
90
+ "onCancelNavigation",
91
+ "onError"
92
+ )
93
+
94
+ Prop("startOrigin") { (view: MapboxNavigationView, origin: [String: Double]) in
95
+ view.startOrigin = origin
96
+ }
97
+
98
+ Prop("destination") { (view: MapboxNavigationView, destination: [String: Any]) in
99
+ view.destination = destination
100
+ }
101
+
102
+ Prop("waypoints") { (view: MapboxNavigationView, waypoints: [[String: Any]]?) in
103
+ view.waypoints = waypoints
104
+ }
105
+
106
+ Prop("shouldSimulateRoute") { (view: MapboxNavigationView, simulate: Bool) in
107
+ view.shouldSimulateRoute = simulate
108
+ }
109
+
110
+ Prop("showCancelButton") { (view: MapboxNavigationView, show: Bool) in
111
+ view.showCancelButton = show
112
+ }
113
+
114
+ Prop("mute") { (view: MapboxNavigationView, mute: Bool) in
115
+ view.mute = mute
116
+ }
117
+
118
+ Prop("voiceVolume") { (view: MapboxNavigationView, volume: Double) in
119
+ view.voiceVolume = volume
120
+ }
121
+
122
+ Prop("cameraPitch") { (view: MapboxNavigationView, pitch: Double) in
123
+ view.cameraPitch = pitch
124
+ }
125
+
126
+ Prop("cameraZoom") { (view: MapboxNavigationView, zoom: Double) in
127
+ view.cameraZoom = zoom
128
+ }
129
+
130
+ Prop("cameraMode") { (view: MapboxNavigationView, mode: String) in
131
+ view.cameraMode = mode
132
+ }
133
+
134
+ Prop("mapStyleUri") { (view: MapboxNavigationView, mapStyleUri: String) in
135
+ view.mapStyleUri = mapStyleUri
136
+ }
137
+
138
+ Prop("routeAlternatives") { (view: MapboxNavigationView, routeAlternatives: Bool) in
139
+ view.routeAlternatives = routeAlternatives
140
+ }
141
+
142
+ Prop("showsSpeedLimits") { (view: MapboxNavigationView, showsSpeedLimits: Bool) in
143
+ view.showsSpeedLimits = showsSpeedLimits
144
+ }
145
+
146
+ Prop("showsWayNameLabel") { (view: MapboxNavigationView, showsWayNameLabel: Bool) in
147
+ view.showsWayNameLabel = showsWayNameLabel
148
+ }
149
+
150
+ Prop("distanceUnit") { (view: MapboxNavigationView, unit: String) in
151
+ view.distanceUnit = unit
152
+ }
153
+
154
+ Prop("language") { (view: MapboxNavigationView, language: String) in
155
+ view.language = language
156
+ }
157
+ }
158
+ }
159
+
160
+ private func startNavigation(options: NavigationStartOptions, promise: Promise) {
161
+ guard let destination = options.destination.toCLLocationCoordinate2D() else {
162
+ promise.reject("INVALID_COORDINATES", "Invalid coordinates provided")
163
+ return
164
+ }
165
+
166
+ if let origin = options.startOrigin?.toCLLocationCoordinate2D() {
167
+ startNavigation(
168
+ origin: origin,
169
+ destination: destination,
170
+ options: options,
171
+ promise: promise
172
+ )
173
+ return
174
+ }
175
+
176
+ resolveCurrentOrigin { [weak self] result in
177
+ guard let self = self else { return }
178
+ switch result {
179
+ case .success(let origin):
180
+ self.startNavigation(
181
+ origin: origin,
182
+ destination: destination,
183
+ options: options,
184
+ promise: promise
185
+ )
186
+ case .failure(let error):
187
+ promise.reject("CURRENT_LOCATION_UNAVAILABLE", error.localizedDescription)
188
+ }
189
+ }
190
+ }
191
+
192
+ private func startNavigation(
193
+ origin: CLLocationCoordinate2D,
194
+ destination: CLLocationCoordinate2D,
195
+ options: NavigationStartOptions,
196
+ promise: Promise
197
+ ) {
198
+
199
+ var waypoints = [Waypoint(coordinate: origin)]
200
+
201
+ // Add intermediate waypoints if provided
202
+ if let intermediateWaypoints = options.waypoints {
203
+ for wp in intermediateWaypoints {
204
+ if let coord = wp.toCLLocationCoordinate2D() {
205
+ let waypoint = Waypoint(coordinate: coord)
206
+ waypoint.name = wp.name
207
+ waypoints.append(waypoint)
208
+ }
209
+ }
210
+ }
211
+
212
+ // Add final destination
213
+ let finalWaypoint = Waypoint(coordinate: destination)
214
+ finalWaypoint.name = options.destination.name ?? "Destination"
215
+ waypoints.append(finalWaypoint)
216
+
217
+ let routeOptions = NavigationRouteOptions(waypoints: waypoints)
218
+ routeOptions.locale = Locale(identifier: options.language ?? currentLanguage)
219
+ routeOptions.distanceMeasurementSystem = options.distanceUnit == "imperial" ? .imperial : .metric
220
+ routeOptions.includesAlternativeRoutes = options.routeAlternatives == true
221
+
222
+ Directions.shared.calculate(routeOptions) { [weak self] (_, result) in
223
+ guard let self = self else { return }
224
+
225
+ switch result {
226
+ case .success(let response):
227
+ guard response.routes?.first != nil else {
228
+ promise.reject("NO_ROUTE", "No route found")
229
+ return
230
+ }
231
+
232
+ let indexedRouteResponse = IndexedRouteResponse(routeResponse: response, routeIndex: 0)
233
+ let navigationService = MapboxNavigationService(
234
+ indexedRouteResponse: indexedRouteResponse,
235
+ credentials: Directions.shared.credentials,
236
+ simulating: options.shouldSimulateRoute == true ? .always : nil
237
+ )
238
+
239
+ let navigationOptions = self.buildNavigationOptions(
240
+ navigationService: navigationService,
241
+ mapStyleUri: options.mapStyleUri
242
+ )
243
+
244
+ let viewController = NavigationViewController(
245
+ for: indexedRouteResponse,
246
+ navigationOptions: navigationOptions
247
+ )
248
+
249
+ viewController.delegate = self
250
+ viewController.modalPresentationStyle = .fullScreen
251
+
252
+ self.currentLanguage = options.language ?? self.currentLanguage
253
+ NavigationSettings.shared.distanceUnit = options.distanceUnit == "imperial" ? .mile : .kilometer
254
+ NavigationSettings.shared.voiceMuted = options.mute == true
255
+ if let volume = options.voiceVolume {
256
+ NavigationSettings.shared.voiceVolume = Float(max(0, min(volume, 1)))
257
+ }
258
+ self.applyCameraConfiguration(
259
+ to: viewController,
260
+ mode: options.cameraMode,
261
+ pitch: options.cameraPitch,
262
+ zoom: options.cameraZoom
263
+ )
264
+ self.currentCameraMode = options.cameraMode ?? "following"
265
+ viewController.showsSpeedLimits = options.showsSpeedLimits ?? true
266
+
267
+ self.navigationViewController = viewController
268
+ self.isCurrentlyNavigating = true
269
+
270
+ if let rootVC = Self.currentTopViewController() {
271
+ rootVC.present(viewController, animated: true) {
272
+ promise.resolve(nil)
273
+ }
274
+ } else {
275
+ promise.reject("NO_ROOT_VC", "Could not find root view controller")
276
+ }
277
+
278
+ case .failure(let error):
279
+ promise.reject("ROUTE_ERROR", error.localizedDescription)
280
+ }
281
+ }
282
+ }
283
+
284
+ private func resolveCurrentOrigin(
285
+ completion: @escaping (Result<CLLocationCoordinate2D, Error>) -> Void
286
+ ) {
287
+ let resolver = CurrentLocationResolver()
288
+ currentLocationResolver = resolver
289
+ resolver.resolve { [weak self] result in
290
+ self?.currentLocationResolver = nil
291
+ completion(result)
292
+ }
293
+ }
294
+
295
+ private func stopNavigation(promise: Promise) {
296
+ guard let navVC = navigationViewController else {
297
+ promise.resolve(nil)
298
+ return
299
+ }
300
+
301
+ navVC.dismiss(animated: true) {
302
+ self.navigationViewController = nil
303
+ self.isCurrentlyNavigating = false
304
+ promise.resolve(nil)
305
+ }
306
+ }
307
+
308
+ private func setMuted(muted: Bool, promise: Promise) {
309
+ NavigationSettings.shared.voiceMuted = muted
310
+ promise.resolve(nil)
311
+ }
312
+
313
+ private func setVoiceVolume(volume: Double, promise: Promise) {
314
+ NavigationSettings.shared.voiceVolume = Float(max(0, min(volume, 1)))
315
+ promise.resolve(nil)
316
+ }
317
+
318
+ private func setDistanceUnit(unit: String, promise: Promise) {
319
+ NavigationSettings.shared.distanceUnit = unit == "imperial" ? .mile : .kilometer
320
+ promise.resolve(nil)
321
+ }
322
+
323
+ private func setLanguage(language: String, promise: Promise) {
324
+ let trimmed = language.trimmingCharacters(in: .whitespacesAndNewlines)
325
+ if trimmed.isEmpty {
326
+ promise.reject("INVALID_LANGUAGE", "Language cannot be empty")
327
+ return
328
+ }
329
+ currentLanguage = trimmed
330
+ promise.resolve(nil)
331
+ }
332
+
333
+ private func applyCameraConfiguration(
334
+ to viewController: NavigationViewController,
335
+ mode: String?,
336
+ pitch: Double?,
337
+ zoom: Double?
338
+ ) {
339
+ guard
340
+ let navigationMapView = viewController.navigationMapView,
341
+ let viewportDataSource = navigationMapView.navigationCamera
342
+ .viewportDataSource as? NavigationViewportDataSource else {
343
+ return
344
+ }
345
+
346
+ let normalizedMode = (mode ?? "following").lowercased()
347
+
348
+ if normalizedMode == "overview" {
349
+ viewportDataSource.options.followingCameraOptions.zoomUpdatesAllowed = false
350
+ viewportDataSource.followingMobileCamera.zoom = CGFloat(zoom ?? 10)
351
+ viewportDataSource.options.followingCameraOptions.pitchUpdatesAllowed = false
352
+ viewportDataSource.followingMobileCamera.pitch = 0
353
+ } else {
354
+ // Keep dynamic camera updates in following mode so turn-by-turn camera behavior
355
+ // (zoom/pitch/bearing adaptation) remains managed by the SDK.
356
+ viewportDataSource.options.followingCameraOptions.pitchUpdatesAllowed = true
357
+ viewportDataSource.options.followingCameraOptions.zoomUpdatesAllowed = true
358
+ viewportDataSource.options.followingCameraOptions.bearingUpdatesAllowed = true
359
+
360
+ if let pitch = pitch {
361
+ viewportDataSource.followingMobileCamera.pitch = CGFloat(max(0, min(pitch, 85)))
362
+ }
363
+
364
+ if let zoom = zoom {
365
+ viewportDataSource.followingMobileCamera.zoom = CGFloat(max(1, min(zoom, 22)))
366
+ }
367
+ }
368
+
369
+ navigationMapView.navigationCamera.follow()
370
+ }
371
+
372
+ private func buildNavigationOptions(
373
+ navigationService: NavigationService,
374
+ mapStyleUri: String?
375
+ ) -> MapboxNavigation.NavigationOptions {
376
+ guard
377
+ let styleUri = mapStyleUri?.trimmingCharacters(in: .whitespacesAndNewlines),
378
+ !styleUri.isEmpty,
379
+ let styleURL = URL(string: styleUri)
380
+ else {
381
+ return MapboxNavigation.NavigationOptions(navigationService: navigationService)
382
+ }
383
+
384
+ let dayStyle = DayStyle()
385
+ dayStyle.mapStyleURL = styleURL
386
+
387
+ let nightStyle = NightStyle()
388
+ nightStyle.mapStyleURL = styleURL
389
+
390
+ return MapboxNavigation.NavigationOptions(
391
+ styles: [dayStyle, nightStyle],
392
+ navigationService: navigationService
393
+ )
394
+ }
395
+
396
+ private static func currentTopViewController() -> UIViewController? {
397
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
398
+ let keyWindow = scenes
399
+ .flatMap { $0.windows }
400
+ .first(where: { $0.isKeyWindow })
401
+
402
+ var top = keyWindow?.rootViewController
403
+ while let presented = top?.presentedViewController {
404
+ top = presented
405
+ }
406
+ return top
407
+ }
408
+ }
409
+
410
+ // MARK: - NavigationViewControllerDelegate
411
+ extension MapboxNavigationModule: NavigationViewControllerDelegate {
412
+ public func navigationViewController(
413
+ _ navigationViewController: NavigationViewController,
414
+ didUpdate progress: RouteProgress,
415
+ with location: CLLocation,
416
+ rawLocation: CLLocation
417
+ ) {
418
+ if currentCameraMode.lowercased() == "following" {
419
+ navigationViewController.navigationMapView?.navigationCamera.follow()
420
+ }
421
+
422
+ sendEvent("onLocationChange", [
423
+ "latitude": location.coordinate.latitude,
424
+ "longitude": location.coordinate.longitude,
425
+ "bearing": location.course,
426
+ "speed": location.speed,
427
+ "altitude": location.altitude,
428
+ "accuracy": location.horizontalAccuracy
429
+ ])
430
+
431
+ sendEvent("onRouteProgressChange", [
432
+ "distanceTraveled": progress.distanceTraveled,
433
+ "distanceRemaining": progress.distanceRemaining,
434
+ "durationRemaining": progress.durationRemaining,
435
+ "fractionTraveled": progress.fractionTraveled
436
+ ])
437
+
438
+ sendEvent("onBannerInstruction", [
439
+ "primaryText": progress.currentLegProgress.currentStep.instructions,
440
+ "stepDistanceRemaining": progress.currentLegProgress.currentStepProgress.distanceRemaining
441
+ ])
442
+ }
443
+
444
+ public func navigationViewController(
445
+ _ navigationViewController: NavigationViewController,
446
+ didArriveAt waypoint: Waypoint
447
+ ) -> Bool {
448
+ sendEvent("onArrive", [
449
+ "name": waypoint.name ?? ""
450
+ ])
451
+ return true
452
+ }
453
+
454
+ public func navigationViewControllerDidDismiss(
455
+ _ navigationViewController: NavigationViewController,
456
+ byCanceling canceled: Bool
457
+ ) {
458
+ if canceled {
459
+ sendEvent("onCancelNavigation", [:])
460
+ }
461
+ self.navigationViewController = nil
462
+ self.isCurrentlyNavigating = false
463
+ }
464
+ }
465
+
466
+ // MARK: - Helper structs
467
+ struct NavigationStartOptions: Record {
468
+ @Field var startOrigin: Coordinate?
469
+ @Field var destination: DestinationWaypoint
470
+ @Field var waypoints: [DestinationWaypoint]?
471
+ @Field var shouldSimulateRoute: Bool?
472
+ @Field var distanceUnit: String?
473
+ @Field var language: String?
474
+ @Field var mute: Bool?
475
+ @Field var voiceVolume: Double?
476
+ @Field var cameraPitch: Double?
477
+ @Field var cameraZoom: Double?
478
+ @Field var cameraMode: String?
479
+ @Field var mapStyleUri: String?
480
+ @Field var routeAlternatives: Bool?
481
+ @Field var showsSpeedLimits: Bool?
482
+ @Field var showsWayNameLabel: Bool?
483
+ }
484
+
485
+ struct Coordinate: Record {
486
+ @Field var latitude: Double
487
+ @Field var longitude: Double
488
+
489
+ func toCLLocationCoordinate2D() -> CLLocationCoordinate2D? {
490
+ guard latitude >= -90 && latitude <= 90,
491
+ longitude >= -180 && longitude <= 180 else {
492
+ return nil
493
+ }
494
+ return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
495
+ }
496
+ }
497
+
498
+ struct DestinationWaypoint: Record {
499
+ @Field var latitude: Double
500
+ @Field var longitude: Double
501
+ @Field var name: String?
502
+
503
+ func toCLLocationCoordinate2D() -> CLLocationCoordinate2D? {
504
+ guard latitude >= -90 && latitude <= 90,
505
+ longitude >= -180 && longitude <= 180 else {
506
+ return nil
507
+ }
508
+ return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
509
+ }
510
+ }
511
+
512
+ private final class CurrentLocationResolver: NSObject, CLLocationManagerDelegate {
513
+ private let locationManager = CLLocationManager()
514
+ private var completion: ((Result<CLLocationCoordinate2D, Error>) -> Void)?
515
+ private var timeoutWorkItem: DispatchWorkItem?
516
+
517
+ enum ResolverError: LocalizedError {
518
+ case permissionDenied
519
+ case unavailable
520
+ case timeout
521
+
522
+ var errorDescription: String? {
523
+ switch self {
524
+ case .permissionDenied:
525
+ return "Location permission denied."
526
+ case .unavailable:
527
+ return "Unable to resolve current location."
528
+ case .timeout:
529
+ return "Timed out while resolving current location."
530
+ }
531
+ }
532
+ }
533
+
534
+ func resolve(
535
+ timeout: TimeInterval = 8.0,
536
+ completion: @escaping (Result<CLLocationCoordinate2D, Error>) -> Void
537
+ ) {
538
+ self.completion = completion
539
+ locationManager.delegate = self
540
+ locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
541
+
542
+ let status = locationAuthorizationStatus()
543
+ switch status {
544
+ case .notDetermined:
545
+ locationManager.requestWhenInUseAuthorization()
546
+ case .restricted, .denied:
547
+ finish(.failure(ResolverError.permissionDenied))
548
+ return
549
+ default:
550
+ requestLocation()
551
+ }
552
+
553
+ let timeoutTask = DispatchWorkItem { [weak self] in
554
+ self?.finish(.failure(ResolverError.timeout))
555
+ }
556
+ timeoutWorkItem = timeoutTask
557
+ DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutTask)
558
+ }
559
+
560
+ private func requestLocation() {
561
+ if let coordinate = locationManager.location?.coordinate {
562
+ finish(.success(coordinate))
563
+ return
564
+ }
565
+ locationManager.requestLocation()
566
+ }
567
+
568
+ private func locationAuthorizationStatus() -> CLAuthorizationStatus {
569
+ if #available(iOS 14.0, *) {
570
+ return locationManager.authorizationStatus
571
+ }
572
+ return CLLocationManager.authorizationStatus()
573
+ }
574
+
575
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
576
+ let status = locationAuthorizationStatus()
577
+ switch status {
578
+ case .authorizedAlways, .authorizedWhenInUse:
579
+ requestLocation()
580
+ case .denied, .restricted:
581
+ finish(.failure(ResolverError.permissionDenied))
582
+ default:
583
+ break
584
+ }
585
+ }
586
+
587
+ func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
588
+ guard let coordinate = locations.last?.coordinate else {
589
+ finish(.failure(ResolverError.unavailable))
590
+ return
591
+ }
592
+ finish(.success(coordinate))
593
+ }
594
+
595
+ func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
596
+ if let coordinate = manager.location?.coordinate {
597
+ finish(.success(coordinate))
598
+ return
599
+ }
600
+ finish(.failure(error))
601
+ }
602
+
603
+ private func finish(_ result: Result<CLLocationCoordinate2D, Error>) {
604
+ guard let completion = completion else {
605
+ return
606
+ }
607
+ timeoutWorkItem?.cancel()
608
+ timeoutWorkItem = nil
609
+ self.completion = nil
610
+ locationManager.delegate = nil
611
+ completion(result)
612
+ }
613
+ }