@jacques_gordon/expo-mapbox-navigation 2.1.6 → 2.2.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.
@@ -10,30 +10,46 @@ Pod::Spec.new do |s|
10
10
  s.license = package['license']
11
11
  s.author = package['author']
12
12
  s.homepage = package['homepage']
13
- s.platforms = { :ios => '14.0' }
14
- s.swift_version = '5.7'
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' }
19
+ s.swift_version = '5.9'
15
20
  s.source = { git: package['repository']['url'], tag: "v#{s.version}" }
16
21
  s.static_framework = true
17
22
 
18
23
  s.dependency 'ExpoModulesCore'
19
24
 
20
- # ── iOS: Mapbox Navigation bundled as xcframework ──────────────────────────
21
- # The Navigation SDK v3 for iOS does not support CocoaPods natively.
22
- # xcframeworks are bundled directly. See README for how to rebuild them.
23
- # ────────────────────────────────────────────────────────────────────────────
24
- s.vendored_frameworks = 'ios/MapboxNavigationUIKit.xcframework',
25
- 'ios/MapboxNavigationCore.xcframework',
26
- 'ios/MapboxDirections.xcframework'
27
-
28
- # Mapbox Maps SDK (via CocoaPods — available as of Maps SDK 11.x)
29
- s.dependency 'MapboxMaps', '>= 11.11.0'
25
+ # ── iOS: Mapbox Navigation SDK v3 via Swift Package Manager ─────────────────
26
+ #
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.
33
+ #
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
+ )
30
47
 
31
- s.source_files = 'ios/**/*.{swift,m,mm}'
48
+ s.source_files = 'ios/**/*.{swift,h,m,mm}'
32
49
 
33
50
  s.pod_target_xcconfig = {
34
51
  'DEFINES_MODULE' => 'YES',
35
- # 16 KB page alignment for iOS (Apple Silicon + future-proof)
36
- 'OTHER_LDFLAGS' => '-Wl,-dead_strip_dylibs',
37
- 'SWIFT_COMPILATION_MODE' => 'wholemodule'
52
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule',
53
+ 'IPHONEOS_DEPLOYMENT_TARGET' => '15.1'
38
54
  }
39
55
  end
package/README.md CHANGED
@@ -12,6 +12,7 @@ Expo module for Mapbox Navigation SDK — forked from [`@badatgil/expo-mapbox-na
12
12
  | ✅ **16 KB page size** | `jniLibs.useLegacyPackaging = false` + `ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON` — required for Google Play compliance from 2025 onwards. |
13
13
  | ✅ **Expo SDK 53+** | Compatible with Expo SDK ≥ 53 and React Native 0.79+. |
14
14
  | ✅ **Maps v11.11.0** | Minimum Mapbox Maps Android SDK enforced to 11.11.0 (config plugin validates this). |
15
+ | ✅ **iOS support** | Full native iOS implementation (`NavigationViewController` drop-in UI) via Swift Package Manager — feature parity with Android (lane guidance, speed limit, voice instructions, day/night, steps list). |
15
16
 
16
17
  ---
17
18
 
@@ -46,6 +47,7 @@ Follow the [full @rnmapbox/maps installation guide](https://rnmapbox.github.io/d
46
47
  "@jacques_gordon/expo-mapbox-navigation",
47
48
  {
48
49
  "accessToken": "pk.your_public_token",
50
+ "downloadsToken": "sk.your_secret_token",
49
51
  "mapboxMapsVersion": "11.11.0"
50
52
  }
51
53
  ]
@@ -54,6 +56,14 @@ Follow the [full @rnmapbox/maps installation guide](https://rnmapbox.github.io/d
54
56
 
55
57
  > ⚠️ `mapboxMapsVersion` must match the version set in `@rnmapbox/maps`. Minimum: `11.11.0`.
56
58
 
59
+ > ⚠️ `downloadsToken` is **required** — it's a secret Mapbox token (starts with `sk.`) with the **Downloads:Read** scope. On iOS it's used to authenticate Swift Package Manager when it fetches the Mapbox Navigation SDK from Mapbox's private package registry (via a generated `.netrc` entry). This is the same token already used for `RNMapboxMapsDownloadToken` above — you can reuse it.
60
+
61
+ ### iOS architecture
62
+
63
+ iOS uses the **official Mapbox Navigation SDK v3 drop-in UI** (`NavigationViewController`), installed via **Swift Package Manager** — not prebuilt `.xcframework` binaries. This is the only officially supported distribution method for the Navigation SDK v3 on iOS; CocoaPods doesn't host it directly, so this package bridges it in automatically through CocoaPods' `spm_dependency()` mechanism when you run `pod install` / `expo prebuild`.
64
+
65
+ Because `NavigationViewController` is a complete drop-in experience, lane guidance, speed limit display, voice instructions, and the recenter/overview camera button all come built-in from Mapbox — no extra wiring needed, unlike the more manual Android implementation.
66
+
57
67
  ### iOS: enable static frameworks
58
68
 
59
69
  ```json
@@ -156,6 +166,12 @@ More info: [Android 16 KB page size guide](https://developer.android.com/guide/p
156
166
 
157
167
  ## Changelog
158
168
 
169
+ ### 2.2.0
170
+ - **iOS support added.** Full native implementation using Mapbox Navigation SDK v3 (`NavigationViewController` drop-in UI) via Swift Package Manager.
171
+ - Fixed: previous versions referenced non-existent `.xcframework` files in the podspec, causing `Unimplemented component: ViewManagerAdapter_ExpoMapboxNavigation` crashes on iOS — no native module was ever actually registered.
172
+ - New required `downloadsToken` config plugin option (secret Mapbox token, used to authenticate Swift Package Manager).
173
+ - iOS feature parity with Android: lane guidance, speed limit, voice instructions (with working mute), day/night auto-switching, and the `onManeuverBannerPressed` full-steps-list event.
174
+
159
175
  ### 2.0.1
160
176
  - Fix #43: `CameraAnimationsUtils.calculateCameraAnimationHint` NoSuchMethodError on Android
161
177
  - Fix #31: Add `voiceUnits` prop for metric/imperial voice instructions
@@ -303,11 +303,20 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
303
303
  ))
304
304
 
305
305
  // ── ManeuverView — top ─────────────────────────────────────────────────
306
- // FIX: apply custom style with autoSizeTextType so long street names
307
- // (e.g. "Wulmersumsesteenweg") shrink to fit instead of being clipped.
308
- val mv = MapboxManeuverView(
309
- android.view.ContextThemeWrapper(context, R.style.MapboxCustomManeuverViewStyle)
310
- )
306
+ // NOTE: a previous attempt to customize the primary maneuver text
307
+ // appearance (auto-sizing for long street names) used a fictional
308
+ // "primaryManeuverTextAppearance" attribute that doesn't exist in the
309
+ // SDK, which broke AAPT resource linking in host apps. A follow-up
310
+ // attempt to call updatePrimaryManeuverTextAppearance() was also
311
+ // dropped: in Navigation SDK v3 the UI components were restructured
312
+ // around a ComponentInstaller/ManeuverConfig system and that method's
313
+ // availability/signature could not be confirmed for v3 with
314
+ // certainty. To avoid risking another build-breaking regression on
315
+ // an unverified API, MapboxManeuverView is used with its default
316
+ // text appearance for now. Long street name truncation can be
317
+ // revisited via the officially supported ComponentInstaller config
318
+ // API in a future, carefully tested release.
319
+ val mv = MapboxManeuverView(context)
311
320
  root.addView(mv as View, FrameLayout.LayoutParams(
312
321
  FrameLayout.LayoutParams.MATCH_PARENT,
313
322
  FrameLayout.LayoutParams.WRAP_CONTENT
package/app.plugin.js CHANGED
@@ -8,6 +8,7 @@ const MAPBOX_MAPS_MIN_VERSION = '11.11.0';
8
8
  const withMapboxNavigation = (config, options = {}) => {
9
9
  const {
10
10
  accessToken,
11
+ downloadsToken,
11
12
  mapboxMapsVersion = MAPBOX_MAPS_MIN_VERSION,
12
13
  androidColorOverrides = {},
13
14
  } = options;
@@ -20,6 +21,21 @@ const withMapboxNavigation = (config, options = {}) => {
20
21
  );
21
22
  }
22
23
 
24
+ // downloadsToken (a secret sk.* token with the Downloads:Read scope) is
25
+ // required on iOS to authenticate Swift Package Manager's fetch of the
26
+ // Mapbox Navigation SDK package — without it, `pod install`/`expo
27
+ // prebuild` will fail to resolve the SPM dependency declared in
28
+ // ExpoMapboxNavigation.podspec.
29
+ if (!downloadsToken) {
30
+ throw new Error(
31
+ '[@jacques_gordon/expo-mapbox-navigation] `downloadsToken` is required for iOS builds.\n' +
32
+ 'This must be a SECRET Mapbox token (starts with "sk.") with the "Downloads:Read" scope,\n' +
33
+ 'used to authenticate Swift Package Manager when fetching the Mapbox Navigation SDK.\n' +
34
+ 'Add it to your app.json plugins array:\n' +
35
+ ' ["@jacques_gordon/expo-mapbox-navigation", { "accessToken": "pk.xxx", "downloadsToken": "sk.xxx" }]'
36
+ );
37
+ }
38
+
23
39
  const [major, minor] = mapboxMapsVersion.split('.').map(Number);
24
40
  if (major < 11 || (major === 11 && minor < 11)) {
25
41
  throw new Error(
@@ -189,6 +205,33 @@ configurations.all {
189
205
  return mod;
190
206
  });
191
207
 
208
+ // ── iOS: .netrc for SPM authentication ───────────────────────────────────
209
+ // Swift Package Manager (used by ExpoMapboxNavigation.podspec's
210
+ // spm_dependency() to fetch the Mapbox Navigation SDK) authenticates
211
+ // against api.mapbox.com using a machine entry in the user's ~/.netrc
212
+ // file — this is Mapbox's documented mechanism for private SPM package
213
+ // access, identical in spirit to the MAPBOX_DOWNLOADS_TOKEN gradle
214
+ // property used on Android.
215
+ config = withDangerousMod(config, [
216
+ 'ios',
217
+ (mod) => {
218
+ const homeDir = require('os').homedir();
219
+ const netrcPath = path.join(homeDir, '.netrc');
220
+ const netrcEntry = `machine api.mapbox.com\nlogin mapbox\npassword ${downloadsToken}\n`;
221
+
222
+ let existingContent = '';
223
+ if (fs.existsSync(netrcPath)) {
224
+ existingContent = fs.readFileSync(netrcPath, 'utf8');
225
+ }
226
+
227
+ if (!existingContent.includes('machine api.mapbox.com')) {
228
+ fs.writeFileSync(netrcPath, existingContent + netrcEntry, { mode: 0o600 });
229
+ }
230
+
231
+ return mod;
232
+ },
233
+ ]);
234
+
192
235
  return config;
193
236
  };
194
237
 
@@ -0,0 +1,60 @@
1
+ import ExpoModulesCore
2
+
3
+ public class ExpoMapboxNavigationModule: Module {
4
+ public func definition() -> ModuleDefinition {
5
+ Name("ExpoMapboxNavigation")
6
+
7
+ View(ExpoMapboxNavigationView.self) {
8
+ // Events must exactly mirror the Android module's Events() list.
9
+ Events(
10
+ "onRouteProgressChanged",
11
+ "onRoutesReady",
12
+ "onNavigationFinished",
13
+ "onNavigationCancelled",
14
+ "onRoutesFailed",
15
+ "onArrival",
16
+ "onManeuverBannerPressed"
17
+ )
18
+
19
+ Prop("coordinates") { (view: ExpoMapboxNavigationView, coordinates: [[String: Double]]) in
20
+ view.setCoordinates(coordinates)
21
+ }
22
+ Prop("waypointIndices") { (view: ExpoMapboxNavigationView, indices: [Int]?) in
23
+ view.setWaypointIndices(indices)
24
+ }
25
+ Prop("language") { (view: ExpoMapboxNavigationView, language: String?) in
26
+ view.setLanguage(language)
27
+ }
28
+ Prop("voiceUnits") { (view: ExpoMapboxNavigationView, units: String?) in
29
+ view.setVoiceUnits(units)
30
+ }
31
+ Prop("navigationProfile") { (view: ExpoMapboxNavigationView, profile: String?) in
32
+ view.setNavigationProfile(profile)
33
+ }
34
+ Prop("excludeTypes") { (view: ExpoMapboxNavigationView, types: [String]?) in
35
+ view.setExcludeTypes(types)
36
+ }
37
+ Prop("mapStyle") { (view: ExpoMapboxNavigationView, style: String?) in
38
+ view.setMapStyle(style)
39
+ }
40
+ Prop("mute") { (view: ExpoMapboxNavigationView, mute: Bool) in
41
+ view.setMute(mute)
42
+ }
43
+ Prop("maxHeight") { (view: ExpoMapboxNavigationView, height: Double?) in
44
+ view.setMaxHeight(height)
45
+ }
46
+ Prop("maxWidth") { (view: ExpoMapboxNavigationView, width: Double?) in
47
+ view.setMaxWidth(width)
48
+ }
49
+ Prop("useMapMatching") { (view: ExpoMapboxNavigationView, use: Bool) in
50
+ view.setUseMapMatching(use)
51
+ }
52
+ Prop("customRasterTileUrl") { (view: ExpoMapboxNavigationView, url: String?) in
53
+ view.setCustomRasterTileUrl(url)
54
+ }
55
+ Prop("customRasterAboveLayerId") { (view: ExpoMapboxNavigationView, layerId: String?) in
56
+ view.setCustomRasterAboveLayerId(layerId)
57
+ }
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,394 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+ import CoreLocation
4
+ import MapboxDirections
5
+ import MapboxNavigationCore
6
+ import MapboxNavigationUIKit
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
35
+ public class ExpoMapboxNavigationView: ExpoView {
36
+
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
102
+ }
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))
119
+ }
120
+
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)
128
+
129
+ if let langTag = language {
130
+ options.locale = Locale(identifier: langTag)
131
+ }
132
+
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
+ }
147
+
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
+ ])
165
+
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).
204
+ }
205
+
206
+ // Show traversed-route fade, matching common navigation app behavior.
207
+ vc.routeLineTracksTraversal = true
208
+
209
+ // Mute parity with Android's resolveVoiceUnits/SpeechVolume approach.
210
+ if mute {
211
+ provider.routeVoiceController?.speechSynthesizer.muted = true
212
+ }
213
+
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)
231
+ }
232
+
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
257
+ }
258
+
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
+ ])
277
+ }
278
+ }
279
+ }
280
+
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
+ }
291
+ }
292
+
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
+ }
306
+
307
+ private func cancelNavigation() {
308
+ tearDownNavigationViewController()
309
+ onNavigationCancelled([:])
310
+ }
311
+
312
+ // MARK: - Helpers
313
+
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
319
+ }
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
+ }
353
+
354
+ // MARK: - NavigationViewControllerDelegate
355
+ //
356
+ // Parity with Android's RouteProgressObserver / RoutesObserver / onArrival.
357
+
358
+ extension ExpoMapboxNavigationView: NavigationViewControllerDelegate {
359
+
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()
392
+ }
393
+ }
394
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacques_gordon/expo-mapbox-navigation",
3
- "version": "2.1.6",
3
+ "version": "2.2.0",
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",
@@ -8,6 +8,7 @@ const MAPBOX_MAPS_MIN_VERSION = '11.11.0';
8
8
  const withMapboxNavigation = (config, options = {}) => {
9
9
  const {
10
10
  accessToken,
11
+ downloadsToken,
11
12
  mapboxMapsVersion = MAPBOX_MAPS_MIN_VERSION,
12
13
  androidColorOverrides = {},
13
14
  } = options;
@@ -20,6 +21,21 @@ const withMapboxNavigation = (config, options = {}) => {
20
21
  );
21
22
  }
22
23
 
24
+ // downloadsToken (a secret sk.* token with the Downloads:Read scope) is
25
+ // required on iOS to authenticate Swift Package Manager's fetch of the
26
+ // Mapbox Navigation SDK package — without it, `pod install`/`expo
27
+ // prebuild` will fail to resolve the SPM dependency declared in
28
+ // ExpoMapboxNavigation.podspec.
29
+ if (!downloadsToken) {
30
+ throw new Error(
31
+ '[@jacques_gordon/expo-mapbox-navigation] `downloadsToken` is required for iOS builds.\n' +
32
+ 'This must be a SECRET Mapbox token (starts with "sk.") with the "Downloads:Read" scope,\n' +
33
+ 'used to authenticate Swift Package Manager when fetching the Mapbox Navigation SDK.\n' +
34
+ 'Add it to your app.json plugins array:\n' +
35
+ ' ["@jacques_gordon/expo-mapbox-navigation", { "accessToken": "pk.xxx", "downloadsToken": "sk.xxx" }]'
36
+ );
37
+ }
38
+
23
39
  const [major, minor] = mapboxMapsVersion.split('.').map(Number);
24
40
  if (major < 11 || (major === 11 && minor < 11)) {
25
41
  throw new Error(
@@ -189,6 +205,33 @@ configurations.all {
189
205
  return mod;
190
206
  });
191
207
 
208
+ // ── iOS: .netrc for SPM authentication ───────────────────────────────────
209
+ // Swift Package Manager (used by ExpoMapboxNavigation.podspec's
210
+ // spm_dependency() to fetch the Mapbox Navigation SDK) authenticates
211
+ // against api.mapbox.com using a machine entry in the user's ~/.netrc
212
+ // file — this is Mapbox's documented mechanism for private SPM package
213
+ // access, identical in spirit to the MAPBOX_DOWNLOADS_TOKEN gradle
214
+ // property used on Android.
215
+ config = withDangerousMod(config, [
216
+ 'ios',
217
+ (mod) => {
218
+ const homeDir = require('os').homedir();
219
+ const netrcPath = path.join(homeDir, '.netrc');
220
+ const netrcEntry = `machine api.mapbox.com\nlogin mapbox\npassword ${downloadsToken}\n`;
221
+
222
+ let existingContent = '';
223
+ if (fs.existsSync(netrcPath)) {
224
+ existingContent = fs.readFileSync(netrcPath, 'utf8');
225
+ }
226
+
227
+ if (!existingContent.includes('machine api.mapbox.com')) {
228
+ fs.writeFileSync(netrcPath, existingContent + netrcEntry, { mode: 0o600 });
229
+ }
230
+
231
+ return mod;
232
+ },
233
+ ]);
234
+
192
235
  return config;
193
236
  };
194
237
 
@@ -1,40 +0,0 @@
1
- <?xml version="1.0" encoding="utf-8"?>
2
- <resources>
3
-
4
- <!--
5
- FIX: Street/road name text was being cut off on the instruction banner
6
- (e.g. "Wulmersumsesteenwe" instead of "Wulmersumsesteenweg") on
7
- narrower or smaller-DPI screens.
8
-
9
- Root cause: MapboxPrimaryManeuver (the road name text view inside
10
- MapboxManeuverView) uses a fixed text size by default with no
11
- auto-shrink behavior, so long street names get clipped by ellipsis
12
- instead of adapting to the available banner width.
13
-
14
- Fix: define a custom text appearance with Android's built-in
15
- autoSizeTextType="uniform", which lets the system shrink the text
16
- size step-by-step (within autoSizeMinTextSize..autoSizeMaxTextSize)
17
- until the full string fits the view width — exactly like Waze/Google
18
- Maps adapt their instruction banner text across different phone
19
- screen sizes.
20
- -->
21
- <style name="MapboxCustomManeuverTextStyle" parent="MapboxStylePrimaryManeuver">
22
- <item name="android:autoSizeTextType">uniform</item>
23
- <item name="android:autoSizeMinTextSize">16sp</item>
24
- <item name="android:autoSizeMaxTextSize">28sp</item>
25
- <item name="android:autoSizeStepGranularity">1sp</item>
26
- <item name="android:maxLines">2</item>
27
- <item name="android:ellipsize">end</item>
28
- </style>
29
-
30
- <!--
31
- Custom MapboxManeuverView style that:
32
- 1. Applies the auto-sizing primary maneuver text style above.
33
- 2. Keeps default backgrounds/colors (day mode handled by Nav SDK
34
- style switching already implemented in ExpoMapboxNavigationView).
35
- -->
36
- <style name="MapboxCustomManeuverViewStyle" parent="MapboxStyleManeuverView">
37
- <item name="primaryManeuverTextAppearance">@style/MapboxCustomManeuverTextStyle</item>
38
- </style>
39
-
40
- </resources>