@jacques_gordon/expo-mapbox-navigation 2.1.5 → 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
@@ -15,7 +15,8 @@ class ExpoMapboxNavigationModule : Module() {
15
15
  "onNavigationFinished",
16
16
  "onNavigationCancelled",
17
17
  "onRoutesFailed",
18
- "onArrival"
18
+ "onArrival",
19
+ "onManeuverBannerPressed"
19
20
  )
20
21
 
21
22
  Prop("coordinates") { view: ExpoMapboxNavigationView, coordinates: List<Map<String, Double>> ->
@@ -93,6 +93,11 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
93
93
  private val onNavigationCancelled by EventDispatcher()
94
94
  private val onRoutesFailed by EventDispatcher()
95
95
  private val onArrival by EventDispatcher()
96
+ // Feature: emits the full list of turn-by-turn steps when the instruction
97
+ // banner is tapped, so the RN layer can render a steps list (bottom
98
+ // sheet/modal). Payload: { steps: [{ instruction, distanceMeters,
99
+ // maneuverType, maneuverModifier, laneInstructions }] }
100
+ private val onManeuverBannerPressed by EventDispatcher()
96
101
 
97
102
  // ── Views ─────────────────────────────────────────────────────────────────
98
103
  private val mapView: MapView = MapView(context)
@@ -135,11 +140,15 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
135
140
  private val dp = context.resources.displayMetrics.density
136
141
 
137
142
  // ── Viewport padding ──────────────────────────────────────────────────────
143
+ // ── Viewport padding — EXACT official Mapbox values from the camera guide ──
144
+ // Previous bottom=300dp pushed the focal point too far up, causing
145
+ // the puck to visibly jump/drift between location updates.
146
+ // Official guide value: top=180, left=40, bottom=150, right=40
138
147
  private val followingPadding by lazy {
139
- EdgeInsets(180.0 * dp, 40.0 * dp, 300.0 * dp, 40.0 * dp)
148
+ EdgeInsets(180.0 * dp, 40.0 * dp, 150.0 * dp, 40.0 * dp)
140
149
  }
141
150
  private val overviewPadding by lazy {
142
- EdgeInsets(120.0 * dp, 40.0 * dp, 120.0 * dp, 40.0 * dp)
151
+ EdgeInsets(140.0 * dp, 40.0 * dp, 120.0 * dp, 40.0 * dp)
143
152
  }
144
153
 
145
154
  // ── Props ─────────────────────────────────────────────────────────────────
@@ -167,43 +176,47 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
167
176
  // Draw icons programmatically (matching screenshot: speaker, route, arrow)
168
177
  // ─────────────────────────────────────────────────────────────────────────
169
178
 
179
+ // ── Modern Google Maps / Waze style icons ──────────────────────────────────
180
+
170
181
  private fun drawSpeakerIcon(muted: Boolean): Bitmap {
171
182
  val size = (44 * dp).toInt()
172
183
  val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
173
184
  val c = Canvas(bmp)
185
+ val s = size.toFloat()
186
+ val color = if (muted) Color.parseColor("#5F6368") else Color.parseColor("#1A73E8")
174
187
  val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
175
- color = Color.BLACK
188
+ this.color = color
176
189
  style = Paint.Style.FILL
177
190
  }
178
- val s = size.toFloat()
179
- // Speaker body (trapezoid)
191
+ // Speaker cone (rounded, modern Material style)
180
192
  val body = Path().apply {
181
- moveTo(s * 0.18f, s * 0.38f)
182
- lineTo(s * 0.38f, s * 0.38f)
183
- lineTo(s * 0.58f, s * 0.22f)
184
- lineTo(s * 0.58f, s * 0.78f)
185
- lineTo(s * 0.38f, s * 0.62f)
186
- lineTo(s * 0.18f, s * 0.62f)
193
+ moveTo(s * 0.16f, s * 0.40f)
194
+ lineTo(s * 0.34f, s * 0.40f)
195
+ lineTo(s * 0.56f, s * 0.20f)
196
+ quadTo(s * 0.60f, s * 0.17f, s * 0.60f, s * 0.22f)
197
+ lineTo(s * 0.60f, s * 0.78f)
198
+ quadTo(s * 0.60f, s * 0.83f, s * 0.56f, s * 0.80f)
199
+ lineTo(s * 0.34f, s * 0.60f)
200
+ lineTo(s * 0.16f, s * 0.60f)
201
+ quadTo(s * 0.12f, s * 0.60f, s * 0.12f, s * 0.56f)
202
+ lineTo(s * 0.12f, s * 0.44f)
203
+ quadTo(s * 0.12f, s * 0.40f, s * 0.16f, s * 0.40f)
187
204
  close()
188
205
  }
189
206
  c.drawPath(body, paint)
190
207
  if (!muted) {
191
- // Sound waves arcs
192
208
  paint.style = Paint.Style.STROKE
193
- paint.strokeWidth = s * 0.065f
209
+ paint.strokeWidth = s * 0.06f
194
210
  paint.strokeCap = Paint.Cap.ROUND
195
- // Inner arc
196
- c.drawArc(RectF(s*0.60f, s*0.33f, s*0.80f, s*0.67f), -60f, 120f, false, paint)
197
- // Outer arc
198
- c.drawArc(RectF(s*0.64f, s*0.24f, s*0.92f, s*0.76f), -55f, 110f, false, paint)
211
+ c.drawArc(RectF(s*0.62f, s*0.34f, s*0.80f, s*0.66f), -45f, 90f, false, paint)
212
+ c.drawArc(RectF(s*0.66f, s*0.22f, s*0.92f, s*0.78f), -40f, 80f, false, paint)
199
213
  } else {
200
- // X mark for muted
201
214
  paint.style = Paint.Style.STROKE
202
- paint.strokeWidth = s * 0.07f
215
+ paint.strokeWidth = s * 0.065f
203
216
  paint.strokeCap = Paint.Cap.ROUND
204
- paint.color = Color.parseColor("#FF4444")
205
- c.drawLine(s*0.65f, s*0.32f, s*0.88f, s*0.68f, paint)
206
- c.drawLine(s*0.88f, s*0.32f, s*0.65f, s*0.68f, paint)
217
+ paint.color = Color.parseColor("#EA4335")
218
+ c.drawLine(s*0.66f, s*0.34f, s*0.88f, s*0.66f, paint)
219
+ c.drawLine(s*0.88f, s*0.34f, s*0.66f, s*0.66f, paint)
207
220
  }
208
221
  return bmp
209
222
  }
@@ -212,34 +225,29 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
212
225
  val size = (44 * dp).toInt()
213
226
  val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
214
227
  val c = Canvas(bmp)
228
+ val s = size.toFloat()
215
229
  val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
216
- color = Color.BLACK
217
- style = Paint.Style.STROKE
218
- strokeWidth = size * 0.08f
219
- strokeCap = Paint.Cap.ROUND
220
- strokeJoin = Paint.Join.ROUND
230
+ color = Color.parseColor("#1A73E8")
221
231
  }
222
- val s = size.toFloat()
223
- // Route line with S-curve (like the image: start dot → curve → end pin)
224
- // Start dot
225
- paint.style = Paint.Style.FILL
226
- c.drawCircle(s * 0.28f, s * 0.75f, s * 0.07f, paint)
227
- // Route path
232
+ // Map/overview icon: stacked layers (like Google Maps "map view" icon)
228
233
  paint.style = Paint.Style.STROKE
229
- val routePath = Path().apply {
230
- moveTo(s * 0.28f, s * 0.75f)
231
- cubicTo(
232
- s * 0.28f, s * 0.45f,
233
- s * 0.72f, s * 0.55f,
234
- s * 0.72f, s * 0.25f
235
- )
234
+ paint.strokeWidth = s * 0.07f
235
+ paint.strokeJoin = Paint.Join.ROUND
236
+ paint.strokeCap = Paint.Cap.ROUND
237
+ // Outer diamond/map frame
238
+ val frame = Path().apply {
239
+ moveTo(s * 0.50f, s * 0.14f)
240
+ lineTo(s * 0.84f, s * 0.34f)
241
+ lineTo(s * 0.84f, s * 0.66f)
242
+ lineTo(s * 0.50f, s * 0.86f)
243
+ lineTo(s * 0.16f, s * 0.66f)
244
+ lineTo(s * 0.16f, s * 0.34f)
245
+ close()
236
246
  }
237
- c.drawPath(routePath, paint)
238
- // End pin (circle with dot)
239
- paint.style = Paint.Style.STROKE
240
- c.drawCircle(s * 0.72f, s * 0.22f, s * 0.10f, paint)
241
- paint.style = Paint.Style.FILL
242
- c.drawCircle(s * 0.72f, s * 0.22f, s * 0.04f, paint)
247
+ c.drawPath(frame, paint)
248
+ // Two horizontal fold lines (classic map icon)
249
+ c.drawLine(s*0.36f, s*0.22f, s*0.36f, s*0.78f, paint)
250
+ c.drawLine(s*0.64f, s*0.22f, s*0.64f, s*0.78f, paint)
243
251
  return bmp
244
252
  }
245
253
 
@@ -247,17 +255,17 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
247
255
  val size = (44 * dp).toInt()
248
256
  val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
249
257
  val c = Canvas(bmp)
258
+ val s = size.toFloat()
250
259
  val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
251
- color = Color.BLACK
260
+ color = Color.parseColor("#1A73E8")
252
261
  style = Paint.Style.FILL
253
262
  }
254
- val s = size.toFloat()
255
- // Navigation arrow pointing up (filled chevron/arrow like Waze)
263
+ // Navigation/recenter compass arrow (Google Maps "my location, follow" style)
256
264
  val arrow = Path().apply {
257
- moveTo(s * 0.50f, s * 0.14f) // tip
258
- lineTo(s * 0.78f, s * 0.76f) // bottom-right
259
- lineTo(s * 0.50f, s * 0.60f) // bottom-center indent
260
- lineTo(s * 0.22f, s * 0.76f) // bottom-left
265
+ moveTo(s * 0.50f, s * 0.12f) // tip
266
+ lineTo(s * 0.80f, s * 0.82f) // bottom-right
267
+ lineTo(s * 0.50f, s * 0.64f) // notch center
268
+ lineTo(s * 0.20f, s * 0.82f) // bottom-left
261
269
  close()
262
270
  }
263
271
  c.drawPath(arrow, paint)
@@ -295,12 +303,30 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
295
303
  ))
296
304
 
297
305
  // ── ManeuverView — top ─────────────────────────────────────────────────
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.
298
319
  val mv = MapboxManeuverView(context)
299
320
  root.addView(mv as View, FrameLayout.LayoutParams(
300
321
  FrameLayout.LayoutParams.MATCH_PARENT,
301
322
  FrameLayout.LayoutParams.WRAP_CONTENT
302
323
  ).also { it.gravity = Gravity.TOP })
303
324
  mv.visibility = View.INVISIBLE
325
+ // Feature: tap the instruction banner to see the full list of upcoming
326
+ // turn-by-turn steps. We emit the data via event so the RN/JS layer can
327
+ // render a bottom sheet or modal using its own native UI components —
328
+ // consistent with how all other navigation events are surfaced.
329
+ mv.setOnClickListener { emitFullRouteSteps() }
304
330
  maneuverView = mv
305
331
 
306
332
  // ── Side buttons — RIGHT side, just below maneuver banner ─────────────
@@ -336,17 +362,21 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
336
362
  })
337
363
  btnOverviewView = overviewBtn
338
364
 
339
- // Button 3: Recenter / Navigation arrow (hidden until in overview mode)
365
+ // Button 3: Recenter / return to follow mode ALWAYS visible, below button 2
366
+ // Greyed out (lower opacity) when already following; full opacity in overview mode
340
367
  val recenterBtn = makeIconButton(drawNavigationArrowIcon()) { recenterCamera() }
341
- recenterBtn.visibility = View.GONE
368
+ recenterBtn.alpha = 0.4f // dimmed by default (already following)
342
369
  sideCol.addView(recenterBtn, LinearLayout.LayoutParams(btnSize, btnSize))
343
370
  btnRecenterView = recenterBtn
344
371
 
345
372
  // ── SpeedInfoView — bottom-left, above ETA bar ────────────────────────
373
+ // FIX: use WRAP_CONTENT instead of fixed 72x72dp — the native Mapbox
374
+ // component renders both the posted speed limit sign AND the current
375
+ // vehicle speed side-by-side, which needs flexible width
346
376
  val siv = MapboxSpeedInfoView(context)
347
377
  root.addView(siv as View, FrameLayout.LayoutParams(
348
- (72 * dp).toInt(),
349
- (72 * dp).toInt()
378
+ FrameLayout.LayoutParams.WRAP_CONTENT,
379
+ FrameLayout.LayoutParams.WRAP_CONTENT
350
380
  ).also {
351
381
  it.gravity = Gravity.BOTTOM or Gravity.START
352
382
  it.setMargins((12 * dp).toInt(), 0, 0, (88 * dp).toInt())
@@ -492,6 +522,11 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
492
522
  viewportDataSource.followingPadding = followingPadding
493
523
  viewportDataSource.overviewPadding = overviewPadding
494
524
 
525
+ // Waze-style tilted 3D following view — also helps stabilize the
526
+ // visual framing since pitch reduces perceived jitter from GPS noise
527
+ viewportDataSource.options.followingFrameOptions.defaultPitch = 45.0
528
+ viewportDataSource.options.followingFrameOptions.maxZoom = 17.0
529
+
495
530
  navigationCamera = NavigationCamera(
496
531
  mapView.mapboxMap,
497
532
  mapView.camera,
@@ -601,7 +636,15 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
601
636
  override fun onNewRawLocation(rawLocation: Location) {}
602
637
  override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
603
638
  val loc = result.enhancedLocation
604
- navigationLocationProvider.changePosition(location = loc, keyPoints = result.keyPoints)
639
+ // ── FIX: Puck jitter / drift ──────────────────────────────────────
640
+ // Root cause (confirmed via Mapbox GitHub issue #4140):
641
+ // When keyPoints are passed to changePosition(), the puck animator
642
+ // splits the 1-second transition EVENLY across all keypoints in time,
643
+ // but the keypoints are NOT evenly spaced in distance. This causes the
644
+ // puck to speed up/slow down and visibly drift left/right off the
645
+ // route line, even on a perfectly straight road.
646
+ // Official Mapbox workaround: pass emptyList() instead of keyPoints.
647
+ navigationLocationProvider.changePosition(location = loc, keyPoints = emptyList())
605
648
  viewportDataSource.onLocationChanged(loc)
606
649
  viewportDataSource.evaluate()
607
650
 
@@ -610,6 +653,11 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
610
653
  safeCameraFollowing()
611
654
  }
612
655
 
656
+ // ── Speed limit (issue #4: was not displaying) ──────────────────
657
+ // updatePostedAndCurrentSpeed returns null when:
658
+ // 1. The route response has no `maxspeed` annotation for this segment
659
+ // 2. GPS speed is unavailable
660
+ // We log this so issues can be diagnosed via `adb logcat`
613
661
  val fmtOptions = DistanceFormatterOptions.Builder(context).build()
614
662
  val speedInfo = speedInfoApi.updatePostedAndCurrentSpeed(result, fmtOptions)
615
663
  if (speedInfo != null) {
@@ -617,6 +665,8 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
617
665
  speedInfoView?.render(speedInfo)
618
666
  } else {
619
667
  speedInfoView?.visibility = View.GONE
668
+ Log.d(TAG, "Speed info unavailable for this location/route segment " +
669
+ "(no maxspeed annotation or GPS speed not ready yet)")
620
670
  }
621
671
 
622
672
  checkAndSwitchDayNight()
@@ -643,7 +693,8 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
643
693
  recenterCamera()
644
694
  } else {
645
695
  isOverviewMode = true
646
- btnRecenterView?.visibility = View.VISIBLE
696
+ // Button 3 lights up (full opacity) to invite the user to tap it
697
+ btnRecenterView?.alpha = 1f
647
698
  try {
648
699
  navigationCamera.requestNavigationCameraToOverview()
649
700
  } catch (e: Exception) {
@@ -654,7 +705,8 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
654
705
 
655
706
  private fun recenterCamera() {
656
707
  isOverviewMode = false
657
- btnRecenterView?.visibility = View.GONE
708
+ // Button 3 dims again — already in following mode
709
+ btnRecenterView?.alpha = 0.4f
658
710
  safeCameraFollowing()
659
711
  }
660
712
 
@@ -713,7 +765,53 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
713
765
  }
714
766
  }
715
767
 
716
- private fun cancelNavigation() {
768
+ // ─────────────────────────────────────────────────────────────────────────
769
+ // Feature: full route steps list (triggered by tapping the instruction
770
+ // banner). Extracts every LegStep across all legs of the active route,
771
+ // including lane guidance data when present on that step's BannerInstructions.
772
+ // ─────────────────────────────────────────────────────────────────────────
773
+ private fun emitFullRouteSteps() {
774
+ val routes = mapboxNavigation?.getNavigationRoutes()
775
+ val activeRoute = routes?.firstOrNull() ?: run {
776
+ onManeuverBannerPressed(mapOf("steps" to emptyList<Map<String, Any>>()))
777
+ return
778
+ }
779
+
780
+ val stepsPayload = mutableListOf<Map<String, Any>>()
781
+
782
+ activeRoute.directionsRoute.legs()?.forEach { leg ->
783
+ leg.steps()?.forEach { step ->
784
+ val maneuver = step.maneuver()
785
+ val firstBanner = step.bannerInstructions()?.firstOrNull()
786
+
787
+ // Lane guidance: present on BannerInstructions.sub() when type == "lane"
788
+ val laneData = firstBanner?.sub()?.components()
789
+ ?.filter { it.type() == "lane" }
790
+ ?.map { component ->
791
+ mapOf(
792
+ "active" to (component.active() ?: false),
793
+ "directions" to (component.directions() ?: emptyList<String>())
794
+ )
795
+ } ?: emptyList()
796
+
797
+ stepsPayload.add(
798
+ mapOf(
799
+ "instruction" to (maneuver?.instruction() ?: ""),
800
+ "distanceMeters" to (step.distance() ?: 0.0),
801
+ "durationSeconds" to (step.duration() ?: 0.0),
802
+ "maneuverType" to (maneuver?.type() ?: ""),
803
+ "maneuverModifier" to (maneuver?.modifier() ?: ""),
804
+ "roadName" to (step.name() ?: ""),
805
+ "laneInstructions" to laneData
806
+ )
807
+ )
808
+ }
809
+ }
810
+
811
+ onManeuverBannerPressed(mapOf("steps" to stepsPayload))
812
+ }
813
+
814
+
717
815
  speechApi.cancel()
718
816
  voiceInstructionsPlayer.clear()
719
817
  mapboxNavigation?.setNavigationRoutes(listOf())
@@ -760,6 +858,21 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
760
858
  .voiceUnits(resolveVoiceUnits())
761
859
  .coordinatesList(points)
762
860
  .annotations("maxspeed,congestion,duration,speed")
861
+ // ── FIX: Lane guidance was not displaying ───────────────────────────
862
+ // Root cause: BannerInstructions.sub() (which carries lane data) is
863
+ // only returned by the Directions API when explicitly requested.
864
+ // applyDefaultNavigationOptions() does set bannerInstructions(true)
865
+ // internally, but several confirmed cases (Mapbox GitHub issue #7377,
866
+ // "Lane guidance not showing, using Navigation Drop-in UI") show this
867
+ // needs to be set explicitly to reliably trigger lane data in the
868
+ // response — official Mapbox examples do the same as a defensive fix.
869
+ .bannerInstructions(true)
870
+ // steps(true) is required for any BannerInstructions/VoiceInstructions
871
+ // to be present in the response at all.
872
+ .steps(true)
873
+ // roundaboutExits(true) ensures lane + exit guidance is generated for
874
+ // roundabout maneuvers specifically (separate flag from bannerInstructions).
875
+ .roundaboutExits(true)
763
876
  waypointIndices?.let { builder.waypointIndicesList(it) }
764
877
  navigationProfile?.let { builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it") }
765
878
  excludeTypes?.takeIf { it.isNotEmpty() }?.let { builder.exclude(it.joinToString(",")) }
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
 
package/build/index.d.ts CHANGED
@@ -84,6 +84,14 @@ export interface MapboxNavigationViewProps {
84
84
  onArrival?: (event: {
85
85
  nativeEvent: {};
86
86
  }) => void;
87
+ /**
88
+ * Fired when the user taps the turn-by-turn instruction banner.
89
+ * Use this to open a bottom sheet / modal listing every upcoming step
90
+ * of the route, including lane guidance data where available.
91
+ */
92
+ onManeuverBannerPressed?: (event: {
93
+ nativeEvent: ManeuverBannerPressedEvent;
94
+ }) => void;
87
95
  style?: ViewStyle;
88
96
  }
89
97
  export interface RouteProgressEvent {
@@ -98,6 +106,33 @@ export interface RoutesReadyEvent {
98
106
  distanceMeters: number;
99
107
  durationSeconds: number;
100
108
  }
109
+ /** A single lane's directional options at an upcoming intersection. */
110
+ export interface LaneInstruction {
111
+ /** Whether this lane is the recommended lane for the upcoming maneuver. */
112
+ active: boolean;
113
+ /** Directions this lane allows, e.g. ["straight"], ["left"], ["straight", "right"]. */
114
+ directions: string[];
115
+ }
116
+ /** One step (maneuver) along the route, used for the full-route steps list. */
117
+ export interface RouteStep {
118
+ /** Human-readable instruction text, e.g. "Turn left onto Main St". */
119
+ instruction: string;
120
+ /** Distance in metres for this step. */
121
+ distanceMeters: number;
122
+ /** Duration in seconds for this step. */
123
+ durationSeconds: number;
124
+ /** Directions API maneuver type, e.g. "turn", "merge", "roundabout". */
125
+ maneuverType: string;
126
+ /** Directions API maneuver modifier, e.g. "left", "right", "straight". */
127
+ maneuverModifier: string;
128
+ /** Name of the road for this step, if available. */
129
+ roadName: string;
130
+ /** Lane guidance for this step's upcoming maneuver, if available. */
131
+ laneInstructions: LaneInstruction[];
132
+ }
133
+ export interface ManeuverBannerPressedEvent {
134
+ steps: RouteStep[];
135
+ }
101
136
  /**
102
137
  * MapboxNavigationView
103
138
  *
@@ -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.5",
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
 
package/src/index.tsx CHANGED
@@ -104,6 +104,13 @@ export interface MapboxNavigationViewProps {
104
104
  onRoutesFailed?: (event: { nativeEvent: { message: string } }) => void;
105
105
  onArrival?: (event: { nativeEvent: {} }) => void;
106
106
 
107
+ /**
108
+ * Fired when the user taps the turn-by-turn instruction banner.
109
+ * Use this to open a bottom sheet / modal listing every upcoming step
110
+ * of the route, including lane guidance data where available.
111
+ */
112
+ onManeuverBannerPressed?: (event: { nativeEvent: ManeuverBannerPressedEvent }) => void;
113
+
107
114
  style?: ViewStyle;
108
115
  }
109
116
 
@@ -121,6 +128,36 @@ export interface RoutesReadyEvent {
121
128
  durationSeconds: number;
122
129
  }
123
130
 
131
+ /** A single lane's directional options at an upcoming intersection. */
132
+ export interface LaneInstruction {
133
+ /** Whether this lane is the recommended lane for the upcoming maneuver. */
134
+ active: boolean;
135
+ /** Directions this lane allows, e.g. ["straight"], ["left"], ["straight", "right"]. */
136
+ directions: string[];
137
+ }
138
+
139
+ /** One step (maneuver) along the route, used for the full-route steps list. */
140
+ export interface RouteStep {
141
+ /** Human-readable instruction text, e.g. "Turn left onto Main St". */
142
+ instruction: string;
143
+ /** Distance in metres for this step. */
144
+ distanceMeters: number;
145
+ /** Duration in seconds for this step. */
146
+ durationSeconds: number;
147
+ /** Directions API maneuver type, e.g. "turn", "merge", "roundabout". */
148
+ maneuverType: string;
149
+ /** Directions API maneuver modifier, e.g. "left", "right", "straight". */
150
+ maneuverModifier: string;
151
+ /** Name of the road for this step, if available. */
152
+ roadName: string;
153
+ /** Lane guidance for this step's upcoming maneuver, if available. */
154
+ laneInstructions: LaneInstruction[];
155
+ }
156
+
157
+ export interface ManeuverBannerPressedEvent {
158
+ steps: RouteStep[];
159
+ }
160
+
124
161
  // ─────────────────────────────────────────────────────────────────────────────
125
162
  // Native view
126
163
  // ─────────────────────────────────────────────────────────────────────────────