@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.
- package/ExpoMapboxNavigation.podspec +32 -16
- package/README.md +16 -0
- package/android/src/main/java/expo/modules/mapboxnavigation/ExpoMapboxNavigationModule.kt +2 -1
- package/android/src/main/java/expo/modules/mapboxnavigation/ExpoMapboxNavigationView.kt +174 -61
- package/app.plugin.js +43 -0
- package/build/index.d.ts +35 -0
- package/ios/ExpoMapboxNavigationModule.swift +60 -0
- package/ios/ExpoMapboxNavigationView.swift +394 -0
- package/package.json +1 -1
- package/plugin/src/index.js +43 -0
- package/src/index.tsx +37 -0
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
#
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
'
|
|
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,
|
|
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(
|
|
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 =
|
|
188
|
+
this.color = color
|
|
176
189
|
style = Paint.Style.FILL
|
|
177
190
|
}
|
|
178
|
-
|
|
179
|
-
// Speaker body (trapezoid)
|
|
191
|
+
// Speaker cone (rounded, modern Material style)
|
|
180
192
|
val body = Path().apply {
|
|
181
|
-
moveTo(s * 0.
|
|
182
|
-
lineTo(s * 0.
|
|
183
|
-
lineTo(s * 0.
|
|
184
|
-
|
|
185
|
-
lineTo(s * 0.
|
|
186
|
-
|
|
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.
|
|
209
|
+
paint.strokeWidth = s * 0.06f
|
|
194
210
|
paint.strokeCap = Paint.Cap.ROUND
|
|
195
|
-
|
|
196
|
-
c.drawArc(RectF(s*0.
|
|
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.
|
|
215
|
+
paint.strokeWidth = s * 0.065f
|
|
203
216
|
paint.strokeCap = Paint.Cap.ROUND
|
|
204
|
-
paint.color = Color.parseColor("#
|
|
205
|
-
c.drawLine(s*0.
|
|
206
|
-
c.drawLine(s*0.88f, s*0.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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(
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
c.
|
|
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.
|
|
260
|
+
color = Color.parseColor("#1A73E8")
|
|
252
261
|
style = Paint.Style.FILL
|
|
253
262
|
}
|
|
254
|
-
|
|
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.
|
|
258
|
-
lineTo(s * 0.
|
|
259
|
-
lineTo(s * 0.50f, s * 0.
|
|
260
|
-
lineTo(s * 0.
|
|
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 /
|
|
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.
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/plugin/src/index.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/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
|
// ─────────────────────────────────────────────────────────────────────────────
|