Skip to content

Commit 3ae8654

Browse files
evil159github-actions[bot]
authored andcommitted
Scale bar nautical miles (#6897)
Adds `ScaleBarSettings.distanceUnits`/`ScaleBarViewOptions.units` along with adding support for nautical scale bar units. Now scale bar supports: metric, imperial and nautical units. Addresses: https://mapbox.atlassian.net/browse/MAPSSDK-869 cc @mapbox/maps-android cc @mapbox/maps-ios cc @mapbox/sdk-ci GitOrigin-RevId: 1abaf1d4b41491333bb9ab5061025ebacc5792b9
1 parent f657662 commit 3ae8654

File tree

12 files changed

+337
-34
lines changed

12 files changed

+337
-34
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Mapbox welcomes participation and contributions from everyone.
44

55
## main
66

7+
* Introduced `ScaleBarViewOptions.units` property supporting metric, imperial, and nautical units, replacing the boolean `useMetricUnits` property.
8+
79
## 11.16.0-beta.1 - 23 September, 2025
810

911
* Added experimental `scaleFactor` param to `MapOptions`, `MapSnapshotOptions` for scaling icons and texts.

Sources/Examples/All Examples/DebugMapExample.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ final class DebugMapExample: UIViewController, ExampleProtocol {
5555
mapView = MapView(frame: view.bounds)
5656
let maxFPS = Float(UIScreen.main.maximumFramesPerSecond)
5757
mapView.preferredFrameRateRange = CAFrameRateRange(minimum: 1, maximum: maxFPS, preferred: maxFPS)
58-
58+
mapView.ornaments.options.scaleBar.units = .nautical
5959
view.addSubview(mapView)
6060
view.backgroundColor = .skyBlue
6161
mapView.translatesAutoresizingMaskIntoConstraints = false

Sources/MapboxMaps/Ornaments/DistanceFormatter.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,31 @@ internal class DistanceFormatter: MeasurementFormatter {
1111
///
1212
/// - parameter distance: The distance, measured in meters.
1313
/// - returns: A localized formatted distance string including units.
14-
internal func string(fromDistance distance: CLLocationDistance, useMetricSystem: Bool? = nil) -> String {
14+
internal func string(fromDistance distance: CLLocationDistance, units: ScaleBarViewOptions.Units) -> String {
1515

1616
numberFormatter.roundingIncrement = 0.25
1717

1818
var measurement = Measurement(value: distance, unit: UnitLength.meters)
1919

20-
let shouldUseMetricSystem = useMetricSystem ?? locale.usesMetricSystem
21-
22-
if shouldUseMetricSystem {
23-
unitOptions = [.providedUnit, .naturalScale]
24-
} else {
20+
switch units {
21+
case .imperial:
2522
unitOptions = .providedUnit
2623
measurement.convert(to: .miles)
2724
if measurement.value <= 0.2 {
2825
measurement.convert(to: .feet)
2926
}
27+
case .nautical:
28+
unitOptions = .providedUnit
29+
measurement.convert(to: .nauticalMiles)
30+
if measurement.value <= 0.2 {
31+
measurement.convert(to: .fathoms)
32+
}
33+
case .metric:
34+
unitOptions = [.providedUnit, .naturalScale]
35+
default:
36+
unitOptions = [.providedUnit, .naturalScale]
3037
}
38+
3139
return string(from: measurement)
3240
}
3341
}

Sources/MapboxMaps/Ornaments/OrnamentOptions.swift

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ public struct OrnamentOptions: Equatable, Sendable {
5454
/// Used to configure position, margin, and visibility for the map's scale bar view.
5555
public struct ScaleBarViewOptions: Equatable, Sendable {
5656

57+
/// The type of the distance unit the scale bar displays in.
58+
public struct Units: RawRepresentable, Equatable, Sendable {
59+
public let rawValue: String
60+
61+
public init(rawValue: String) {
62+
self.rawValue = rawValue
63+
}
64+
65+
/// Imperial units using feet for short distances and miles for longer distances.
66+
///
67+
/// The scale bar will display distances in feet for small distances
68+
/// and automatically switch to miles for longer distances.
69+
public static let imperial = Units(rawValue: "imperial")
70+
71+
/// Metric units using meters and kilometers.
72+
///
73+
/// The scale bar will automatically choose between meters and kilometers
74+
/// based on the distance being displayed for optimal readability.
75+
public static let metric = Units(rawValue: "metric")
76+
77+
/// Nautical units using fathoms for short distances and nautical miles for longer distances.
78+
///
79+
/// The scale bar will display distances in fathoms for small distances
80+
/// and automatically switch to nautical miles for longer distances. Commonly used for marine and aviation navigation.
81+
public static let nautical = Units(rawValue: "nautical")
82+
}
83+
5784
/// The position of the scale bar view.
5885
///
5986
/// The default value for this property is `.topLeading`.
@@ -73,7 +100,30 @@ public struct ScaleBarViewOptions: Equatable, Sendable {
73100
/// True if the scale bar is using metric units, false if the scale bar is using imperial units.
74101
///
75102
/// The default value for this property is `Locale.current.usesMetricSystem`.
76-
public var useMetricUnits: Bool
103+
@available(*, deprecated, renamed: "units", message: "Use `units` instead.")
104+
public var useMetricUnits: Bool {
105+
didSet {
106+
guard useMetricUnits != oldValue, !ignoreDeprecatedUnitUpdates else {
107+
return
108+
}
109+
units = useMetricUnits ? .metric : .imperial
110+
}
111+
}
112+
113+
/// Specifies the distance units the scale bar uses.
114+
///
115+
/// The default value for this property is `.metric`.
116+
public var units: Units {
117+
didSet {
118+
guard units != oldValue else {
119+
return
120+
}
121+
ignoreDeprecatedUnitUpdates = true
122+
useMetricUnits = units == .metric
123+
ignoreDeprecatedUnitUpdates = false
124+
}
125+
}
126+
private var ignoreDeprecatedUnitUpdates: Bool = false
77127

78128
/// Initializes a `ScaleBarViewOptions`.
79129
/// - Parameters:
@@ -85,12 +135,20 @@ public struct ScaleBarViewOptions: Equatable, Sendable {
85135
position: OrnamentPosition = .topLeading,
86136
margins: CGPoint = .init(x: 8.0, y: 8.0),
87137
visibility: OrnamentVisibility = .adaptive,
88-
useMetricUnits: Bool = Locale.current.usesMetricSystem
138+
useMetricUnits: Bool = Locale.current.usesMetricSystem,
139+
units: Units? = nil
89140
) {
90141
self.position = position
91142
self.margins = margins
92143
self.visibility = visibility
93-
self.useMetricUnits = useMetricUnits
144+
145+
if let units {
146+
self.useMetricUnits = units == .metric
147+
self.units = units
148+
} else {
149+
self.useMetricUnits = useMetricUnits
150+
self.units = useMetricUnits ? .metric : .imperial
151+
}
94152
}
95153
}
96154

Sources/MapboxMaps/Ornaments/OrnamentsManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ public final class OrnamentsManager {
229229
_compassView.visibility = options.compass.visibility
230230
_compassView.isHidden = options.compass.visibility == .hidden
231231
_attributionButton.isHidden = options.attributionButton.visibility == .hidden
232-
_scaleBarView.useMetricUnits = options.scaleBar.useMetricUnits
232+
_scaleBarView.units = options.scaleBar.units
233233
if _attributionButton.tintColor != options.attributionButton.tintColor {
234234
_attributionButton.tintColor = options.attributionButton.tintColor
235235
}

Sources/MapboxMaps/Ornaments/ScaleBar/MapboxScaleBarConstants.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ extension MapboxScaleBarOrnamentView {
1010
internal static let feetPerMile: Double = 5280
1111
internal static let feetPerMeter: Double = 3.28084
1212

13+
internal static let feetPerNauticalMile: Double = 6076.12
14+
internal static let feetPerFathom: Double = 6
15+
1316
internal static let barHeight: CGFloat = 4
1417
internal static let scaleBarLabelWidthHint: CGFloat = 30.0
1518
internal static let scaleBarMinimumBarWidth: CGFloat = 30.0 // Arbitrary
@@ -99,6 +102,39 @@ extension MapboxScaleBarOrnamentView {
99102
(distance: 10000*feetPerMile, numberOfBars: 2)
100103
]
101104

105+
internal static let nauticalTable: [Row] = [
106+
// Small distances in fathoms
107+
(distance: 1*feetPerFathom, numberOfBars: 2), // 1 fathom
108+
(distance: 2*feetPerFathom, numberOfBars: 2), // 2 fathoms
109+
(distance: 3*feetPerFathom, numberOfBars: 3), // 3 fathoms
110+
(distance: 5*feetPerFathom, numberOfBars: 2), // 5 fathoms
111+
(distance: 10*feetPerFathom, numberOfBars: 2), // 10 fathoms
112+
(distance: 20*feetPerFathom, numberOfBars: 2), // 20 fathoms
113+
(distance: 30*feetPerFathom, numberOfBars: 3), // 30 fathoms
114+
(distance: 50*feetPerFathom, numberOfBars: 2), // 50 fathoms
115+
(distance: 100*feetPerFathom, numberOfBars: 2), // 100 fathoms
116+
(distance: 200*feetPerFathom, numberOfBars: 2), // 200 fathoms
117+
118+
// Nautical miles
119+
(distance: 0.5*feetPerNauticalMile, numberOfBars: 2), // 1/2 nautical mile
120+
(distance: 1*feetPerNauticalMile, numberOfBars: 2), // 1 nautical mile
121+
(distance: 2*feetPerNauticalMile, numberOfBars: 2), // 2 nautical miles
122+
(distance: 3*feetPerNauticalMile, numberOfBars: 3), // 3 nautical miles
123+
(distance: 5*feetPerNauticalMile, numberOfBars: 2), // 5 nautical miles
124+
(distance: 10*feetPerNauticalMile, numberOfBars: 2), // 10 nautical miles
125+
(distance: 20*feetPerNauticalMile, numberOfBars: 2), // 20 nautical miles
126+
(distance: 30*feetPerNauticalMile, numberOfBars: 3), // 30 nautical miles
127+
(distance: 50*feetPerNauticalMile, numberOfBars: 2), // 50 nautical miles
128+
(distance: 100*feetPerNauticalMile, numberOfBars: 2), // 100 nautical miles
129+
(distance: 200*feetPerNauticalMile, numberOfBars: 2), // 200 nautical miles
130+
(distance: 300*feetPerNauticalMile, numberOfBars: 3), // 300 nautical miles
131+
(distance: 500*feetPerNauticalMile, numberOfBars: 2), // 500 nautical miles
132+
(distance: 1000*feetPerNauticalMile, numberOfBars: 2), // 1000 nautical miles
133+
(distance: 2000*feetPerNauticalMile, numberOfBars: 2), // 2000 nautical miles
134+
(distance: 3000*feetPerNauticalMile, numberOfBars: 3), // 3000 nautical miles
135+
(distance: 5000*feetPerNauticalMile, numberOfBars: 2), // 5000 nautical miles
136+
]
137+
102138
private init() {}
103139
}
104140
}

Sources/MapboxMaps/Ornaments/ScaleBar/MapboxScaleBarOrnamentView.swift

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ internal class MapboxScaleBarOrnamentView: UIView {
9393
private var shouldLayoutBars = false
9494

9595
internal var unitsPerPoint: Double {
96-
return useMetricUnits ? metersPerPoint : metersPerPoint * Constants.feetPerMeter
96+
return switch units {
97+
case .imperial, .nautical: metersPerPoint * Constants.feetPerMeter
98+
case .metric: metersPerPoint
99+
default: metersPerPoint
100+
}
97101
}
98102

99103
internal var maximumWidth: CGFloat {
@@ -103,9 +107,9 @@ internal class MapboxScaleBarOrnamentView: UIView {
103107
return floor(bounds.width / 2)
104108
}
105109

106-
internal var useMetricUnits: Bool = true {
110+
internal var units: ScaleBarViewOptions.Units = .metric {
107111
didSet {
108-
guard useMetricUnits != oldValue else {
112+
guard units != oldValue else {
109113
return
110114
}
111115

@@ -315,7 +319,7 @@ internal class MapboxScaleBarOrnamentView: UIView {
315319
if let image = labelImageCache[distance] {
316320
return image
317321
} else {
318-
let text = formatter.string(fromDistance: distance, useMetricSystem: useMetricUnits)
322+
let text = formatter.string(fromDistance: distance, units: units)
319323
let image = renderImageFor(text: text)
320324
labelImageCache[distance] = image
321325
return image
@@ -325,8 +329,9 @@ internal class MapboxScaleBarOrnamentView: UIView {
325329
private func updateLabels() {
326330
var multiplier = row.distance / Double(row.numberOfBars)
327331

328-
if !useMetricUnits {
329-
multiplier /= Constants.feetPerMeter
332+
switch units {
333+
case .imperial, .nautical: multiplier /= Constants.feetPerMeter
334+
default: break
330335
}
331336

332337
labelViews.enumerated().forEach {
@@ -388,10 +393,12 @@ internal class MapboxScaleBarOrnamentView: UIView {
388393

389394
private func maxDistanceAndRows() -> (maxDistance: Measurement<UnitLength>, rows: [Row]) {
390395
let distanceInMeters = Measurement(value: metersPerPoint * maximumWidth, unit: UnitLength.meters)
391-
if useMetricUnits {
392-
return (distanceInMeters, Constants.metricTable)
393-
} else {
394-
return (distanceInMeters.converted(to: .feet), Constants.imperialTable)
396+
397+
return switch units {
398+
case .imperial: (distanceInMeters.converted(to: .feet), Constants.imperialTable)
399+
case .nautical: (distanceInMeters.converted(to: .feet), Constants.nauticalTable)
400+
case .metric: (distanceInMeters, Constants.metricTable)
401+
default: (distanceInMeters, Constants.metricTable)
395402
}
396403
}
397404
}

Tests/MapboxMapsTests/Annotations/ViewAnnotationTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
@_spi(Experimental) @testable import MapboxMaps
2-
import SwiftUICore
2+
import SwiftUI
33
import XCTest
44

55
final class ViewAnnotationTests: XCTestCase {

Tests/MapboxMapsTests/Ornaments/DistanceFormatterTests.swift

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class DistanceFormatterTests: XCTestCase {
77
let sut = DistanceFormatter()
88
sut.locale = Locale(identifier: "EN_CA")
99

10-
let formattedString = sut.string(fromDistance: 1)
10+
let formattedString = sut.string(fromDistance: 1, units: .metric)
1111

1212
XCTAssert(sut.locale.usesMetricSystem, "Selected locale does not use Metric system")
1313
XCTAssertEqual(formattedString, "1 m", "Meters distance is not formatted correctly!")
@@ -17,7 +17,7 @@ class DistanceFormatterTests: XCTestCase {
1717
let sut = DistanceFormatter()
1818
sut.locale = Locale(identifier: "EN_CA")
1919

20-
let formattedString = sut.string(fromDistance: 1337)
20+
let formattedString = sut.string(fromDistance: 1337, units: .metric)
2121

2222
XCTAssert(sut.locale.usesMetricSystem, "Selected locale does not use Metric system")
2323
XCTAssertEqual(formattedString, "1.25 km", "Kilometers distance is not formatted correctly!")
@@ -27,7 +27,7 @@ class DistanceFormatterTests: XCTestCase {
2727
let sut = DistanceFormatter()
2828
sut.locale = Locale(identifier: "EN_US")
2929

30-
let formattedString = sut.string(fromDistance: 1)
30+
let formattedString = sut.string(fromDistance: 1, units: .imperial)
3131

3232
XCTAssert(!sut.locale.usesMetricSystem, "Selected locale does not use Imperial system")
3333
XCTAssertEqual(formattedString, "3.25 ft", "Feet distance is not formatted correctly!")
@@ -37,7 +37,7 @@ class DistanceFormatterTests: XCTestCase {
3737
let sut = DistanceFormatter()
3838
sut.locale = Locale(identifier: "EN_US")
3939

40-
let formattedString = sut.string(fromDistance: 1337)
40+
let formattedString = sut.string(fromDistance: 1337, units: .imperial)
4141

4242
XCTAssert(!sut.locale.usesMetricSystem, "Selected locale does not use Imperial system")
4343
XCTAssertEqual(formattedString, "0.75 mi", "Miles distance is not formatted correctly!")
@@ -47,7 +47,7 @@ class DistanceFormatterTests: XCTestCase {
4747
let sut = DistanceFormatter()
4848
sut.locale = Locale(identifier: "EN_US")
4949

50-
let formattedString = sut.string(fromDistance: 1337, useMetricSystem: true)
50+
let formattedString = sut.string(fromDistance: 1337, units: .metric)
5151

5252
XCTAssertFalse(sut.locale.usesMetricSystem, "Selected locale does not use Metric system")
5353
XCTAssertEqual(formattedString, "1.25 km", "Kilometers distance is not formatted correctly!")
@@ -57,12 +57,43 @@ class DistanceFormatterTests: XCTestCase {
5757
let sut = DistanceFormatter()
5858
sut.locale = Locale(identifier: "EN_CA")
5959

60-
let formattedString = sut.string(fromDistance: 1337, useMetricSystem: false)
60+
let formattedString = sut.string(fromDistance: 1337, units: .imperial)
6161

6262
XCTAssert(sut.locale.usesMetricSystem, "Selected locale does not use Metric system")
6363
XCTAssertEqual(
6464
formattedString.trimmingCharacters(in: CharacterSet(charactersIn: ".")),
6565
"0.75 mi",
6666
"Miles distance is not formatted correctly!")
6767
}
68+
69+
func testFathomsFormat() {
70+
let sut = DistanceFormatter()
71+
sut.locale = Locale(identifier: "EN_US")
72+
73+
let formattedString = sut.string(fromDistance: 18.288, units: .nautical) // ~10 fathoms
74+
75+
XCTAssertEqual(formattedString, "10 fth", "Small nautical distance should be formatted in fathoms")
76+
}
77+
78+
func testNauticalMilesFormat() {
79+
let sut = DistanceFormatter()
80+
sut.locale = Locale(identifier: "EN_US")
81+
82+
let formattedString = sut.string(fromDistance: 1852, units: .nautical) // 1 nautical mile
83+
84+
XCTAssertEqual(formattedString, "1 nmi", "Large nautical distance should be formatted in nautical miles")
85+
}
86+
87+
func testNauticalUnitsFormatting() {
88+
let sut = DistanceFormatter()
89+
sut.locale = Locale(identifier: "EN_US")
90+
91+
// Test small distance (should use fathoms)
92+
let smallDistance = sut.string(fromDistance: 103, units: .nautical) // ~54 fathoms
93+
XCTAssertEqual(smallDistance, "56.25 fth", "Distance under 0.2 nautical miles should use fathoms")
94+
95+
// Test large distance (should use nautical miles)
96+
let largeDistance = sut.string(fromDistance: 5556, units: .nautical) // ~3 nautical miles
97+
XCTAssertEqual(largeDistance, "3 nmi", "Distance over 0.2 nautical miles should use nautical miles")
98+
}
6899
}

0 commit comments

Comments
 (0)