Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Examples/Swift/CustomViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ class CustomViewController: UIViewController, MGLMapViewDelegate, AVSpeechSynthe
let directions = Directions.shared
var routeController: RouteController!

let textDistanceFormatter = DistanceFormatter(approximate: true, forVoiceUse: false)
let voiceDistanceFormatter = DistanceFormatter(approximate: true, forVoiceUse: true)
let textDistanceFormatter = DistanceFormatter(approximate: true)
let voiceDistanceFormatter = SpokenDistanceFormatter(approximate: true)
lazy var speechSynth = AVSpeechSynthesizer()
var userRoute: Route?
var simulateLocation = false
Expand Down
160 changes: 81 additions & 79 deletions MapboxCoreNavigation/DistanceFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,85 @@ let metersPerMile: CLLocationDistance = 1_609.344
let secondsPerHour = 60.0 * 60.0
let yardsPerMile = 1_760.0
let feetPerMile = yardsPerMile * 3.0
let feetPerMeter = 3.28084

extension CLLocationDistance {
var miles: CLLocationDistance {
return self / metersPerMile
}

var feet: CLLocationDistance {
return self * feetPerMeter
}

var kilometers: CLLocationDistance {
return self / 1000
}
}

/// Provides appropriately formatted, localized descriptions of linear distances.
@objc(MBDistanceFormatter)
public class DistanceFormatter: LengthFormatter {
/// True to favor brevity over precision.
var approx: Bool
/// True to insert hints for text-to-speech.
var forVoiceUse: Bool

let nonFractionalLengthFormatter = LengthFormatter()

var usesMetric: Bool {
let locale = numberFormatter.locale as NSLocale
guard let measurementSystem = locale.object(forKey: .measurementSystem) as? String else {
return false
}
return measurementSystem == "Metric"
}

/**
Intializes a new `DistanceFormatter`.

- parameter approximate: approximates the distances.
- parameter forVoiceUse: If true, the returned string will contain strings for some numbers like fractions. This is helpful for some speech synthesizers.
*/
public init(approximate: Bool = false, forVoiceUse: Bool = false) {
public init(approximate: Bool = false) {
self.approx = approximate
self.forVoiceUse = forVoiceUse
super.init()
}

public required init?(coder decoder: NSCoder) {
approx = decoder.decodeBool(forKey: "approximate")
forVoiceUse = decoder.decodeBool(forKey: "forVoiceUse")
super.init(coder: decoder)
}

public override func encode(with aCoder: NSCoder) {
super.encode(with: aCoder)
aCoder.encode(approx, forKey: "approximate")
aCoder.encode(forVoiceUse, forKey: "forVoiceUse")
}

func maximumFractionDigits(for distance: CLLocationDistance) -> Int {
if usesMetric {
return distance < 3_000 ? 1 : 0
} else {
return distance.miles < 3 ? 1 : 0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we set the cutoff at exactly 3 miles with a rounding increment of 0.25, I think that means 3.0 miles becomes “3” while 2.9 miles becomes “3.0”. That would be odd.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}

func roundingIncrement(for distance: CLLocationDistance, unit: LengthFormatter.Unit) -> Double {
if usesMetric {
if distance < 100 {
return 25
} else if distance < 1_000 {
return 50
}
return distance < 3_000 ? 0 : 0.5
} else {
if unit == .yard {
if distance.miles > 0.2 {
return 0
} else {
return 50
}
} else {
return 0.25
}
}
}

/**
Expand All @@ -44,93 +91,48 @@ public class DistanceFormatter: LengthFormatter {
The user’s `Locale` is used here to set the units.
*/
public func string(from distance: CLLocationDistance) -> String {
let miles = distance / metersPerMile
let feet = miles * feetPerMile

// British roads are measured in miles, yards, and feet. Simulate this idiosyncrasy using the U.S. locale.
let isBritish = Locale.current.identifier == "en-GB"
numberFormatter.locale = isBritish ? Locale(identifier: "en-US") : Locale.current
let localeIdentifier = numberFormatter.locale.identifier
if localeIdentifier == "en-GB" || localeIdentifier == "en_GB" {
numberFormatter.locale = Locale(identifier: "en-US")
}

numberFormatter.positivePrefix = ""
numberFormatter.positiveSuffix = ""
numberFormatter.decimalSeparator = nonFractionalLengthFormatter.numberFormatter.decimalSeparator
numberFormatter.alwaysShowsDecimalSeparator = nonFractionalLengthFormatter.numberFormatter.alwaysShowsDecimalSeparator
numberFormatter.usesSignificantDigits = false
numberFormatter.maximumFractionDigits = maximumFractionDigits(for: distance)

var unit: LengthFormatter.Unit = .millimeter
unitString(fromMeters: distance, usedUnit: &unit)
let replacesYardsWithMiles = unit == .yard && miles > 0.2
let showsMixedFraction = (unit == .mile && miles < 10) || replacesYardsWithMiles

// An elaborate hack to use vulgar fractions with miles regardless of
// language.
if showsMixedFraction {
numberFormatter.positivePrefix = "|"
numberFormatter.positiveSuffix = "|"
numberFormatter.decimalSeparator = "!"
numberFormatter.alwaysShowsDecimalSeparator = true
} else {
numberFormatter.positivePrefix = ""
numberFormatter.positiveSuffix = ""
numberFormatter.decimalSeparator = nonFractionalLengthFormatter.numberFormatter.decimalSeparator
numberFormatter.alwaysShowsDecimalSeparator = nonFractionalLengthFormatter.numberFormatter.alwaysShowsDecimalSeparator
}

if approx && !showsMixedFraction {
numberFormatter.usesSignificantDigits = true
numberFormatter.maximumSignificantDigits = 2
} else {
numberFormatter.usesSignificantDigits = false
numberFormatter.maximumFractionDigits = showsMixedFraction ? 2 : 0
}
numberFormatter.roundingIncrement = 0.25
numberFormatter.roundingIncrement = roundingIncrement(for: distance, unit: unit) as NSNumber

return formattedDistance(distance, modify: &unit)
}

func formattedDistance(_ distance: CLLocationDistance, modify unit: inout LengthFormatter.Unit) -> String {
var formattedDistance: String
if unit == .yard {
if miles > 0.2 {
unit = .mile
formattedDistance = string(fromValue: miles, unit: unit)
} else if !isBritish {
unit = .foot
numberFormatter.roundingIncrement = 50
formattedDistance = string(fromValue: feet, unit: unit)
if usesMetric {
if distance >= 1000 {
unit = .kilometer
formattedDistance = string(fromValue: distance.kilometers, unit: unit)
} else {
numberFormatter.roundingIncrement = 50
formattedDistance = string(fromMeters: distance)
}
} else {
formattedDistance = string(fromMeters: distance)
}

// Elaborate hack continued.
if showsMixedFraction {
var parts = formattedDistance.components(separatedBy: "|")
assert(parts.count == 3, "Positive format should’ve inserted two pipe characters.")
var numberParts = parts[1].components(separatedBy: "!")
assert(numberParts.count == 2, "Decimal separator should be present.")
let decimal = Int(numberParts[0])
if let fraction = Double(".\(numberParts[1])0") {
let fourths = Int(round(fraction * 4))
if fourths == fourths % 4 {
if decimal == 0 && fourths != 0 {
numberParts[0] = ""
}
var vulgarFractions = ["", "¼", "½", "¾"]
if forVoiceUse && Locale.current.languageCode == "en" {
vulgarFractions = ["", "a quarter", "a half", "3 quarters"]
if numberParts[0].isEmpty {
parts[2] = " \(unitString(fromValue: 1, unit: unit)) "
if fourths == 3 {
parts[2] = " of a\(parts[2])"
}
}
}
numberParts[1] = vulgarFractions[fourths]
if forVoiceUse && !numberParts[0].isEmpty && !numberParts[1].isEmpty {
numberParts[0] += " & "
}
parts[1] = numberParts.joined(separator: "")
if unit == .yard {
if distance.miles > 0.2 {
unit = .mile
formattedDistance = string(fromValue: distance.miles, unit: unit)
} else {
parts[1] = numberParts.joined(separator: nonFractionalLengthFormatter.numberFormatter.decimalSeparator)
unit = .foot
formattedDistance = string(fromValue: distance.feet, unit: unit)
}
} else {
parts[1] = numberParts.joined(separator: nonFractionalLengthFormatter.numberFormatter.decimalSeparator)
formattedDistance = string(fromMeters: distance)
}
formattedDistance = parts.joined(separator: "")
}

return formattedDistance
Expand Down
86 changes: 86 additions & 0 deletions MapboxCoreNavigation/SpokenDistanceFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import CoreLocation

/// Provides appropriately formatted, localized descriptions of linear distances for voice use.
@objc(MBSpokenDistanceFormatter)
public class SpokenDistanceFormatter: DistanceFormatter {


/**
Returns a more human readable `String` from a given `CLLocationDistance`.

The user’s `Locale` is used here to set the units.
*/
public override func string(from distance: CLLocationDistance) -> String {
// British roads are measured in miles, yards, and feet. Simulate this idiosyncrasy using the U.S. locale.
let localeIdentifier = numberFormatter.locale.identifier
if localeIdentifier == "en-GB" || localeIdentifier == "en_GB" {
numberFormatter.locale = Locale(identifier: "en-US")
}

var unit: LengthFormatter.Unit = .millimeter
unitString(fromMeters: distance, usedUnit: &unit)
let replacesYardsWithMiles = unit == .yard && distance.miles > 0.2
let showsMixedFraction = (unit == .mile && distance.miles < 10) || replacesYardsWithMiles

// An elaborate hack to use vulgar fractions with miles regardless of
// language.
if showsMixedFraction {
numberFormatter.positivePrefix = "|"
numberFormatter.positiveSuffix = "|"
numberFormatter.decimalSeparator = "!"
numberFormatter.alwaysShowsDecimalSeparator = true
} else {
numberFormatter.positivePrefix = ""
numberFormatter.positiveSuffix = ""
numberFormatter.decimalSeparator = nonFractionalLengthFormatter.numberFormatter.decimalSeparator
numberFormatter.alwaysShowsDecimalSeparator = nonFractionalLengthFormatter.numberFormatter.alwaysShowsDecimalSeparator
}

numberFormatter.usesSignificantDigits = false
numberFormatter.maximumFractionDigits = maximumFractionDigits(for: distance)
numberFormatter.roundingIncrement = roundingIncrement(for: distance, unit: unit) as NSNumber

var distanceString = formattedDistance(distance, modify: &unit)

// Elaborate hack continued.
if showsMixedFraction {
var parts = distanceString.components(separatedBy: "|")
assert(parts.count == 3, "Positive format should’ve inserted two pipe characters.")
var numberParts = parts[1].components(separatedBy: "!")
assert(numberParts.count == 2, "Decimal separator should be present.")
let decimal = Int(numberParts[0])
if let fraction = Double(".\(numberParts[1])0") {
let fourths = Int(round(fraction * 4))
if fourths == fourths % 4 {
if decimal == 0 && fourths != 0 {
numberParts[0] = ""
}
var vulgarFractions = ["", "¼", "½", "¾"]
if Locale.current.languageCode == "en" {
vulgarFractions = ["", "a quarter", "a half", "3 quarters"]
if numberParts[0].isEmpty {
parts[2] = " \(unitString(fromValue: 1, unit: unit)) "
if fourths == 3 {
parts[2] = " of a\(parts[2])"
}
}
}
numberParts[1] = vulgarFractions[fourths]
if !numberParts[0].isEmpty && !numberParts[1].isEmpty {
numberParts[0] += " & "
}
parts[1] = numberParts.joined(separator: "")
} else {
parts[1] = numberParts.joined(separator: nonFractionalLengthFormatter.numberFormatter.decimalSeparator)
}
} else {
parts[1] = numberParts.joined(separator: nonFractionalLengthFormatter.numberFormatter.decimalSeparator)
}
distanceString = parts.joined(separator: "")
}

return distanceString
}


}
72 changes: 72 additions & 0 deletions MapboxCoreNavigationTests/DistanceFormatterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import XCTest
import CoreLocation
@testable import MapboxCoreNavigation

let oneMile: CLLocationDistance = metersPerMile
let oneFeet: CLLocationDistance = 0.3048

class DistanceFormatterTests: XCTestCase {

var spokenDistanceFormatter = SpokenDistanceFormatter(approximate: true)
var distanceFormatter = DistanceFormatter(approximate: true)

override func setUp() {
super.setUp()
spokenDistanceFormatter.unitStyle = .long
}

func assertDistance(_ distance: CLLocationDistance, spoken: String, displayed: String) {
let spokenString = spokenDistanceFormatter.string(from: distance)
let displayedString = distanceFormatter.string(from: distance)
XCTAssert(spokenString.contains(spoken), "Spoken '\(spokenString)' should be equal to '\(spoken)'")
XCTAssert(displayedString.contains(displayed), "Displayed: '\(displayedString)' should be equal to \(displayed)")
}

func testDistanceFormatters_US() {
spokenDistanceFormatter.numberFormatter.locale = Locale(identifier: "en-US")
distanceFormatter.numberFormatter.locale = Locale(identifier: "en-US")

assertDistance(oneFeet*50, spoken: "50 feet", displayed: "50 ft")
assertDistance(oneFeet*100, spoken: "100 feet", displayed: "100 ft")
assertDistance(oneFeet*249, spoken: "250 feet", displayed: "250 ft")
assertDistance(oneFeet*305, spoken: "300 feet", displayed: "300 ft")
assertDistance(oneFeet*997, spoken: "1,000 feet", displayed: "1,000 ft")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use this grouping separator here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The grouping separator is very important when there are more than two significant figures. But if we’re rounding up to the hundreds and lack the space to display it, I suppose we could get away with omitting it. In any case, I think it’s really unlikely that anyone would misread “1,000 feet” as “1.000 feet” (that is, one and zero thousandths).

Does the grouping separator ever occur when the formatter uses the metric system?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It did show "1,000 m" before I forced "1,000 m" to display "1 km" (5bd709d#diff-cbb2b216ecdb63112349c1a2e9642224R118)

assertDistance(oneMile*0.25, spoken: "a quarter mile", displayed: "0.2 mi")
Copy link
Copy Markdown
Contributor Author

@frederoni frederoni Jul 21, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this quarter mile displayed correctly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we’re using the formatter to count down to a maneuver, I suppose it’s better to undercount than to overcount.

assertDistance(oneMile/2, spoken: "a half mile", displayed: "0.5 mi")
assertDistance(oneMile*0.75, spoken: "3 quarters of a mile", displayed: "0.8 mi")
assertDistance(oneMile, spoken: "1 mile", displayed: "1 mi")
assertDistance(oneMile*2.5, spoken: "2 & a half miles", displayed: "2.5 mi")
assertDistance(oneMile*2.9, spoken: "3 miles", displayed: "3 mi")
assertDistance(oneMile*3, spoken: "3 miles", displayed: "3 mi")
assertDistance(oneMile*3.5, spoken: "4 miles", displayed: "4 mi")
}

func testDistanceFormatters_DE() {
spokenDistanceFormatter.numberFormatter.locale = Locale(identifier: "de-DE")
distanceFormatter.numberFormatter.locale = Locale(identifier: "de-DE")

assertDistance(10, spoken: "0 Meter", displayed: "0 m")
assertDistance(15, spoken: "25 Meter", displayed: "25 m")
assertDistance(24, spoken: "25 Meter", displayed: "25 m")
assertDistance(500, spoken: "500 Meter", displayed: "500 m")
assertDistance(949, spoken: "950 Meter", displayed: "950 m")
assertDistance(951, spoken: "950 Meter", displayed: "950 m")
assertDistance(1000, spoken: "1 Kilometer", displayed: "1 km")
assertDistance(1001, spoken: "1 Kilometer", displayed: "1 km")
assertDistance(2_500, spoken: "2.5 Kilometer", displayed: "2.5 km")
assertDistance(2_900, spoken: "2.9 Kilometer", displayed: "2.9 km")
assertDistance(3_000, spoken: "3 Kilometer", displayed: "3 km")
assertDistance(3_500, spoken: "4 Kilometer", displayed: "4 km")
}

func testDistanceFormatters_GB() {
spokenDistanceFormatter.numberFormatter.locale = Locale(identifier: "en-GB")
distanceFormatter.numberFormatter.locale = Locale(identifier: "en-GB")

assertDistance(oneMile/2, spoken: "a half mile", displayed: "0.5 mi")
assertDistance(oneMile, spoken: "1 mile", displayed: "1 mi")
assertDistance(oneMile*2.5, spoken: "2 & a half miles", displayed: "2.5 mi")
assertDistance(oneMile*3, spoken: "3 miles", displayed: "3 mi")
assertDistance(oneMile*3.5, spoken: "4 miles", displayed: "4 mi")
}
}
Loading