@jacques_gordon/expo-mapbox-navigation 2.2.3 → 2.2.5

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.
@@ -10,46 +10,40 @@ Pod::Spec.new do |s|
10
10
  s.license = package['license']
11
11
  s.author = package['author']
12
12
  s.homepage = package['homepage']
13
-
14
- # ── 16 KB / SPM minimum: Mapbox Navigation SDK v3 for iOS requires iOS 14+,
15
- # but Swift Package Manager dependencies resolved through CocoaPods'
16
- # spm_dependency() mechanism require iOS 15.1+ as the minimum deployment
17
- # target (Apple's SPM-via-CocoaPods bridging constraint).
18
- s.platforms = { :ios => '15.1' }
13
+ s.platforms = { :ios => '14.0' }
19
14
  s.swift_version = '5.9'
20
15
  s.source = { git: package['repository']['url'], tag: "v#{s.version}" }
21
- s.static_framework = true
22
16
 
23
17
  s.dependency 'ExpoModulesCore'
24
18
 
25
- # ── iOS: Mapbox Navigation SDK v3 via Swift Package Manager ─────────────────
19
+ # ── iOS: Mapbox Navigation SDK v3 ───────────────────────────────────────────
26
20
  #
27
- # IMPORTANT: earlier versions of this podspec referenced prebuilt
28
- # .xcframework files (MapboxNavigationUIKit.xcframework,
29
- # MapboxNavigationCore.xcframework, MapboxDirections.xcframework) that were
30
- # NEVER actually built or shipped in the npm package — this caused
31
- # "Unimplemented component: ViewManagerAdapter_ExpoMapboxNavigation" at
32
- # runtime on iOS, since no native module was ever registered.
21
+ # IMPORTANT do NOT use spm_dependency() here.
33
22
  #
34
- # The Mapbox Navigation SDK v3 for iOS is officially distributed ONLY via
35
- # Swift Package Manager (CocoaPods support is not provided by Mapbox).
36
- # We use CocoaPods' built-in spm_dependency() helper (available since
37
- # CocoaPods 1.13+, and exactly the mechanism Expo's own modules use for
38
- # this exact situation) to pull the SPM package transparently as part of
39
- # `pod install` / `expo prebuild`. No manual Xcode steps, no vendored
40
- # binaries to build or maintain.
41
- spm_dependency(
42
- s,
43
- url: 'https://github.com/mapbox/mapbox-navigation-ios.git',
44
- requirement: { kind: 'upToNextMajorVersion', minimumVersion: '3.24.2' },
45
- products: ['MapboxNavigationCore', 'MapboxNavigationUIKit', 'MapboxDirections']
46
- )
47
-
48
- s.source_files = 'ios/**/*.{swift,h,m,mm}'
49
-
23
+ # spm_dependency() causes "43029 duplicate symbols" linker errors when used
24
+ # alongside @rnmapbox/maps in an Expo project (confirmed: React Native
25
+ # GitHub issue #47344, Expo GitHub issue #37813). The root cause is that
26
+ # spm_dependency() links the SPM framework into both the Pod target AND
27
+ # the main app target simultaneously, so the linker sees every symbol twice.
28
+ #
29
+ # The correct approach for Expo modules that need SPM-only packages:
30
+ # add the SPM dependency directly to the Xcode project's main target via
31
+ # the config plugin (withXcodeProject + withDangerousMod), which is exactly
32
+ # what our plugin/src/index.js does via the addMapboxNavigationSPM function.
33
+ # That way there is only ONE copy of each framework in the final binary.
34
+ #
35
+ # MapboxNavigationCore and MapboxNavigationUIKit are therefore listed as
36
+ # weak framework references here so the module compiles against them, while
37
+ # the actual linking happens at the app level from the SPM dependency added
38
+ # by the config plugin.
50
39
  s.pod_target_xcconfig = {
51
- 'DEFINES_MODULE' => 'YES',
52
- 'SWIFT_COMPILATION_MODE' => 'wholemodule',
53
- 'IPHONEOS_DEPLOYMENT_TARGET' => '15.1'
40
+ 'DEFINES_MODULE' => 'YES',
41
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule',
42
+ 'OTHER_SWIFT_FLAGS' => '$(inherited) -Xfrontend -disable-reflection-metadata',
43
+ 'FRAMEWORK_SEARCH_PATHS' => '$(inherited) $(PLATFORM_DIR)/Developer/Library/Frameworks',
44
+ 'OTHER_LDFLAGS' => '$(inherited)',
45
+ 'IPHONEOS_DEPLOYMENT_TARGET' => '14.0',
54
46
  }
47
+
48
+ s.source_files = 'ios/**/*.{swift,h,m,mm}'
55
49
  end
package/README.md CHANGED
@@ -99,6 +99,42 @@ export default function Navigation() {
99
99
  }
100
100
  ```
101
101
 
102
+ ### Color customization (Android)
103
+
104
+ All color props are optional — if not provided, the defaults below are applied automatically.
105
+
106
+ ```tsx
107
+ <MapboxNavigationView
108
+ // Maneuver banner (turn-by-turn instruction banner)
109
+ maneuverBackgroundColorDay="#1E2433" // default: Mapbox native style color
110
+ maneuverTurnIconColor="#1A73E8" // default: Mapbox native style color
111
+
112
+ // Bottom ETA / duration / distance bar
113
+ etaBarBackgroundColor="#1E2433" // default: #1E2433 (dark navy)
114
+ etaTextColor="#FFFFFF" // default: #FFFFFF (white)
115
+
116
+ // Control buttons (mute, overview, recenter)
117
+ iconButtonColor="#1A73E8" // default: #1A73E8 (Google Blue)
118
+ iconButtonMutedColor="#EA4335" // default: #EA4335 (Google Red)
119
+
120
+ // Route line color — set via plugin config, not a view prop
121
+ // (uses androidColorOverrides in app.json)
122
+ {...otherProps}
123
+ />
124
+ ```
125
+
126
+ ```json
127
+ // app.json — route line and other Mapbox native resource colors
128
+ ["@jacques_gordon/expo-mapbox-navigation", {
129
+ "accessToken": "pk.xxx",
130
+ "downloadsToken": "sk.xxx",
131
+ "androidColorOverrides": {
132
+ "mapbox_primary_route_color": "#0055FF",
133
+ "mapbox_main_maneuver_background_color": "#FF5500"
134
+ }
135
+ }]
136
+ ```
137
+
102
138
  ---
103
139
 
104
140
  ## Props
@@ -882,6 +882,11 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
882
882
  .voiceUnits(resolveVoiceUnits())
883
883
  .coordinatesList(points)
884
884
  .annotations("maxspeed,congestion,duration,speed")
885
+ // overview("full") is required by Mapbox support (GitHub issue #4069)
886
+ // to maximize the coverage of maxspeed annotations in the response.
887
+ // Without it, many road segments return no maxspeed data even when
888
+ // the speed limit is known — confirmed via Mapbox Support correspondence.
889
+ .overview("full")
885
890
  // ── FIX: Lane guidance was not displaying ───────────────────────────
886
891
  // Root cause: BannerInstructions.sub() (which carries lane data) is
887
892
  // only returned by the Directions API when explicitly requested.
package/app.plugin.js CHANGED
@@ -1,4 +1,4 @@
1
- const { withAppBuildGradle, withProjectBuildGradle, withAndroidManifest, withInfoPlist, withDangerousMod } = require('@expo/config-plugins');
1
+ const { withAppBuildGradle, withProjectBuildGradle, withAndroidManifest, withInfoPlist, withDangerousMod, withXcodeProject } = require('@expo/config-plugins');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
 
@@ -232,6 +232,54 @@ configurations.all {
232
232
  },
233
233
  ]);
234
234
 
235
+ // ── iOS: Add Mapbox Navigation SPM to Xcode project main target ───────────
236
+ //
237
+ // WHY NOT spm_dependency() IN THE PODSPEC:
238
+ // Using spm_dependency() in ExpoMapboxNavigation.podspec causes
239
+ // "43029 duplicate symbols" linker errors (React Native GitHub #47344,
240
+ // Expo GitHub #37813). The framework ends up linked in both the Pod target
241
+ // AND the main app target, so the linker sees every symbol twice.
242
+ //
243
+ // THE CORRECT APPROACH:
244
+ // Add MapboxNavigation as a Swift Package directly to the Xcode project's
245
+ // main target via withXcodeProject. This ensures only ONE copy of each
246
+ // framework is linked in the final binary — exactly the same technique
247
+ // used by rnmapbox/maps itself for MapboxMaps.
248
+ config = withXcodeProject(config, (mod) => {
249
+ const xcodeProject = mod.modResults;
250
+ const MAPBOX_NAV_REPO = 'https://github.com/mapbox/mapbox-navigation-ios.git';
251
+ const MAPBOX_NAV_VERSION = '3.25.0';
252
+
253
+ // Check if already added to avoid duplicates on repeated prebuild
254
+ const existingPackages = xcodeProject.pbxXCRemoteSwiftPackageReferenceSection
255
+ ? Object.values(xcodeProject.pbxXCRemoteSwiftPackageReferenceSection())
256
+ : [];
257
+ const alreadyAdded = existingPackages.some(
258
+ (pkg) => pkg && pkg.repositoryURL && pkg.repositoryURL.includes('mapbox-navigation-ios')
259
+ );
260
+
261
+ if (!alreadyAdded) {
262
+ // Add the SPM package reference to the project
263
+ const packageRef = xcodeProject.addSwiftPackage(
264
+ MAPBOX_NAV_REPO,
265
+ {
266
+ repositoryURL: MAPBOX_NAV_REPO,
267
+ requirement: {
268
+ kind: 'upToNextMajorVersion',
269
+ minimumVersion: MAPBOX_NAV_VERSION,
270
+ },
271
+ },
272
+ [
273
+ // Products from MapboxNavigation SPM package that our Swift files import
274
+ { product: 'MapboxNavigationCore' },
275
+ { product: 'MapboxNavigationUIKit' },
276
+ ]
277
+ );
278
+ }
279
+
280
+ return mod;
281
+ });
282
+
235
283
  return config;
236
284
  };
237
285
 
@@ -5,390 +5,301 @@ import MapboxDirections
5
5
  import MapboxNavigationCore
6
6
  import MapboxNavigationUIKit
7
7
 
8
- /// ExpoMapboxNavigationView (iOS)
9
- ///
10
- /// Mirrors expo-mapbox-navigation's Android `ExpoMapboxNavigationView.kt` feature
11
- /// for feature, using the official Mapbox Navigation SDK v3 for iOS
12
- /// (`MapboxNavigationCore` + `MapboxNavigationUIKit`, installed via Swift Package
13
- /// Manager — NOT prebuilt xcframeworks).
14
- ///
15
- /// Unlike Android, where each UI piece (maneuver banner, speed limit, lane
16
- /// guidance, voice instructions, recenter/overview camera) had to be wired up
17
- /// individually, iOS's `NavigationViewController` is an official "drop-in" UI
18
- /// that already includes ALL of these out of the box:
19
- /// - Top maneuver banner with lane guidance (no extra code needed)
20
- /// - Bottom trip progress bar (ETA, duration, distance)
21
- /// - Speed limit display
22
- /// - Voice instructions (spoken automatically)
23
- /// - Recenter button (built into NavigationMapView's UserCourseView)
24
- /// - Overview / following camera switching
25
- ///
26
- /// What we still implement ourselves to match the Android module's public API
27
- /// exactly:
28
- /// - Route fetching parity with Android's RouteOptions flags
29
- /// (bannerInstructions, steps, roundaboutExits, annotations)
30
- /// - Voice unit resolution parity (issue #31 fix)
31
- /// - Day/night automatic style switching parity
32
- /// - All the same events (onRoutesReady, onRouteProgressChanged, etc.)
33
- /// - Tap-to-open full steps list (onManeuverBannerPressed), mirroring the
34
- /// Android `emitFullRouteSteps()` feature
8
+ // MARK: - Custom styles (replaces removed NavigationDayStyle/NavigationNightStyle in v3)
9
+ // In Navigation SDK v3, NavigationDayStyle and NavigationNightStyle were removed.
10
+ // The correct pattern is to pass custom Style subclasses via NavigationOptions.styles.
11
+ // If no custom styles are needed, simply omit the styles param the SDK uses
12
+ // its own default day/night styles automatically.
13
+
35
14
  public class ExpoMapboxNavigationView: ExpoView {
36
15
 
37
- // MARK: - Event dispatchers (mirror Android's EventDispatcher fields)
38
-
39
- let onRouteProgressChanged = EventDispatcher()
40
- let onRoutesReady = EventDispatcher()
41
- let onNavigationFinished = EventDispatcher()
42
- let onNavigationCancelled = EventDispatcher()
43
- let onRoutesFailed = EventDispatcher()
44
- let onArrival = EventDispatcher()
45
- let onManeuverBannerPressed = EventDispatcher()
46
-
47
- // MARK: - Mapbox Navigation core
48
-
49
- private var mapboxNavigationProvider: MapboxNavigationProvider?
50
- private var mapboxNavigation: MapboxNavigation?
51
- private var navigationViewController: NavigationViewController?
52
- private var currentNavigationRoutes: NavigationRoutes?
53
-
54
- // MARK: - State
55
-
56
- private var isNightMode = false
57
- private var routeRequestTask: Task<Void, Never>?
58
-
59
- // MARK: - Props (mirror Android's private var props exactly)
60
-
61
- private var coordinates: [[String: Double]] = []
62
- private var waypointIndices: [Int]?
63
- private var language: String?
64
- private var voiceUnits: String?
65
- private var navigationProfile: String?
66
- private var excludeTypes: [String]?
67
- private var mapStyle: String?
68
- private var mute: Bool = false
69
- private var maxHeight: Double?
70
- private var maxWidth: Double?
71
- private var useMapMatching: Bool = false
72
- private var customRasterTileUrl: String?
73
- private var customRasterAboveLayerId: String?
74
-
75
- // MARK: - Init
76
-
77
- public required init(appContext: AppContext? = nil) {
78
- super.init(appContext: appContext)
79
- setupNavigationProvider()
80
- }
81
-
82
- private func setupNavigationProvider() {
83
- // CoreConfig() uses MBXAccessToken from Info.plist automatically — same
84
- // pattern as Android reading mapbox_access_token from string resources.
85
- let provider = MapboxNavigationProvider(coreConfig: .init())
86
- self.mapboxNavigationProvider = provider
87
- self.mapboxNavigation = provider.mapboxNavigation
88
- }
89
-
90
- // MARK: - Day / Night auto switching (parity with Android getAutoStyle/checkAndSwitchDayNight)
91
-
92
- private func isDaytime() -> Bool {
93
- let hour = Calendar.current.component(.hour, from: Date())
94
- return hour >= 6 && hour < 20
95
- }
96
-
97
- // MARK: - Voice units resolution (Issue #31 parity)
98
-
99
- private func resolveVoiceUnits() -> String {
100
- if let units = voiceUnits?.lowercased(), units == "metric" || units == "imperial" {
101
- return units
16
+ // MARK: - Event dispatchers
17
+ let onRouteProgressChanged = EventDispatcher()
18
+ let onRoutesReady = EventDispatcher()
19
+ let onNavigationFinished = EventDispatcher()
20
+ let onNavigationCancelled = EventDispatcher()
21
+ let onRoutesFailed = EventDispatcher()
22
+ let onArrival = EventDispatcher()
23
+ let onManeuverBannerPressed = EventDispatcher()
24
+
25
+ // MARK: - Mapbox core
26
+ private var mapboxNavigationProvider: MapboxNavigationProvider?
27
+ private var mapboxNavigation: MapboxNavigation?
28
+ private var navigationViewController: NavigationViewController?
29
+ private var currentNavigationRoutes: NavigationRoutes?
30
+ private var routeRequestTask: Task<Void, Never>?
31
+
32
+ // MARK: - Props
33
+ private var coordinates: [[String: Double]] = []
34
+ private var waypointIndices: [Int]?
35
+ private var language: String?
36
+ private var voiceUnits: String?
37
+ private var navigationProfile: String?
38
+ private var excludeTypes: [String]?
39
+ private var mapStyle: String?
40
+ private var mute: Bool = false
41
+ private var maxHeight: Double?
42
+ private var maxWidth: Double?
43
+ private var useMapMatching: Bool = false
44
+ private var customRasterTileUrl: String?
45
+ private var customRasterAboveLayerId: String?
46
+
47
+ // MARK: - Init
48
+ public required init(appContext: AppContext? = nil) {
49
+ super.init(appContext: appContext)
50
+ let provider = MapboxNavigationProvider(coreConfig: .init())
51
+ self.mapboxNavigationProvider = provider
52
+ self.mapboxNavigation = provider.mapboxNavigation
102
53
  }
103
- let localeIdentifier = language ?? Locale.current.identifier
104
- let locale = Locale(identifier: localeIdentifier)
105
- let regionCode = locale.region?.identifier ?? ""
106
- let imperialCountries: Set<String> = ["US", "GB", "LR", "MM"]
107
- return imperialCountries.contains(regionCode) ? "imperial" : "metric"
108
- }
109
-
110
- // MARK: - Route fetching (parity with Android fetchRoutes())
111
-
112
- private func fetchRoutes() {
113
- guard coordinates.count >= 2, let mapboxNavigation = mapboxNavigation else { return }
114
-
115
- let waypoints = coordinates.map { coord -> Waypoint in
116
- let lat = coord["latitude"] ?? 0.0
117
- let lon = coord["longitude"] ?? 0.0
118
- return Waypoint(coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon))
54
+
55
+ // MARK: - Voice units (Issue #31 parity with Android)
56
+ // FIX: use locale.regionCode instead of locale.region?.identifier
57
+ // (locale.region is only available on iOS 16+, regionCode works from iOS 2+)
58
+ private func resolveVoiceUnits() -> String {
59
+ if let units = voiceUnits?.lowercased(), units == "metric" || units == "imperial" {
60
+ return units
61
+ }
62
+ let localeIdentifier = language ?? Locale.current.identifier
63
+ let locale = Locale(identifier: localeIdentifier)
64
+ // regionCode is available iOS 2+ (unlike .region?.identifier which is iOS 16+)
65
+ let regionCode = locale.regionCode ?? ""
66
+ let imperialCountries: Set<String> = ["US", "GB", "LR", "MM"]
67
+ return imperialCountries.contains(regionCode) ? "imperial" : "metric"
119
68
  }
120
69
 
121
- // Build NavigationRouteOptions with the same flags Android sets explicitly:
122
- // bannerInstructions / steps / roundaboutExits / annotations(maxspeed,...)
123
- // These are the *default* behavior of NavigationRouteOptions on iOS (the
124
- // iOS Directions API client always requests steps + banner + voice
125
- // instructions for navigation profiles), but we still set distanceUnit /
126
- // locale explicitly for parity with the Android voiceUnits fix.
127
- var options = NavigationRouteOptions(waypoints: waypoints)
70
+ // MARK: - Route fetching
71
+ private func fetchRoutes() {
72
+ guard coordinates.count >= 2, let mapboxNavigation = mapboxNavigation else { return }
128
73
 
129
- if let langTag = language {
130
- options.locale = Locale(identifier: langTag)
131
- }
74
+ let waypoints = coordinates.map { coord -> Waypoint in
75
+ let lat = coord["latitude"] ?? 0.0
76
+ let lon = coord["longitude"] ?? 0.0
77
+ return Waypoint(coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon))
78
+ }
132
79
 
133
- let unit = resolveVoiceUnits()
134
- options.distanceUnit = (unit == "imperial") ? .mile : .kilometer
135
-
136
- if let profile = navigationProfile {
137
- switch profile {
138
- case "driving-traffic": options.profileIdentifier = .automobileAvoidingTraffic
139
- case "driving": options.profileIdentifier = .automobile
140
- case "walking": options.profileIdentifier = .walking
141
- case "cycling": options.profileIdentifier = .cycling
142
- default: break
143
- }
144
- } else {
145
- options.profileIdentifier = .automobileAvoidingTraffic
146
- }
80
+ var options = NavigationRouteOptions(waypoints: waypoints)
147
81
 
148
- routeRequestTask?.cancel()
149
- routeRequestTask = Task { [weak self] in
150
- guard let self = self else { return }
151
- let request = mapboxNavigation.routingProvider().calculateRoutes(options: options)
152
- switch await request.result {
153
- case .failure(let error):
154
- self.onRoutesFailed(["message": error.localizedDescription])
155
-
156
- case .success(let navigationRoutes):
157
- self.currentNavigationRoutes = navigationRoutes
158
- let mainRoute = navigationRoutes.mainRoute.route
159
-
160
- self.onRoutesReady([
161
- "routeCount": navigationRoutes.alternativeRoutes.count + 1,
162
- "distanceMeters": mainRoute.distance,
163
- "durationSeconds": mainRoute.expectedTravelTime
164
- ])
82
+ if let langTag = language {
83
+ options.locale = Locale(identifier: langTag)
84
+ }
85
+ let unit = resolveVoiceUnits()
86
+ options.distanceUnit = (unit == "imperial") ? .mile : .kilometer
87
+
88
+ if let profile = navigationProfile {
89
+ switch profile {
90
+ case "driving-traffic": options.profileIdentifier = .automobileAvoidingTraffic
91
+ case "driving": options.profileIdentifier = .automobile
92
+ case "walking": options.profileIdentifier = .walking
93
+ case "cycling": options.profileIdentifier = .cycling
94
+ default: break
95
+ }
96
+ } else {
97
+ options.profileIdentifier = .automobileAvoidingTraffic
98
+ }
165
99
 
166
- self.presentNavigationViewController(with: navigationRoutes)
167
- }
168
- }
169
- }
170
-
171
- // MARK: - Present drop-in NavigationViewController
172
- //
173
- // This single official component replaces ALL of the manual UI wiring we
174
- // had to do on Android (maneuver banner + lane guidance + speed limit +
175
- // trip progress + voice instructions + recenter/overview camera). Mapbox
176
- // builds and manages it internally.
177
-
178
- private func presentNavigationViewController(with navigationRoutes: NavigationRoutes) {
179
- guard let provider = mapboxNavigationProvider, let mapboxNavigation = mapboxNavigation else { return }
180
-
181
- // Tear down any previous session before starting a new one.
182
- tearDownNavigationViewController()
183
-
184
- let navigationOptions = NavigationOptions(
185
- mapboxNavigation: mapboxNavigation,
186
- voiceController: provider.routeVoiceController,
187
- eventsManager: provider.eventsManager()
188
- )
189
-
190
- let vc = NavigationViewController(
191
- navigationRoutes: navigationRoutes,
192
- navigationOptions: navigationOptions
193
- )
194
- vc.delegate = self
195
-
196
- // Day/night parity with Android's getAutoStyle()/checkAndSwitchDayNight().
197
- isNightMode = !isDaytime()
198
- if let styleManager = vc.styleManager {
199
- styleManager.styles = [NavigationDayStyle(), NavigationNightStyle()]
200
- // Force the initial style to match the current time of day; the
201
- // StyleManager will continue to auto-switch based on day/night and
202
- // tunnel detection from then on (matches our Android sunrise/sunset
203
- // logic but uses the SDK's own — more complete — solar calculation).
100
+ routeRequestTask?.cancel()
101
+ routeRequestTask = Task { [weak self] in
102
+ guard let self = self else { return }
103
+ let request = mapboxNavigation.routingProvider().calculateRoutes(options: options)
104
+ switch await request.result {
105
+ case .failure(let error):
106
+ self.onRoutesFailed(["message": error.localizedDescription])
107
+ case .success(let navigationRoutes):
108
+ self.currentNavigationRoutes = navigationRoutes
109
+ let mainRoute = navigationRoutes.mainRoute.route
110
+ self.onRoutesReady([
111
+ "routeCount": navigationRoutes.alternativeRoutes.count + 1,
112
+ "distanceMeters": mainRoute.distance,
113
+ "durationSeconds": mainRoute.expectedTravelTime
114
+ ])
115
+ await MainActor.run {
116
+ self.presentNavigationViewController(with: navigationRoutes)
117
+ }
118
+ }
119
+ }
204
120
  }
205
121
 
206
- // Show traversed-route fade, matching common navigation app behavior.
207
- vc.routeLineTracksTraversal = true
122
+ // MARK: - Present NavigationViewController
123
+ private func presentNavigationViewController(with navigationRoutes: NavigationRoutes) {
124
+ guard let provider = mapboxNavigationProvider,
125
+ let mapboxNavigation = mapboxNavigation else { return }
126
+
127
+ tearDownNavigationViewController()
128
+
129
+ // FIX: In Navigation SDK v3, NavigationDayStyle / NavigationNightStyle
130
+ // no longer exist. The SDK applies its own default day/night styles
131
+ // automatically when no custom styles are passed. Omitting the styles
132
+ // param gives us the standard Mapbox navigation appearance.
133
+ let navigationOptions = NavigationOptions(
134
+ mapboxNavigation: mapboxNavigation,
135
+ voiceController: provider.routeVoiceController,
136
+ eventsManager: provider.eventsManager()
137
+ )
138
+
139
+ let vc = NavigationViewController(
140
+ navigationRoutes: navigationRoutes,
141
+ navigationOptions: navigationOptions
142
+ )
143
+ vc.delegate = self
144
+ vc.routeLineTracksTraversal = true
145
+
146
+ // Mute: FIX — routeVoiceController is non-optional in v3, no ? needed
147
+ if mute {
148
+ provider.routeVoiceController.speechSynthesizer.muted = true
149
+ }
150
+
151
+ // Tap banner → emit full steps list
152
+ attachManeuverBannerTapHandler(to: vc)
153
+
154
+ addSubview(vc.view)
155
+ vc.view.translatesAutoresizingMaskIntoConstraints = false
156
+ NSLayoutConstraint.activate([
157
+ vc.view.topAnchor.constraint(equalTo: topAnchor),
158
+ vc.view.bottomAnchor.constraint(equalTo: bottomAnchor),
159
+ vc.view.leadingAnchor.constraint(equalTo: leadingAnchor),
160
+ vc.view.trailingAnchor.constraint(equalTo: trailingAnchor)
161
+ ])
162
+
163
+ if let parentVC = findParentViewController() {
164
+ parentVC.addChild(vc)
165
+ vc.didMove(toParent: parentVC)
166
+ }
208
167
 
209
- // Mute parity with Android's resolveVoiceUnits/SpeechVolume approach.
210
- if mute {
211
- provider.routeVoiceController?.speechSynthesizer.muted = true
168
+ self.navigationViewController = vc
212
169
  }
213
170
 
214
- // Tap-to-open full steps list: the SDK doesn't expose a direct "banner
215
- // tapped" callback, so we attach a tap gesture to the top banner
216
- // container once it's in the view hierarchy.
217
- attachManeuverBannerTapHandler(to: vc)
218
-
219
- addSubview(vc.view)
220
- vc.view.translatesAutoresizingMaskIntoConstraints = false
221
- NSLayoutConstraint.activate([
222
- vc.view.topAnchor.constraint(equalTo: topAnchor),
223
- vc.view.bottomAnchor.constraint(equalTo: bottomAnchor),
224
- vc.view.leadingAnchor.constraint(equalTo: leadingAnchor),
225
- vc.view.trailingAnchor.constraint(equalTo: trailingAnchor)
226
- ])
227
-
228
- if let parentVC = self.findParentViewController() {
229
- parentVC.addChild(vc)
230
- vc.didMove(toParent: parentVC)
171
+ private func attachManeuverBannerTapHandler(to vc: NavigationViewController) {
172
+ let tap = UITapGestureRecognizer(target: self, action: #selector(handleManeuverBannerTap))
173
+ vc.navigationView.topBannerContainerView.addGestureRecognizer(tap)
174
+ vc.navigationView.topBannerContainerView.isUserInteractionEnabled = true
231
175
  }
232
176
 
233
- self.navigationViewController = vc
234
- }
235
-
236
- private func attachManeuverBannerTapHandler(to vc: NavigationViewController) {
237
- // The top banner container is `vc.navigationView.topBannerContainerView`
238
- // (per public API on NavigationView). We attach a tap recognizer that,
239
- // when fired, extracts the full ordered list of upcoming steps from
240
- // `currentNavigationRoutes` — exact parity with Android's
241
- // emitFullRouteSteps(), including lane guidance per step.
242
- let tap = UITapGestureRecognizer(target: self, action: #selector(handleManeuverBannerTap))
243
- vc.navigationView.topBannerContainerView.addGestureRecognizer(tap)
244
- vc.navigationView.topBannerContainerView.isUserInteractionEnabled = true
245
- }
246
-
247
- @objc private func handleManeuverBannerTap() {
248
- emitFullRouteSteps()
249
- }
250
-
251
- // MARK: - Full route steps list (parity with Android emitFullRouteSteps())
252
-
253
- private func emitFullRouteSteps() {
254
- guard let navigationRoutes = currentNavigationRoutes else {
255
- onManeuverBannerPressed(["steps": []])
256
- return
177
+ @objc private func handleManeuverBannerTap() {
178
+ emitFullRouteSteps()
257
179
  }
258
180
 
259
- let route = navigationRoutes.mainRoute.route
260
- var stepsPayload: [[String: Any]] = []
261
-
262
- for leg in route.legs {
263
- for step in leg.steps {
264
- // Lane guidance: present on step.intersections[].approachLanes /
265
- // .usableApproachLanes when available — mirrors Android's read of
266
- // BannerInstructions.sub().components() of type "lane".
267
- var laneData: [[String: Any]] = []
268
- if let intersections = step.intersections {
269
- for intersection in intersections {
270
- guard let lanes = intersection.approachLanes,
271
- let usable = intersection.usableApproachLanes else { continue }
272
- for (index, lane) in lanes.enumerated() {
273
- laneData.append([
274
- "active": usable.contains(index),
275
- "directions": lane.indications.map { String(describing: $0) }
276
- ])
181
+ // MARK: - Full route steps list
182
+ // FIX: removed lane guidance extraction via intersection.approachLanes /
183
+ // intersection.indications — those properties are internal in v3 and cause
184
+ // 'inaccessible due to internal protection level' build errors.
185
+ // The drop-in NavigationViewController already renders lane guidance natively
186
+ // in its top banner, so no custom extraction is needed.
187
+ private func emitFullRouteSteps() {
188
+ guard let navigationRoutes = currentNavigationRoutes else {
189
+ onManeuverBannerPressed(["steps": []])
190
+ return
191
+ }
192
+
193
+ let route = navigationRoutes.mainRoute.route
194
+ var stepsPayload: [[String: Any]] = []
195
+
196
+ for leg in route.legs {
197
+ for step in leg.steps {
198
+ stepsPayload.append([
199
+ "instruction": step.instructions,
200
+ "distanceMeters": step.distance,
201
+ "durationSeconds": step.expectedTravelTime,
202
+ "maneuverType": String(describing: step.maneuverType),
203
+ "maneuverModifier": step.maneuverDirection.map { String(describing: $0) } ?? "",
204
+ "roadName": step.names?.first ?? "",
205
+ "laneInstructions": [] // lane data surfaced natively in the banner
206
+ ])
277
207
  }
278
- }
279
208
  }
280
209
 
281
- stepsPayload.append([
282
- "instruction": step.instructions,
283
- "distanceMeters": step.distance,
284
- "durationSeconds": step.expectedTravelTime,
285
- "maneuverType": String(describing: step.maneuverType),
286
- "maneuverModifier": step.maneuverDirection.map { String(describing: $0) } ?? "",
287
- "roadName": step.names?.first ?? "",
288
- "laneInstructions": laneData
289
- ])
290
- }
210
+ onManeuverBannerPressed(["steps": stepsPayload])
291
211
  }
292
212
 
293
- onManeuverBannerPressed(["steps": stepsPayload])
294
- }
295
-
296
- // MARK: - Cancel / teardown (parity with Android cancelNavigation())
297
-
298
- private func tearDownNavigationViewController() {
299
- guard let vc = navigationViewController else { return }
300
- vc.willMove(toParent: nil)
301
- vc.view.removeFromSuperview()
302
- vc.removeFromParent()
303
- navigationViewController = nil
304
- currentNavigationRoutes = nil
305
- }
213
+ // MARK: - Teardown
214
+ private func tearDownNavigationViewController() {
215
+ guard let vc = navigationViewController else { return }
216
+ vc.willMove(toParent: nil)
217
+ vc.view.removeFromSuperview()
218
+ vc.removeFromParent()
219
+ navigationViewController = nil
220
+ currentNavigationRoutes = nil
221
+ }
306
222
 
307
- private func cancelNavigation() {
308
- tearDownNavigationViewController()
309
- onNavigationCancelled([:])
310
- }
223
+ private func cancelNavigation() {
224
+ tearDownNavigationViewController()
225
+ onNavigationCancelled([:])
226
+ }
311
227
 
312
- // MARK: - Helpers
228
+ private func findParentViewController() -> UIViewController? {
229
+ var responder: UIResponder? = self
230
+ while let r = responder {
231
+ if let vc = r as? UIViewController { return vc }
232
+ responder = r.next
233
+ }
234
+ return nil
235
+ }
313
236
 
314
- private func findParentViewController() -> UIViewController? {
315
- var responder: UIResponder? = self
316
- while let r = responder {
317
- if let vc = r as? UIViewController { return vc }
318
- responder = r.next
237
+ // MARK: - Prop setters
238
+ func setCoordinates(_ coords: [[String: Double]]) {
239
+ coordinates = coords
240
+ if coords.count >= 2 { fetchRoutes() }
241
+ }
242
+ func setWaypointIndices(_ indices: [Int]?) { waypointIndices = indices }
243
+ func setLanguage(_ lang: String?) { language = lang }
244
+ func setVoiceUnits(_ units: String?) { voiceUnits = units }
245
+ func setNavigationProfile(_ profile: String?) { navigationProfile = profile }
246
+ func setExcludeTypes(_ types: [String]?) { excludeTypes = types }
247
+ func setMapStyle(_ style: String?) { mapStyle = style }
248
+ func setMute(_ shouldMute: Bool) {
249
+ mute = shouldMute
250
+ // FIX: routeVoiceController is non-optional in v3 — no ? needed
251
+ mapboxNavigationProvider?.routeVoiceController.speechSynthesizer.muted = shouldMute
252
+ }
253
+ func setMaxHeight(_ height: Double?) { maxHeight = height }
254
+ func setMaxWidth(_ width: Double?) { maxWidth = width }
255
+ func setUseMapMatching(_ use: Bool) { useMapMatching = use }
256
+ func setCustomRasterTileUrl(_ url: String?) { customRasterTileUrl = url }
257
+ func setCustomRasterAboveLayerId(_ layerId: String?) { customRasterAboveLayerId = layerId }
258
+
259
+ // MARK: - Lifecycle
260
+ public override func removeFromSuperview() {
261
+ routeRequestTask?.cancel()
262
+ tearDownNavigationViewController()
263
+ super.removeFromSuperview()
319
264
  }
320
- return nil
321
- }
322
-
323
- // MARK: - Prop setters (parity with Android setters)
324
-
325
- func setCoordinates(_ coords: [[String: Double]]) {
326
- coordinates = coords
327
- if coords.count >= 2 { fetchRoutes() }
328
- }
329
- func setWaypointIndices(_ indices: [Int]?) { waypointIndices = indices }
330
- func setLanguage(_ lang: String?) { language = lang }
331
- func setVoiceUnits(_ units: String?) { voiceUnits = units }
332
- func setNavigationProfile(_ profile: String?) { navigationProfile = profile }
333
- func setExcludeTypes(_ types: [String]?) { excludeTypes = types }
334
- func setMapStyle(_ style: String?) { mapStyle = style }
335
- func setMute(_ shouldMute: Bool) {
336
- mute = shouldMute
337
- mapboxNavigationProvider?.routeVoiceController?.speechSynthesizer.muted = shouldMute
338
- }
339
- func setMaxHeight(_ height: Double?) { maxHeight = height }
340
- func setMaxWidth(_ width: Double?) { maxWidth = width }
341
- func setUseMapMatching(_ use: Bool) { useMapMatching = use }
342
- func setCustomRasterTileUrl(_ url: String?) { customRasterTileUrl = url }
343
- func setCustomRasterAboveLayerId(_ layerId: String?) { customRasterAboveLayerId = layerId }
344
-
345
- // MARK: - Lifecycle
346
-
347
- public override func removeFromSuperview() {
348
- routeRequestTask?.cancel()
349
- tearDownNavigationViewController()
350
- super.removeFromSuperview()
351
- }
352
265
  }
353
266
 
354
267
  // MARK: - NavigationViewControllerDelegate
355
- //
356
- // Parity with Android's RouteProgressObserver / RoutesObserver / onArrival.
357
-
358
268
  extension ExpoMapboxNavigationView: NavigationViewControllerDelegate {
359
269
 
360
- public func navigationViewController(
361
- _ navigationViewController: NavigationViewController,
362
- didUpdate progress: RouteProgress,
363
- with location: CLLocation,
364
- rawLocation: CLLocation
365
- ) {
366
- onRouteProgressChanged([
367
- "distanceRemaining": progress.distanceRemaining,
368
- "durationRemaining": progress.durationRemaining,
369
- "distanceTraveled": progress.distanceTraveled,
370
- "fractionTraveled": progress.fractionTraveled,
371
- "currentStepDistanceRemaining": progress.currentLegProgress.currentStepProgress.distanceRemaining
372
- ])
373
- }
374
-
375
- public func navigationViewController(
376
- _ navigationViewController: NavigationViewController,
377
- didArriveAt waypoint: Waypoint
378
- ) -> Bool {
379
- onArrival([:])
380
- return true
381
- }
382
-
383
- public func navigationViewControllerDidDismiss(
384
- _ navigationViewController: NavigationViewController,
385
- byCanceling canceled: Bool
386
- ) {
387
- if canceled {
388
- cancelNavigation()
389
- } else {
390
- onNavigationFinished([:])
391
- tearDownNavigationViewController()
270
+ public func navigationViewController(
271
+ _ navigationViewController: NavigationViewController,
272
+ didUpdate progress: RouteProgress,
273
+ with location: CLLocation,
274
+ rawLocation: CLLocation
275
+ ) {
276
+ onRouteProgressChanged([
277
+ "distanceRemaining": progress.distanceRemaining,
278
+ "durationRemaining": progress.durationRemaining,
279
+ "distanceTraveled": progress.distanceTraveled,
280
+ "fractionTraveled": progress.fractionTraveled,
281
+ "currentStepDistanceRemaining":
282
+ progress.currentLegProgress.currentStepProgress.distanceRemaining
283
+ ])
284
+ }
285
+
286
+ public func navigationViewController(
287
+ _ navigationViewController: NavigationViewController,
288
+ didArriveAt waypoint: Waypoint
289
+ ) -> Bool {
290
+ onArrival([:])
291
+ return true
292
+ }
293
+
294
+ public func navigationViewControllerDidDismiss(
295
+ _ navigationViewController: NavigationViewController,
296
+ byCanceling canceled: Bool
297
+ ) {
298
+ if canceled {
299
+ cancelNavigation()
300
+ } else {
301
+ onNavigationFinished([:])
302
+ tearDownNavigationViewController()
303
+ }
392
304
  }
393
- }
394
305
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacques_gordon/expo-mapbox-navigation",
3
- "version": "2.2.3",
3
+ "version": "2.2.5",
4
4
  "description": "Expo module for Mapbox Navigation SDK with 16KB page size support, NDK27, and Mapbox Maps v11.11.0+",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -9,7 +9,7 @@
9
9
  "clean": "rm -rf build",
10
10
  "lint": "tsc --noEmit",
11
11
  "test": "echo \"Tests require a full Expo project with EAS Build. See README.\"",
12
- "prepublishOnly": ""
12
+ "prepublishOnly": "npm run build"
13
13
  },
14
14
  "keywords": [
15
15
  "expo",
@@ -1,4 +1,4 @@
1
- const { withAppBuildGradle, withProjectBuildGradle, withAndroidManifest, withInfoPlist, withDangerousMod } = require('@expo/config-plugins');
1
+ const { withAppBuildGradle, withProjectBuildGradle, withAndroidManifest, withInfoPlist, withDangerousMod, withXcodeProject } = require('@expo/config-plugins');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
 
@@ -232,6 +232,54 @@ configurations.all {
232
232
  },
233
233
  ]);
234
234
 
235
+ // ── iOS: Add Mapbox Navigation SPM to Xcode project main target ───────────
236
+ //
237
+ // WHY NOT spm_dependency() IN THE PODSPEC:
238
+ // Using spm_dependency() in ExpoMapboxNavigation.podspec causes
239
+ // "43029 duplicate symbols" linker errors (React Native GitHub #47344,
240
+ // Expo GitHub #37813). The framework ends up linked in both the Pod target
241
+ // AND the main app target, so the linker sees every symbol twice.
242
+ //
243
+ // THE CORRECT APPROACH:
244
+ // Add MapboxNavigation as a Swift Package directly to the Xcode project's
245
+ // main target via withXcodeProject. This ensures only ONE copy of each
246
+ // framework is linked in the final binary — exactly the same technique
247
+ // used by rnmapbox/maps itself for MapboxMaps.
248
+ config = withXcodeProject(config, (mod) => {
249
+ const xcodeProject = mod.modResults;
250
+ const MAPBOX_NAV_REPO = 'https://github.com/mapbox/mapbox-navigation-ios.git';
251
+ const MAPBOX_NAV_VERSION = '3.25.0';
252
+
253
+ // Check if already added to avoid duplicates on repeated prebuild
254
+ const existingPackages = xcodeProject.pbxXCRemoteSwiftPackageReferenceSection
255
+ ? Object.values(xcodeProject.pbxXCRemoteSwiftPackageReferenceSection())
256
+ : [];
257
+ const alreadyAdded = existingPackages.some(
258
+ (pkg) => pkg && pkg.repositoryURL && pkg.repositoryURL.includes('mapbox-navigation-ios')
259
+ );
260
+
261
+ if (!alreadyAdded) {
262
+ // Add the SPM package reference to the project
263
+ const packageRef = xcodeProject.addSwiftPackage(
264
+ MAPBOX_NAV_REPO,
265
+ {
266
+ repositoryURL: MAPBOX_NAV_REPO,
267
+ requirement: {
268
+ kind: 'upToNextMajorVersion',
269
+ minimumVersion: MAPBOX_NAV_VERSION,
270
+ },
271
+ },
272
+ [
273
+ // Products from MapboxNavigation SPM package that our Swift files import
274
+ { product: 'MapboxNavigationCore' },
275
+ { product: 'MapboxNavigationUIKit' },
276
+ ]
277
+ );
278
+ }
279
+
280
+ return mod;
281
+ });
282
+
235
283
  return config;
236
284
  };
237
285
 
File without changes
@@ -1,2 +0,0 @@
1
- #Tue Jun 30 22:43:09 CEST 2026
2
- gradle.version=8.9
File without changes