@jacques_gordon/expo-mapbox-navigation 2.2.1 → 2.2.4

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/README.md CHANGED
@@ -99,6 +99,42 @@ export default function Navigation() {
99
99
  }
100
100
  ```
101
101
 
102
+ ### Color customization (Android)
103
+
104
+ All color props are optional — if not provided, the defaults below are applied automatically.
105
+
106
+ ```tsx
107
+ <MapboxNavigationView
108
+ // Maneuver banner (turn-by-turn instruction banner)
109
+ maneuverBackgroundColorDay="#1E2433" // default: Mapbox native style color
110
+ maneuverTurnIconColor="#1A73E8" // default: Mapbox native style color
111
+
112
+ // Bottom ETA / duration / distance bar
113
+ etaBarBackgroundColor="#1E2433" // default: #1E2433 (dark navy)
114
+ etaTextColor="#FFFFFF" // default: #FFFFFF (white)
115
+
116
+ // Control buttons (mute, overview, recenter)
117
+ iconButtonColor="#1A73E8" // default: #1A73E8 (Google Blue)
118
+ iconButtonMutedColor="#EA4335" // default: #EA4335 (Google Red)
119
+
120
+ // Route line color — set via plugin config, not a view prop
121
+ // (uses androidColorOverrides in app.json)
122
+ {...otherProps}
123
+ />
124
+ ```
125
+
126
+ ```json
127
+ // app.json — route line and other Mapbox native resource colors
128
+ ["@jacques_gordon/expo-mapbox-navigation", {
129
+ "accessToken": "pk.xxx",
130
+ "downloadsToken": "sk.xxx",
131
+ "androidColorOverrides": {
132
+ "mapbox_primary_route_color": "#0055FF",
133
+ "mapbox_main_maneuver_background_color": "#FF5500"
134
+ }
135
+ }]
136
+ ```
137
+
102
138
  ---
103
139
 
104
140
  ## Props
@@ -58,6 +58,25 @@ class ExpoMapboxNavigationModule : Module() {
58
58
  Prop("customRasterAboveLayerId") { view: ExpoMapboxNavigationView, layerId: String? ->
59
59
  view.setCustomRasterAboveLayerId(layerId)
60
60
  }
61
+ // ── Color customization props ────────────────────────────────────────
62
+ Prop("maneuverBackgroundColorDay") { view: ExpoMapboxNavigationView, color: String? ->
63
+ view.setManeuverBackgroundColorDay(color)
64
+ }
65
+ Prop("maneuverTurnIconColor") { view: ExpoMapboxNavigationView, color: String? ->
66
+ view.setManeuverTurnIconColor(color)
67
+ }
68
+ Prop("etaBarBackgroundColor") { view: ExpoMapboxNavigationView, color: String? ->
69
+ view.setEtaBarBackgroundColor(color)
70
+ }
71
+ Prop("etaTextColor") { view: ExpoMapboxNavigationView, color: String? ->
72
+ view.setEtaTextColor(color)
73
+ }
74
+ Prop("iconButtonColor") { view: ExpoMapboxNavigationView, color: String? ->
75
+ view.setIconButtonColor(color)
76
+ }
77
+ Prop("iconButtonMutedColor") { view: ExpoMapboxNavigationView, color: String? ->
78
+ view.setIconButtonMutedColor(color)
79
+ }
61
80
  }
62
81
  }
63
82
  }
@@ -54,6 +54,7 @@ import com.mapbox.navigation.tripdata.progress.model.EstimatedTimeToArrivalForma
54
54
  import com.mapbox.navigation.tripdata.progress.model.TimeRemainingFormatter
55
55
  import com.mapbox.navigation.tripdata.progress.model.TripProgressUpdateFormatter
56
56
  import com.mapbox.navigation.tripdata.speedlimit.api.MapboxSpeedInfoApi
57
+ import com.mapbox.navigation.ui.components.maneuver.model.ManeuverViewOptions
57
58
  import com.mapbox.navigation.ui.components.maneuver.view.MapboxManeuverView
58
59
  import com.mapbox.navigation.ui.components.speedlimit.view.MapboxSpeedInfoView
59
60
  import com.mapbox.navigation.ui.components.tripprogress.view.MapboxTripProgressView
@@ -143,8 +144,17 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
143
144
  // Previous bottom=300dp pushed the focal point too far up, causing
144
145
  // the puck to visibly jump/drift between location updates.
145
146
  // Official guide value: top=180, left=40, bottom=150, right=40
147
+ // ── Viewport padding — Waze-style vehicle position ──────────────────────────
148
+ // bottom=300dp pushes the focal point (and thus the vehicle puck) up to
149
+ // roughly 30% from the bottom of the screen, matching Waze/Google Maps.
150
+ // NOTE: this was briefly regressed to bottom=150dp (the "exact" value from
151
+ // the Mapbox camera guide example) while investigating puck jitter — but
152
+ // the jitter was actually caused by passing keyPoints to changePosition()
153
+ // (see the LocationObserver fix below), NOT by this padding value. The
154
+ // jitter fix and the Waze-style positioning are independent and both
155
+ // needed; restoring bottom=300dp here does not reintroduce the jitter.
146
156
  private val followingPadding by lazy {
147
- EdgeInsets(180.0 * dp, 40.0 * dp, 150.0 * dp, 40.0 * dp)
157
+ EdgeInsets(180.0 * dp, 40.0 * dp, 300.0 * dp, 40.0 * dp)
148
158
  }
149
159
  private val overviewPadding by lazy {
150
160
  EdgeInsets(140.0 * dp, 40.0 * dp, 120.0 * dp, 40.0 * dp)
@@ -165,6 +175,18 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
165
175
  private var customRasterTileUrl: String? = null
166
176
  private var customRasterAboveLayerId: String? = null
167
177
 
178
+ // ── Color customization props ────────────────────────────────────────────
179
+ // Maneuver banner colors — verified against ManeuverViewOptions public API
180
+ // (maneuverBackgroundColor, turnIconManeuver are real documented properties)
181
+ private var maneuverBackgroundColorDay: String? = null
182
+ private var maneuverTurnIconColor: String? = null
183
+ // ETA bottom bar colors — fully custom view, safe to color freely
184
+ private var etaBarBackgroundColor: String? = null
185
+ private var etaTextColor: String? = null
186
+ // Custom icon button colors (mute, overview, recenter) — our own bitmaps
187
+ private var iconButtonColor: String? = null
188
+ private var iconButtonMutedColor: String? = null
189
+
168
190
  init {
169
191
  buildUI()
170
192
  initAPIs()
@@ -182,7 +204,10 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
182
204
  val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
183
205
  val c = Canvas(bmp)
184
206
  val s = size.toFloat()
185
- val color = if (muted) Color.parseColor("#5F6368") else Color.parseColor("#1A73E8")
207
+ val defaultColor = if (muted) "#5F6368" else "#1A73E8"
208
+ val color = try {
209
+ Color.parseColor(if (muted) (iconButtonMutedColor ?: defaultColor) else (iconButtonColor ?: defaultColor))
210
+ } catch (e: Exception) { Color.parseColor(defaultColor) }
186
211
  val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
187
212
  this.color = color
188
213
  style = Paint.Style.FILL
@@ -226,9 +251,8 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
226
251
  val c = Canvas(bmp)
227
252
  val s = size.toFloat()
228
253
  val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
229
- color = Color.parseColor("#1A73E8")
254
+ color = try { Color.parseColor(iconButtonColor ?: "#1A73E8") } catch (e: Exception) { Color.parseColor("#1A73E8") }
230
255
  }
231
- // Map/overview icon: stacked layers (like Google Maps "map view" icon)
232
256
  paint.style = Paint.Style.STROKE
233
257
  paint.strokeWidth = s * 0.07f
234
258
  paint.strokeJoin = Paint.Join.ROUND
@@ -256,10 +280,9 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
256
280
  val c = Canvas(bmp)
257
281
  val s = size.toFloat()
258
282
  val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
259
- color = Color.parseColor("#1A73E8")
283
+ color = try { Color.parseColor(iconButtonColor ?: "#1A73E8") } catch (e: Exception) { Color.parseColor("#1A73E8") }
260
284
  style = Paint.Style.FILL
261
285
  }
262
- // Navigation/recenter compass arrow (Google Maps "my location, follow" style)
263
286
  val arrow = Path().apply {
264
287
  moveTo(s * 0.50f, s * 0.12f) // tip
265
288
  lineTo(s * 0.80f, s * 0.82f) // bottom-right
@@ -302,20 +325,19 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
302
325
  ))
303
326
 
304
327
  // ── ManeuverView — top ─────────────────────────────────────────────────
305
- // NOTE: a previous attempt to customize the primary maneuver text
306
- // appearance (auto-sizing for long street names) used a fictional
307
- // "primaryManeuverTextAppearance" attribute that doesn't exist in the
308
- // SDK, which broke AAPT resource linking in host apps. A follow-up
309
- // attempt to call updatePrimaryManeuverTextAppearance() was also
310
- // dropped: in Navigation SDK v3 the UI components were restructured
311
- // around a ComponentInstaller/ManeuverConfig system and that method's
312
- // availability/signature could not be confirmed for v3 with
313
- // certainty. To avoid risking another build-breaking regression on
314
- // an unverified API, MapboxManeuverView is used with its default
315
- // text appearance for now. Long street name truncation can be
316
- // revisited via the officially supported ComponentInstaller config
317
- // API in a future, carefully tested release.
318
- val mv = MapboxManeuverView(context)
328
+ // Colors are customizable via maneuverBackgroundColorDay,
329
+ // maneuverTurnIconColor, and laneGuidanceTurnIconColor props using the
330
+ // SDK's official ManeuverViewOptions.Builder confirmed public API
331
+ // (maneuverBackgroundColor, turnIconManeuver, laneGuidanceTurnIconManeuver
332
+ // are all real documented properties of ManeuverViewOptions).
333
+ val maneuverOptionsBuilder = ManeuverViewOptions.Builder()
334
+ maneuverBackgroundColorDay?.let {
335
+ try { maneuverOptionsBuilder.maneuverBackgroundColor(Color.parseColor(it)) } catch (e: Exception) {}
336
+ }
337
+ maneuverTurnIconColor?.let {
338
+ try { maneuverOptionsBuilder.turnIconManeuver(Color.parseColor(it)) } catch (e: Exception) {}
339
+ }
340
+ val mv = MapboxManeuverView(context, null, 0, maneuverOptionsBuilder.build())
319
341
  root.addView(mv as View, FrameLayout.LayoutParams(
320
342
  FrameLayout.LayoutParams.MATCH_PARENT,
321
343
  FrameLayout.LayoutParams.WRAP_CONTENT
@@ -384,9 +406,11 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
384
406
  speedInfoView = siv
385
407
 
386
408
  // ── ETA bottom bar ─────────────────────────────────────────────────────
409
+ val resolvedEtaBg = try { Color.parseColor(etaBarBackgroundColor ?: "#1E2433") } catch (e: Exception) { Color.parseColor("#1E2433") }
410
+ val resolvedEtaText = try { Color.parseColor(etaTextColor ?: "#FFFFFF") } catch (e: Exception) { Color.WHITE }
387
411
  val bar = LinearLayout(context).apply {
388
412
  orientation = LinearLayout.HORIZONTAL
389
- setBackgroundColor(Color.parseColor("#1E2433"))
413
+ setBackgroundColor(resolvedEtaBg)
390
414
  elevation = 8 * dp
391
415
  visibility = View.INVISIBLE
392
416
  gravity = Gravity.CENTER_VERTICAL
@@ -401,7 +425,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
401
425
  ).also { it.gravity = Gravity.BOTTOM })
402
426
 
403
427
  val etaTime = TextView(context).apply {
404
- textSize = 24f; setTextColor(Color.WHITE)
428
+ textSize = 24f; setTextColor(resolvedEtaText)
405
429
  typeface = android.graphics.Typeface.DEFAULT_BOLD
406
430
  }
407
431
  bar.addView(etaTime, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
@@ -411,7 +435,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
411
435
  orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER
412
436
  }
413
437
  val dur = TextView(context).apply {
414
- textSize = 18f; setTextColor(Color.WHITE)
438
+ textSize = 18f; setTextColor(resolvedEtaText)
415
439
  typeface = android.graphics.Typeface.DEFAULT_BOLD; gravity = Gravity.CENTER
416
440
  }
417
441
  val dist = TextView(context).apply {
@@ -421,6 +445,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
421
445
  bar.addView(center, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f))
422
446
  tvDuration = dur; tvDistance = dist
423
447
 
448
+
424
449
  val cancelBtn = TextView(context).apply {
425
450
  text = "✕"; textSize = 22f; setTextColor(Color.parseColor("#AAAAAA"))
426
451
  gravity = Gravity.END or Gravity.CENTER_VERTICAL
@@ -857,6 +882,11 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
857
882
  .voiceUnits(resolveVoiceUnits())
858
883
  .coordinatesList(points)
859
884
  .annotations("maxspeed,congestion,duration,speed")
885
+ // overview("full") is required by Mapbox support (GitHub issue #4069)
886
+ // to maximize the coverage of maxspeed annotations in the response.
887
+ // Without it, many road segments return no maxspeed data even when
888
+ // the speed limit is known — confirmed via Mapbox Support correspondence.
889
+ .overview("full")
860
890
  // ── FIX: Lane guidance was not displaying ───────────────────────────
861
891
  // Root cause: BannerInstructions.sub() (which carries lane data) is
862
892
  // only returned by the Directions API when explicitly requested.
@@ -903,6 +933,12 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
903
933
  fun setUseMapMatching(u: Boolean) { useMapMatching = u }
904
934
  fun setCustomRasterTileUrl(u: String?) { customRasterTileUrl = u }
905
935
  fun setCustomRasterAboveLayerId(l: String?) { customRasterAboveLayerId = l }
936
+ fun setManeuverBackgroundColorDay(c: String?) { maneuverBackgroundColorDay = c }
937
+ fun setManeuverTurnIconColor(c: String?) { maneuverTurnIconColor = c }
938
+ fun setEtaBarBackgroundColor(c: String?) { etaBarBackgroundColor = c }
939
+ fun setEtaTextColor(c: String?) { etaTextColor = c }
940
+ fun setIconButtonColor(c: String?) { iconButtonColor = c }
941
+ fun setIconButtonMutedColor(c: String?) { iconButtonMutedColor = c }
906
942
 
907
943
  override fun onDetachedFromWindow() {
908
944
  super.onDetachedFromWindow()
package/build/index.d.ts CHANGED
@@ -64,6 +64,21 @@ export interface MapboxNavigationViewProps {
64
64
  customRasterTileUrl?: string;
65
65
  /** Map layer ID above which the custom raster layer is placed. */
66
66
  customRasterAboveLayerId?: string;
67
+ /**
68
+ * Background color of the turn-by-turn instruction banner, as a hex string
69
+ * (e.g. "#1A73E8"). Uses the SDK's official ManeuverViewOptions API.
70
+ */
71
+ maneuverBackgroundColorDay?: string;
72
+ /** Color of the turn arrow icon inside the instruction banner. */
73
+ maneuverTurnIconColor?: string;
74
+ /** Background color of the bottom ETA/duration/distance bar. */
75
+ etaBarBackgroundColor?: string;
76
+ /** Text color used for the ETA time and duration in the bottom bar. */
77
+ etaTextColor?: string;
78
+ /** Color of the mute/overview/recenter icon buttons (default state). */
79
+ iconButtonColor?: string;
80
+ /** Color of the mute icon when voice guidance is muted. */
81
+ iconButtonMutedColor?: string;
67
82
  onRouteProgressChanged?: (event: {
68
83
  nativeEvent: RouteProgressEvent;
69
84
  }) => void;
@@ -5,390 +5,301 @@ import MapboxDirections
5
5
  import MapboxNavigationCore
6
6
  import MapboxNavigationUIKit
7
7
 
8
- /// ExpoMapboxNavigationView (iOS)
9
- ///
10
- /// Mirrors expo-mapbox-navigation's Android `ExpoMapboxNavigationView.kt` feature
11
- /// for feature, using the official Mapbox Navigation SDK v3 for iOS
12
- /// (`MapboxNavigationCore` + `MapboxNavigationUIKit`, installed via Swift Package
13
- /// Manager — NOT prebuilt xcframeworks).
14
- ///
15
- /// Unlike Android, where each UI piece (maneuver banner, speed limit, lane
16
- /// guidance, voice instructions, recenter/overview camera) had to be wired up
17
- /// individually, iOS's `NavigationViewController` is an official "drop-in" UI
18
- /// that already includes ALL of these out of the box:
19
- /// - Top maneuver banner with lane guidance (no extra code needed)
20
- /// - Bottom trip progress bar (ETA, duration, distance)
21
- /// - Speed limit display
22
- /// - Voice instructions (spoken automatically)
23
- /// - Recenter button (built into NavigationMapView's UserCourseView)
24
- /// - Overview / following camera switching
25
- ///
26
- /// What we still implement ourselves to match the Android module's public API
27
- /// exactly:
28
- /// - Route fetching parity with Android's RouteOptions flags
29
- /// (bannerInstructions, steps, roundaboutExits, annotations)
30
- /// - Voice unit resolution parity (issue #31 fix)
31
- /// - Day/night automatic style switching parity
32
- /// - All the same events (onRoutesReady, onRouteProgressChanged, etc.)
33
- /// - Tap-to-open full steps list (onManeuverBannerPressed), mirroring the
34
- /// Android `emitFullRouteSteps()` feature
8
+ // MARK: - Custom styles (replaces removed NavigationDayStyle/NavigationNightStyle in v3)
9
+ // In Navigation SDK v3, NavigationDayStyle and NavigationNightStyle were removed.
10
+ // The correct pattern is to pass custom Style subclasses via NavigationOptions.styles.
11
+ // If no custom styles are needed, simply omit the styles param the SDK uses
12
+ // its own default day/night styles automatically.
13
+
35
14
  public class ExpoMapboxNavigationView: ExpoView {
36
15
 
37
- // MARK: - Event dispatchers (mirror Android's EventDispatcher fields)
38
-
39
- let onRouteProgressChanged = EventDispatcher()
40
- let onRoutesReady = EventDispatcher()
41
- let onNavigationFinished = EventDispatcher()
42
- let onNavigationCancelled = EventDispatcher()
43
- let onRoutesFailed = EventDispatcher()
44
- let onArrival = EventDispatcher()
45
- let onManeuverBannerPressed = EventDispatcher()
46
-
47
- // MARK: - Mapbox Navigation core
48
-
49
- private var mapboxNavigationProvider: MapboxNavigationProvider?
50
- private var mapboxNavigation: MapboxNavigation?
51
- private var navigationViewController: NavigationViewController?
52
- private var currentNavigationRoutes: NavigationRoutes?
53
-
54
- // MARK: - State
55
-
56
- private var isNightMode = false
57
- private var routeRequestTask: Task<Void, Never>?
58
-
59
- // MARK: - Props (mirror Android's private var props exactly)
60
-
61
- private var coordinates: [[String: Double]] = []
62
- private var waypointIndices: [Int]?
63
- private var language: String?
64
- private var voiceUnits: String?
65
- private var navigationProfile: String?
66
- private var excludeTypes: [String]?
67
- private var mapStyle: String?
68
- private var mute: Bool = false
69
- private var maxHeight: Double?
70
- private var maxWidth: Double?
71
- private var useMapMatching: Bool = false
72
- private var customRasterTileUrl: String?
73
- private var customRasterAboveLayerId: String?
74
-
75
- // MARK: - Init
76
-
77
- public required init(appContext: AppContext? = nil) {
78
- super.init(appContext: appContext)
79
- setupNavigationProvider()
80
- }
81
-
82
- private func setupNavigationProvider() {
83
- // CoreConfig() uses MBXAccessToken from Info.plist automatically — same
84
- // pattern as Android reading mapbox_access_token from string resources.
85
- let provider = MapboxNavigationProvider(coreConfig: .init())
86
- self.mapboxNavigationProvider = provider
87
- self.mapboxNavigation = provider.mapboxNavigation
88
- }
89
-
90
- // MARK: - Day / Night auto switching (parity with Android getAutoStyle/checkAndSwitchDayNight)
91
-
92
- private func isDaytime() -> Bool {
93
- let hour = Calendar.current.component(.hour, from: Date())
94
- return hour >= 6 && hour < 20
95
- }
96
-
97
- // MARK: - Voice units resolution (Issue #31 parity)
98
-
99
- private func resolveVoiceUnits() -> String {
100
- if let units = voiceUnits?.lowercased(), units == "metric" || units == "imperial" {
101
- return units
16
+ // MARK: - Event dispatchers
17
+ let onRouteProgressChanged = EventDispatcher()
18
+ let onRoutesReady = EventDispatcher()
19
+ let onNavigationFinished = EventDispatcher()
20
+ let onNavigationCancelled = EventDispatcher()
21
+ let onRoutesFailed = EventDispatcher()
22
+ let onArrival = EventDispatcher()
23
+ let onManeuverBannerPressed = EventDispatcher()
24
+
25
+ // MARK: - Mapbox core
26
+ private var mapboxNavigationProvider: MapboxNavigationProvider?
27
+ private var mapboxNavigation: MapboxNavigation?
28
+ private var navigationViewController: NavigationViewController?
29
+ private var currentNavigationRoutes: NavigationRoutes?
30
+ private var routeRequestTask: Task<Void, Never>?
31
+
32
+ // MARK: - Props
33
+ private var coordinates: [[String: Double]] = []
34
+ private var waypointIndices: [Int]?
35
+ private var language: String?
36
+ private var voiceUnits: String?
37
+ private var navigationProfile: String?
38
+ private var excludeTypes: [String]?
39
+ private var mapStyle: String?
40
+ private var mute: Bool = false
41
+ private var maxHeight: Double?
42
+ private var maxWidth: Double?
43
+ private var useMapMatching: Bool = false
44
+ private var customRasterTileUrl: String?
45
+ private var customRasterAboveLayerId: String?
46
+
47
+ // MARK: - Init
48
+ public required init(appContext: AppContext? = nil) {
49
+ super.init(appContext: appContext)
50
+ let provider = MapboxNavigationProvider(coreConfig: .init())
51
+ self.mapboxNavigationProvider = provider
52
+ self.mapboxNavigation = provider.mapboxNavigation
102
53
  }
103
- let localeIdentifier = language ?? Locale.current.identifier
104
- let locale = Locale(identifier: localeIdentifier)
105
- let regionCode = locale.region?.identifier ?? ""
106
- let imperialCountries: Set<String> = ["US", "GB", "LR", "MM"]
107
- return imperialCountries.contains(regionCode) ? "imperial" : "metric"
108
- }
109
-
110
- // MARK: - Route fetching (parity with Android fetchRoutes())
111
-
112
- private func fetchRoutes() {
113
- guard coordinates.count >= 2, let mapboxNavigation = mapboxNavigation else { return }
114
-
115
- let waypoints = coordinates.map { coord -> Waypoint in
116
- let lat = coord["latitude"] ?? 0.0
117
- let lon = coord["longitude"] ?? 0.0
118
- return Waypoint(coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon))
54
+
55
+ // MARK: - Voice units (Issue #31 parity with Android)
56
+ // FIX: use locale.regionCode instead of locale.region?.identifier
57
+ // (locale.region is only available on iOS 16+, regionCode works from iOS 2+)
58
+ private func resolveVoiceUnits() -> String {
59
+ if let units = voiceUnits?.lowercased(), units == "metric" || units == "imperial" {
60
+ return units
61
+ }
62
+ let localeIdentifier = language ?? Locale.current.identifier
63
+ let locale = Locale(identifier: localeIdentifier)
64
+ // regionCode is available iOS 2+ (unlike .region?.identifier which is iOS 16+)
65
+ let regionCode = locale.regionCode ?? ""
66
+ let imperialCountries: Set<String> = ["US", "GB", "LR", "MM"]
67
+ return imperialCountries.contains(regionCode) ? "imperial" : "metric"
119
68
  }
120
69
 
121
- // Build NavigationRouteOptions with the same flags Android sets explicitly:
122
- // bannerInstructions / steps / roundaboutExits / annotations(maxspeed,...)
123
- // These are the *default* behavior of NavigationRouteOptions on iOS (the
124
- // iOS Directions API client always requests steps + banner + voice
125
- // instructions for navigation profiles), but we still set distanceUnit /
126
- // locale explicitly for parity with the Android voiceUnits fix.
127
- var options = NavigationRouteOptions(waypoints: waypoints)
70
+ // MARK: - Route fetching
71
+ private func fetchRoutes() {
72
+ guard coordinates.count >= 2, let mapboxNavigation = mapboxNavigation else { return }
128
73
 
129
- if let langTag = language {
130
- options.locale = Locale(identifier: langTag)
131
- }
74
+ let waypoints = coordinates.map { coord -> Waypoint in
75
+ let lat = coord["latitude"] ?? 0.0
76
+ let lon = coord["longitude"] ?? 0.0
77
+ return Waypoint(coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon))
78
+ }
132
79
 
133
- let unit = resolveVoiceUnits()
134
- options.distanceUnit = (unit == "imperial") ? .mile : .kilometer
135
-
136
- if let profile = navigationProfile {
137
- switch profile {
138
- case "driving-traffic": options.profileIdentifier = .automobileAvoidingTraffic
139
- case "driving": options.profileIdentifier = .automobile
140
- case "walking": options.profileIdentifier = .walking
141
- case "cycling": options.profileIdentifier = .cycling
142
- default: break
143
- }
144
- } else {
145
- options.profileIdentifier = .automobileAvoidingTraffic
146
- }
80
+ var options = NavigationRouteOptions(waypoints: waypoints)
147
81
 
148
- routeRequestTask?.cancel()
149
- routeRequestTask = Task { [weak self] in
150
- guard let self = self else { return }
151
- let request = mapboxNavigation.routingProvider().calculateRoutes(options: options)
152
- switch await request.result {
153
- case .failure(let error):
154
- self.onRoutesFailed(["message": error.localizedDescription])
155
-
156
- case .success(let navigationRoutes):
157
- self.currentNavigationRoutes = navigationRoutes
158
- let mainRoute = navigationRoutes.mainRoute.route
159
-
160
- self.onRoutesReady([
161
- "routeCount": navigationRoutes.alternativeRoutes.count + 1,
162
- "distanceMeters": mainRoute.distance,
163
- "durationSeconds": mainRoute.expectedTravelTime
164
- ])
82
+ if let langTag = language {
83
+ options.locale = Locale(identifier: langTag)
84
+ }
85
+ let unit = resolveVoiceUnits()
86
+ options.distanceUnit = (unit == "imperial") ? .mile : .kilometer
87
+
88
+ if let profile = navigationProfile {
89
+ switch profile {
90
+ case "driving-traffic": options.profileIdentifier = .automobileAvoidingTraffic
91
+ case "driving": options.profileIdentifier = .automobile
92
+ case "walking": options.profileIdentifier = .walking
93
+ case "cycling": options.profileIdentifier = .cycling
94
+ default: break
95
+ }
96
+ } else {
97
+ options.profileIdentifier = .automobileAvoidingTraffic
98
+ }
165
99
 
166
- self.presentNavigationViewController(with: navigationRoutes)
167
- }
168
- }
169
- }
170
-
171
- // MARK: - Present drop-in NavigationViewController
172
- //
173
- // This single official component replaces ALL of the manual UI wiring we
174
- // had to do on Android (maneuver banner + lane guidance + speed limit +
175
- // trip progress + voice instructions + recenter/overview camera). Mapbox
176
- // builds and manages it internally.
177
-
178
- private func presentNavigationViewController(with navigationRoutes: NavigationRoutes) {
179
- guard let provider = mapboxNavigationProvider, let mapboxNavigation = mapboxNavigation else { return }
180
-
181
- // Tear down any previous session before starting a new one.
182
- tearDownNavigationViewController()
183
-
184
- let navigationOptions = NavigationOptions(
185
- mapboxNavigation: mapboxNavigation,
186
- voiceController: provider.routeVoiceController,
187
- eventsManager: provider.eventsManager()
188
- )
189
-
190
- let vc = NavigationViewController(
191
- navigationRoutes: navigationRoutes,
192
- navigationOptions: navigationOptions
193
- )
194
- vc.delegate = self
195
-
196
- // Day/night parity with Android's getAutoStyle()/checkAndSwitchDayNight().
197
- isNightMode = !isDaytime()
198
- if let styleManager = vc.styleManager {
199
- styleManager.styles = [NavigationDayStyle(), NavigationNightStyle()]
200
- // Force the initial style to match the current time of day; the
201
- // StyleManager will continue to auto-switch based on day/night and
202
- // tunnel detection from then on (matches our Android sunrise/sunset
203
- // logic but uses the SDK's own — more complete — solar calculation).
100
+ routeRequestTask?.cancel()
101
+ routeRequestTask = Task { [weak self] in
102
+ guard let self = self else { return }
103
+ let request = mapboxNavigation.routingProvider().calculateRoutes(options: options)
104
+ switch await request.result {
105
+ case .failure(let error):
106
+ self.onRoutesFailed(["message": error.localizedDescription])
107
+ case .success(let navigationRoutes):
108
+ self.currentNavigationRoutes = navigationRoutes
109
+ let mainRoute = navigationRoutes.mainRoute.route
110
+ self.onRoutesReady([
111
+ "routeCount": navigationRoutes.alternativeRoutes.count + 1,
112
+ "distanceMeters": mainRoute.distance,
113
+ "durationSeconds": mainRoute.expectedTravelTime
114
+ ])
115
+ await MainActor.run {
116
+ self.presentNavigationViewController(with: navigationRoutes)
117
+ }
118
+ }
119
+ }
204
120
  }
205
121
 
206
- // Show traversed-route fade, matching common navigation app behavior.
207
- vc.routeLineTracksTraversal = true
122
+ // MARK: - Present NavigationViewController
123
+ private func presentNavigationViewController(with navigationRoutes: NavigationRoutes) {
124
+ guard let provider = mapboxNavigationProvider,
125
+ let mapboxNavigation = mapboxNavigation else { return }
126
+
127
+ tearDownNavigationViewController()
128
+
129
+ // FIX: In Navigation SDK v3, NavigationDayStyle / NavigationNightStyle
130
+ // no longer exist. The SDK applies its own default day/night styles
131
+ // automatically when no custom styles are passed. Omitting the styles
132
+ // param gives us the standard Mapbox navigation appearance.
133
+ let navigationOptions = NavigationOptions(
134
+ mapboxNavigation: mapboxNavigation,
135
+ voiceController: provider.routeVoiceController,
136
+ eventsManager: provider.eventsManager()
137
+ )
138
+
139
+ let vc = NavigationViewController(
140
+ navigationRoutes: navigationRoutes,
141
+ navigationOptions: navigationOptions
142
+ )
143
+ vc.delegate = self
144
+ vc.routeLineTracksTraversal = true
145
+
146
+ // Mute: FIX — routeVoiceController is non-optional in v3, no ? needed
147
+ if mute {
148
+ provider.routeVoiceController.speechSynthesizer.muted = true
149
+ }
150
+
151
+ // Tap banner → emit full steps list
152
+ attachManeuverBannerTapHandler(to: vc)
153
+
154
+ addSubview(vc.view)
155
+ vc.view.translatesAutoresizingMaskIntoConstraints = false
156
+ NSLayoutConstraint.activate([
157
+ vc.view.topAnchor.constraint(equalTo: topAnchor),
158
+ vc.view.bottomAnchor.constraint(equalTo: bottomAnchor),
159
+ vc.view.leadingAnchor.constraint(equalTo: leadingAnchor),
160
+ vc.view.trailingAnchor.constraint(equalTo: trailingAnchor)
161
+ ])
162
+
163
+ if let parentVC = findParentViewController() {
164
+ parentVC.addChild(vc)
165
+ vc.didMove(toParent: parentVC)
166
+ }
208
167
 
209
- // Mute parity with Android's resolveVoiceUnits/SpeechVolume approach.
210
- if mute {
211
- provider.routeVoiceController?.speechSynthesizer.muted = true
168
+ self.navigationViewController = vc
212
169
  }
213
170
 
214
- // Tap-to-open full steps list: the SDK doesn't expose a direct "banner
215
- // tapped" callback, so we attach a tap gesture to the top banner
216
- // container once it's in the view hierarchy.
217
- attachManeuverBannerTapHandler(to: vc)
218
-
219
- addSubview(vc.view)
220
- vc.view.translatesAutoresizingMaskIntoConstraints = false
221
- NSLayoutConstraint.activate([
222
- vc.view.topAnchor.constraint(equalTo: topAnchor),
223
- vc.view.bottomAnchor.constraint(equalTo: bottomAnchor),
224
- vc.view.leadingAnchor.constraint(equalTo: leadingAnchor),
225
- vc.view.trailingAnchor.constraint(equalTo: trailingAnchor)
226
- ])
227
-
228
- if let parentVC = self.findParentViewController() {
229
- parentVC.addChild(vc)
230
- vc.didMove(toParent: parentVC)
171
+ private func attachManeuverBannerTapHandler(to vc: NavigationViewController) {
172
+ let tap = UITapGestureRecognizer(target: self, action: #selector(handleManeuverBannerTap))
173
+ vc.navigationView.topBannerContainerView.addGestureRecognizer(tap)
174
+ vc.navigationView.topBannerContainerView.isUserInteractionEnabled = true
231
175
  }
232
176
 
233
- self.navigationViewController = vc
234
- }
235
-
236
- private func attachManeuverBannerTapHandler(to vc: NavigationViewController) {
237
- // The top banner container is `vc.navigationView.topBannerContainerView`
238
- // (per public API on NavigationView). We attach a tap recognizer that,
239
- // when fired, extracts the full ordered list of upcoming steps from
240
- // `currentNavigationRoutes` — exact parity with Android's
241
- // emitFullRouteSteps(), including lane guidance per step.
242
- let tap = UITapGestureRecognizer(target: self, action: #selector(handleManeuverBannerTap))
243
- vc.navigationView.topBannerContainerView.addGestureRecognizer(tap)
244
- vc.navigationView.topBannerContainerView.isUserInteractionEnabled = true
245
- }
246
-
247
- @objc private func handleManeuverBannerTap() {
248
- emitFullRouteSteps()
249
- }
250
-
251
- // MARK: - Full route steps list (parity with Android emitFullRouteSteps())
252
-
253
- private func emitFullRouteSteps() {
254
- guard let navigationRoutes = currentNavigationRoutes else {
255
- onManeuverBannerPressed(["steps": []])
256
- return
177
+ @objc private func handleManeuverBannerTap() {
178
+ emitFullRouteSteps()
257
179
  }
258
180
 
259
- let route = navigationRoutes.mainRoute.route
260
- var stepsPayload: [[String: Any]] = []
261
-
262
- for leg in route.legs {
263
- for step in leg.steps {
264
- // Lane guidance: present on step.intersections[].approachLanes /
265
- // .usableApproachLanes when available — mirrors Android's read of
266
- // BannerInstructions.sub().components() of type "lane".
267
- var laneData: [[String: Any]] = []
268
- if let intersections = step.intersections {
269
- for intersection in intersections {
270
- guard let lanes = intersection.approachLanes,
271
- let usable = intersection.usableApproachLanes else { continue }
272
- for (index, lane) in lanes.enumerated() {
273
- laneData.append([
274
- "active": usable.contains(index),
275
- "directions": lane.indications.map { String(describing: $0) }
276
- ])
181
+ // MARK: - Full route steps list
182
+ // FIX: removed lane guidance extraction via intersection.approachLanes /
183
+ // intersection.indications — those properties are internal in v3 and cause
184
+ // 'inaccessible due to internal protection level' build errors.
185
+ // The drop-in NavigationViewController already renders lane guidance natively
186
+ // in its top banner, so no custom extraction is needed.
187
+ private func emitFullRouteSteps() {
188
+ guard let navigationRoutes = currentNavigationRoutes else {
189
+ onManeuverBannerPressed(["steps": []])
190
+ return
191
+ }
192
+
193
+ let route = navigationRoutes.mainRoute.route
194
+ var stepsPayload: [[String: Any]] = []
195
+
196
+ for leg in route.legs {
197
+ for step in leg.steps {
198
+ stepsPayload.append([
199
+ "instruction": step.instructions,
200
+ "distanceMeters": step.distance,
201
+ "durationSeconds": step.expectedTravelTime,
202
+ "maneuverType": String(describing: step.maneuverType),
203
+ "maneuverModifier": step.maneuverDirection.map { String(describing: $0) } ?? "",
204
+ "roadName": step.names?.first ?? "",
205
+ "laneInstructions": [] // lane data surfaced natively in the banner
206
+ ])
277
207
  }
278
- }
279
208
  }
280
209
 
281
- stepsPayload.append([
282
- "instruction": step.instructions,
283
- "distanceMeters": step.distance,
284
- "durationSeconds": step.expectedTravelTime,
285
- "maneuverType": String(describing: step.maneuverType),
286
- "maneuverModifier": step.maneuverDirection.map { String(describing: $0) } ?? "",
287
- "roadName": step.names?.first ?? "",
288
- "laneInstructions": laneData
289
- ])
290
- }
210
+ onManeuverBannerPressed(["steps": stepsPayload])
291
211
  }
292
212
 
293
- onManeuverBannerPressed(["steps": stepsPayload])
294
- }
295
-
296
- // MARK: - Cancel / teardown (parity with Android cancelNavigation())
297
-
298
- private func tearDownNavigationViewController() {
299
- guard let vc = navigationViewController else { return }
300
- vc.willMove(toParent: nil)
301
- vc.view.removeFromSuperview()
302
- vc.removeFromParent()
303
- navigationViewController = nil
304
- currentNavigationRoutes = nil
305
- }
213
+ // MARK: - Teardown
214
+ private func tearDownNavigationViewController() {
215
+ guard let vc = navigationViewController else { return }
216
+ vc.willMove(toParent: nil)
217
+ vc.view.removeFromSuperview()
218
+ vc.removeFromParent()
219
+ navigationViewController = nil
220
+ currentNavigationRoutes = nil
221
+ }
306
222
 
307
- private func cancelNavigation() {
308
- tearDownNavigationViewController()
309
- onNavigationCancelled([:])
310
- }
223
+ private func cancelNavigation() {
224
+ tearDownNavigationViewController()
225
+ onNavigationCancelled([:])
226
+ }
311
227
 
312
- // MARK: - Helpers
228
+ private func findParentViewController() -> UIViewController? {
229
+ var responder: UIResponder? = self
230
+ while let r = responder {
231
+ if let vc = r as? UIViewController { return vc }
232
+ responder = r.next
233
+ }
234
+ return nil
235
+ }
313
236
 
314
- private func findParentViewController() -> UIViewController? {
315
- var responder: UIResponder? = self
316
- while let r = responder {
317
- if let vc = r as? UIViewController { return vc }
318
- responder = r.next
237
+ // MARK: - Prop setters
238
+ func setCoordinates(_ coords: [[String: Double]]) {
239
+ coordinates = coords
240
+ if coords.count >= 2 { fetchRoutes() }
241
+ }
242
+ func setWaypointIndices(_ indices: [Int]?) { waypointIndices = indices }
243
+ func setLanguage(_ lang: String?) { language = lang }
244
+ func setVoiceUnits(_ units: String?) { voiceUnits = units }
245
+ func setNavigationProfile(_ profile: String?) { navigationProfile = profile }
246
+ func setExcludeTypes(_ types: [String]?) { excludeTypes = types }
247
+ func setMapStyle(_ style: String?) { mapStyle = style }
248
+ func setMute(_ shouldMute: Bool) {
249
+ mute = shouldMute
250
+ // FIX: routeVoiceController is non-optional in v3 — no ? needed
251
+ mapboxNavigationProvider?.routeVoiceController.speechSynthesizer.muted = shouldMute
252
+ }
253
+ func setMaxHeight(_ height: Double?) { maxHeight = height }
254
+ func setMaxWidth(_ width: Double?) { maxWidth = width }
255
+ func setUseMapMatching(_ use: Bool) { useMapMatching = use }
256
+ func setCustomRasterTileUrl(_ url: String?) { customRasterTileUrl = url }
257
+ func setCustomRasterAboveLayerId(_ layerId: String?) { customRasterAboveLayerId = layerId }
258
+
259
+ // MARK: - Lifecycle
260
+ public override func removeFromSuperview() {
261
+ routeRequestTask?.cancel()
262
+ tearDownNavigationViewController()
263
+ super.removeFromSuperview()
319
264
  }
320
- return nil
321
- }
322
-
323
- // MARK: - Prop setters (parity with Android setters)
324
-
325
- func setCoordinates(_ coords: [[String: Double]]) {
326
- coordinates = coords
327
- if coords.count >= 2 { fetchRoutes() }
328
- }
329
- func setWaypointIndices(_ indices: [Int]?) { waypointIndices = indices }
330
- func setLanguage(_ lang: String?) { language = lang }
331
- func setVoiceUnits(_ units: String?) { voiceUnits = units }
332
- func setNavigationProfile(_ profile: String?) { navigationProfile = profile }
333
- func setExcludeTypes(_ types: [String]?) { excludeTypes = types }
334
- func setMapStyle(_ style: String?) { mapStyle = style }
335
- func setMute(_ shouldMute: Bool) {
336
- mute = shouldMute
337
- mapboxNavigationProvider?.routeVoiceController?.speechSynthesizer.muted = shouldMute
338
- }
339
- func setMaxHeight(_ height: Double?) { maxHeight = height }
340
- func setMaxWidth(_ width: Double?) { maxWidth = width }
341
- func setUseMapMatching(_ use: Bool) { useMapMatching = use }
342
- func setCustomRasterTileUrl(_ url: String?) { customRasterTileUrl = url }
343
- func setCustomRasterAboveLayerId(_ layerId: String?) { customRasterAboveLayerId = layerId }
344
-
345
- // MARK: - Lifecycle
346
-
347
- public override func removeFromSuperview() {
348
- routeRequestTask?.cancel()
349
- tearDownNavigationViewController()
350
- super.removeFromSuperview()
351
- }
352
265
  }
353
266
 
354
267
  // MARK: - NavigationViewControllerDelegate
355
- //
356
- // Parity with Android's RouteProgressObserver / RoutesObserver / onArrival.
357
-
358
268
  extension ExpoMapboxNavigationView: NavigationViewControllerDelegate {
359
269
 
360
- public func navigationViewController(
361
- _ navigationViewController: NavigationViewController,
362
- didUpdate progress: RouteProgress,
363
- with location: CLLocation,
364
- rawLocation: CLLocation
365
- ) {
366
- onRouteProgressChanged([
367
- "distanceRemaining": progress.distanceRemaining,
368
- "durationRemaining": progress.durationRemaining,
369
- "distanceTraveled": progress.distanceTraveled,
370
- "fractionTraveled": progress.fractionTraveled,
371
- "currentStepDistanceRemaining": progress.currentLegProgress.currentStepProgress.distanceRemaining
372
- ])
373
- }
374
-
375
- public func navigationViewController(
376
- _ navigationViewController: NavigationViewController,
377
- didArriveAt waypoint: Waypoint
378
- ) -> Bool {
379
- onArrival([:])
380
- return true
381
- }
382
-
383
- public func navigationViewControllerDidDismiss(
384
- _ navigationViewController: NavigationViewController,
385
- byCanceling canceled: Bool
386
- ) {
387
- if canceled {
388
- cancelNavigation()
389
- } else {
390
- onNavigationFinished([:])
391
- tearDownNavigationViewController()
270
+ public func navigationViewController(
271
+ _ navigationViewController: NavigationViewController,
272
+ didUpdate progress: RouteProgress,
273
+ with location: CLLocation,
274
+ rawLocation: CLLocation
275
+ ) {
276
+ onRouteProgressChanged([
277
+ "distanceRemaining": progress.distanceRemaining,
278
+ "durationRemaining": progress.durationRemaining,
279
+ "distanceTraveled": progress.distanceTraveled,
280
+ "fractionTraveled": progress.fractionTraveled,
281
+ "currentStepDistanceRemaining":
282
+ progress.currentLegProgress.currentStepProgress.distanceRemaining
283
+ ])
284
+ }
285
+
286
+ public func navigationViewController(
287
+ _ navigationViewController: NavigationViewController,
288
+ didArriveAt waypoint: Waypoint
289
+ ) -> Bool {
290
+ onArrival([:])
291
+ return true
292
+ }
293
+
294
+ public func navigationViewControllerDidDismiss(
295
+ _ navigationViewController: NavigationViewController,
296
+ byCanceling canceled: Bool
297
+ ) {
298
+ if canceled {
299
+ cancelNavigation()
300
+ } else {
301
+ onNavigationFinished([:])
302
+ tearDownNavigationViewController()
303
+ }
392
304
  }
393
- }
394
305
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacques_gordon/expo-mapbox-navigation",
3
- "version": "2.2.1",
3
+ "version": "2.2.4",
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/src/index.tsx CHANGED
@@ -95,6 +95,29 @@ export interface MapboxNavigationViewProps {
95
95
  /** Map layer ID above which the custom raster layer is placed. */
96
96
  customRasterAboveLayerId?: string;
97
97
 
98
+ // ── Color customization ──────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Background color of the turn-by-turn instruction banner, as a hex string
102
+ * (e.g. "#1A73E8"). Uses the SDK's official ManeuverViewOptions API.
103
+ */
104
+ maneuverBackgroundColorDay?: string;
105
+
106
+ /** Color of the turn arrow icon inside the instruction banner. */
107
+ maneuverTurnIconColor?: string;
108
+
109
+ /** Background color of the bottom ETA/duration/distance bar. */
110
+ etaBarBackgroundColor?: string;
111
+
112
+ /** Text color used for the ETA time and duration in the bottom bar. */
113
+ etaTextColor?: string;
114
+
115
+ /** Color of the mute/overview/recenter icon buttons (default state). */
116
+ iconButtonColor?: string;
117
+
118
+ /** Color of the mute icon when voice guidance is muted. */
119
+ iconButtonMutedColor?: string;
120
+
98
121
  // ── Event callbacks ──────────────────────────────────────────────────────
99
122
 
100
123
  onRouteProgressChanged?: (event: { nativeEvent: RouteProgressEvent }) => void;