Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/localization #480

Merged
merged 15 commits into from
Jun 22, 2021
4 changes: 4 additions & 0 deletions Apps/Examples/Examples.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
B5327E9D260124C00095B6BD /* MapboxMaps in Frameworks */ = {isa = PBXBuildFile; productRef = B5327E9C260124C00095B6BD /* MapboxMaps */; };
B5327EBF260277930095B6BD /* Example.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5327EBE260277930095B6BD /* Example.swift */; };
B5327EC3260277AC0095B6BD /* ExampleProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5327EC2260277AC0095B6BD /* ExampleProtocol.swift */; };
C608C107267BC5B1003C86C3 /* LocalizationExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = C608C106267BC5B1003C86C3 /* LocalizationExample.swift */; };
CA03F10F26268DF700673961 /* OfflineManagerExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA03F10E26268DF700673961 /* OfflineManagerExample.swift */; };
CA4F1F7225E815CF00822D2A /* SwiftUIExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A2E03C25CB64E20082BC31 /* SwiftUIExample.swift */; };
CA628414262DFD5C00651488 /* OfflineManagerExample.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CA628413262DFD5C00651488 /* OfflineManagerExample.storyboard */; };
Expand Down Expand Up @@ -149,6 +150,7 @@
A4AC5DEB2542CB2200995E4C /* CustomStyleURLExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomStyleURLExample.swift; sourceTree = "<group>"; };
B5327EBE260277930095B6BD /* Example.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example.swift; sourceTree = "<group>"; };
B5327EC2260277AC0095B6BD /* ExampleProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleProtocol.swift; sourceTree = "<group>"; };
C608C106267BC5B1003C86C3 /* LocalizationExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationExample.swift; sourceTree = "<group>"; };
C64ED3842540A2BE00ADADFB /* CameraAnimationExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraAnimationExample.swift; sourceTree = "<group>"; };
C64ED3882540BA0700ADADFB /* PointAnnotationExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointAnnotationExample.swift; sourceTree = "<group>"; };
C64ED3C42540DD6E00ADADFB /* CustomPointAnnotationExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPointAnnotationExample.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -257,6 +259,7 @@
73694BD225D4B2CE0064F636 /* TrackingModeExample.swift */,
077E3B932581987B00564A3E /* UpdatePointAnnotationPositionExample.swift */,
07B071D12547CF50007F2865 /* Sample Data */,
C608C106267BC5B1003C86C3 /* LocalizationExample.swift */,
);
path = "All Examples";
sourceTree = "<group>";
Expand Down Expand Up @@ -527,6 +530,7 @@
CADCF71F2584990E0065C51B /* DataDrivenSymbolsExample.swift in Sources */,
CADCF7302584990E0065C51B /* LayerPositionExample.swift in Sources */,
CA86E81825BE7C2300E5A1D9 /* BuildingExtrusionsExample.swift in Sources */,
C608C107267BC5B1003C86C3 /* LocalizationExample.swift in Sources */,
0706C4A625B1181A008733C0 /* TerrainExample.swift in Sources */,
CADCF7282584990E0065C51B /* LineAnnotationExample.swift in Sources */,
CADCF72F2584990E0065C51B /* SnapshotterCoreGraphicsExample.swift in Sources */,
Expand Down
79 changes: 79 additions & 0 deletions Apps/Examples/Examples/All Examples/LocalizationExample.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import UIKit
import MapboxMaps

@objc(LocalizationExample)

public class LocalizationExample: UIViewController, ExampleProtocol {

internal var mapView: MapView!

override public func viewDidLoad() {
super.viewDidLoad()

mapView = MapView(frame: view.bounds)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(mapView)

configureLanguageButton()

// Allows the delegate to receive information about map events.
mapView.mapboxMap.onNext(.mapLoaded) { _ in
self.finish() // Needed for internal testing purposes.
}
}

private func configureLanguageButton() {
// Set up layer postion change button
let button = UIButton(type: .system)
button.setTitle("Change Language", for: .normal)
button.setTitleColor(.white, for: .normal)
button.backgroundColor = #colorLiteral(red: 0, green: 0.4784313725, blue: 0.9882352941, alpha: 1)
button.translatesAutoresizingMaskIntoConstraints = false
button.layer.cornerRadius = 20
button.clipsToBounds = true
button.addTarget(self, action: #selector(changeLanguage(sender:)), for: .touchUpInside)
view.addSubview(button)

// Set button location
let horizontalConstraint = button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24)
let verticalConstraint = button.centerXAnchor.constraint(equalTo: view.centerXAnchor)
let widthConstraint = button.widthAnchor.constraint(equalToConstant: 200)
let heightConstraint = button.heightAnchor.constraint(equalToConstant: 40)
NSLayoutConstraint.activate([horizontalConstraint, verticalConstraint, widthConstraint, heightConstraint])
}

@objc public func changeLanguage(sender: UIButton) {
let alert = UIAlertController(title: "Languages",
message: "Please select a language to localize to.",
preferredStyle: .actionSheet)

alert.addAction(UIAlertAction(title: "Spanish", style: .default, handler: { [weak self] _ in
guard let self = self else { return }
self.mapView.locale = Locale(identifier: SupportedLanguage.spanish.rawValue)
}))

alert.addAction(UIAlertAction(title: "French", style: .default, handler: { [weak self] _ in
guard let self = self else { return }
self.mapView.locale = Locale(identifier: SupportedLanguage.french.rawValue)
}))

alert.addAction(UIAlertAction(title: "Traditional Chinese", style: .default, handler: { [weak self] _ in
guard let self = self else { return }
self.mapView.locale = Locale(identifier: SupportedLanguage.traditionalChinese.rawValue)
}))

alert.addAction(UIAlertAction(title: "Arabic", style: .default, handler: { [weak self] _ in
guard let self = self else { return }
self.mapView.locale = Locale(identifier: SupportedLanguage.arabic.rawValue)
}))

alert.addAction(UIAlertAction(title: "English", style: .default, handler: { [weak self] _ in
guard let self = self else { return }
self.mapView.locale = Locale(identifier: SupportedLanguage.english.rawValue)
}))

alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

present(alert, animated: true)
}
}
5 changes: 4 additions & 1 deletion Apps/Examples/Examples/Models/Examples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,10 @@ public struct Examples {
type: LineGradientExample.self),
Example(title: "Change the map's style",
description: "Switch between local and default Mapbox styles for the same map view.",
type: SwitchStylesExample.self)
type: SwitchStylesExample.self),
Example(title: "Change the map's language",
description: "Switch between supported languages for Symbol Layers",
type: LocalizationExample.self)
]

// Examples that show use cases related to user interaction with the map.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Mapbox welcomes participation and contributions from everyone.
### Features ✨ and improvements 🏁

- Annotations now will persist across style changes by default. ([#475](https://github.com/mapbox/mapbox-maps-ios/pull/475))
- Adds localization support for v10 Maps SDK. This can be used by setting the `mapView.locale`. Use the `SupportedLanguages` enum, which lists currently supported `Locale`. ([#480](https://github.com/mapbox/mapbox-maps-ios/pull/480))

### Breaking changes ⚠️

Expand Down
8 changes: 8 additions & 0 deletions Mapbox/MapboxMaps.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@
B5F294BB2635EEAD00199426 /* FloatingPointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F294BA2635EEAD00199426 /* FloatingPointTests.swift */; };
B5F295552635EF4E00199426 /* CompassDirectionLong.strings in Resources */ = {isa = PBXBuildFile; fileRef = B5F295512635EF4E00199426 /* CompassDirectionLong.strings */; };
B5F295562635EF4E00199426 /* CompassDirectionShort.strings in Resources */ = {isa = PBXBuildFile; fileRef = B5F295532635EF4E00199426 /* CompassDirectionShort.strings */; };
C608C133267BD04A003C86C3 /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C608C132267BD04A003C86C3 /* SupportedLanguage.swift */; };
C6334944262E28C300D17701 /* StyleTransitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6334943262E28C300D17701 /* StyleTransitionTests.swift */; };
C64994A9258D5ADE0052C21C /* LocationPuckManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64994A5258D5ADD0052C21C /* LocationPuckManager.swift */; };
C64994AB258D5ADE0052C21C /* Puck.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64994A6258D5ADE0052C21C /* Puck.swift */; };
Expand All @@ -228,6 +229,7 @@
C64ED307253F7CF500ADADFB /* LocationSupportableMapViewMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64ED305253F7CF500ADADFB /* LocationSupportableMapViewMock.swift */; };
C64ED322253F7E9100ADADFB /* LocationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64ED320253F7E9100ADADFB /* LocationManagerTests.swift */; };
C64ED33D253F819B00ADADFB /* LocationConsumerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64ED33B253F819B00ADADFB /* LocationConsumerMock.swift */; };
C678E42026825A1B00C7B560 /* Style+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C678E41F26825A1B00C7B560 /* Style+Localization.swift */; };
C69F0142254358B3001AB49B /* LocationManagerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69F011125431AF4001AB49B /* LocationManagerIntegrationTests.swift */; };
C69F017525435C55001AB49B /* LocationProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69F017325435C55001AB49B /* LocationProviderMock.swift */; };
C6C5CDC5264C12C80097FCD1 /* OfflineGuideIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6C5CDBE264C0CBE0097FCD1 /* OfflineGuideIntegrationTests.swift */; };
Expand Down Expand Up @@ -691,6 +693,7 @@
B5F2957D2635EF9F00199426 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/CompassDirectionLong.strings; sourceTree = "<group>"; };
B5F2957E2635EF9F00199426 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/CompassDirectionLong.strings; sourceTree = "<group>"; };
B5F2957F2635EF9F00199426 /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/CompassDirectionLong.strings; sourceTree = "<group>"; };
C608C132267BD04A003C86C3 /* SupportedLanguage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
C6334943262E28C300D17701 /* StyleTransitionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StyleTransitionTests.swift; sourceTree = "<group>"; };
C64994A5258D5ADD0052C21C /* LocationPuckManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationPuckManager.swift; sourceTree = "<group>"; };
C64994A6258D5ADE0052C21C /* Puck.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Puck.swift; sourceTree = "<group>"; };
Expand All @@ -700,6 +703,7 @@
C64ED305253F7CF500ADADFB /* LocationSupportableMapViewMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSupportableMapViewMock.swift; sourceTree = "<group>"; };
C64ED320253F7E9100ADADFB /* LocationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManagerTests.swift; sourceTree = "<group>"; };
C64ED33B253F819B00ADADFB /* LocationConsumerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationConsumerMock.swift; sourceTree = "<group>"; };
C678E41F26825A1B00C7B560 /* Style+Localization.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Style+Localization.swift"; sourceTree = "<group>"; };
C69F011125431AF4001AB49B /* LocationManagerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManagerIntegrationTests.swift; sourceTree = "<group>"; };
C69F017325435C55001AB49B /* LocationProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationProviderMock.swift; sourceTree = "<group>"; };
C6C5CDB8264C0C910097FCD1 /* OfflineManagerIntegrationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OfflineManagerIntegrationTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1466,6 +1470,8 @@
A4519DCE2432FF03007CF39A /* MapboxMapsStyle */ = {
isa = PBXGroup;
children = (
C678E41F26825A1B00C7B560 /* Style+Localization.swift */,
C608C132267BD04A003C86C3 /* SupportedLanguage.swift */,
CA9F8CE22641F95C00A8BCB6 /* StyleManagerProtocol.swift */,
B5DDE95F2613ACB400998840 /* StyleEncodable.swift */,
0CBCF2D7254229630025F7B3 /* STYLE_README.md */,
Expand Down Expand Up @@ -2039,6 +2045,7 @@
0C708F2924EB1EE2003CE791 /* LocationIndicatorLayer.swift in Sources */,
0C708F6024EC70DD003CE791 /* Color.swift in Sources */,
0C708F2D24EB1EE2003CE791 /* HeatmapLayer.swift in Sources */,
C608C133267BD04A003C86C3 /* SupportedLanguage.swift in Sources */,
0C3B1E9024DDADD000CC29E8 /* EventsManager.swift in Sources */,
0C708F4B24EB23C7003CE791 /* ImageSource.swift in Sources */,
C64994AF258D5ADE0052C21C /* Puck2D.swift in Sources */,
Expand Down Expand Up @@ -2070,6 +2077,7 @@
B575018F26334BB800937A9E /* OrnamentOptions.swift in Sources */,
0CD62F23245887C8006421D1 /* OrnamentsManager.swift in Sources */,
0CB90C6D264CC292003008A5 /* GestureType.swift in Sources */,
C678E42026825A1B00C7B560 /* Style+Localization.swift in Sources */,
CA0C427E2602BECE0054D9D0 /* LayerPosition.swift in Sources */,
07A0BCCB2582F16300B8E109 /* GeoJSONManager.swift in Sources */,
0C708F2724EB1EE2003CE791 /* LineLayer.swift in Sources */,
Expand Down
12 changes: 12 additions & 0 deletions Sources/MapboxMaps/Foundation/MapView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// swiftlint:disable file_length
@_exported import MapboxCoreMaps
@_exported import MapboxCommon
@_implementationOnly import MapboxCoreMaps_Private
Expand Down Expand Up @@ -34,6 +35,17 @@ open class MapView: UIView {
/// Controls the addition/removal of annotations to the map.
public internal(set) var annotations: AnnotationOrchestrator!

/// Property that describes the current language for `SymbolLayer.textField`
public var locale: Locale = Locale.current {
Copy link
Contributor

Choose a reason for hiding this comment

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

Locale.current only matches the language that the iOS UI is currently displayed in, which can differ from both the surrounding application and the user’s preference. Bundle.preferredLocalizations has the advantage of matching the language that the rest of the application is currently displayed in. Locale.preferredLanguages has the advantage of matching the user’s preferred content language settings, even if the application is unavailable in the preferred language.

didSet {
do {
try mapboxMap.style.localizeLabels(into: locale)
} catch {
Log.warning(forMessage: "Error when localizing labels", category: "Style")
}
}
}

/// A reference to the `EventsManager` used for dispatching telemetry.
internal var eventsListener: EventsListener!

Expand Down
83 changes: 83 additions & 0 deletions Sources/MapboxMaps/Style/Style+Localization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
@_implementationOnly import MapboxCommon_Private

/// Support for localization of labels
extension Style {

/// This function creates an expression that will localize the `textField` property of a `SymbolLayer`
/// - Parameter locale: A `SupportedLanguage` based `Locale`
internal func localizeLabels(into locale: Locale) throws {

/// Get all symbol layers that are currently on the map
let symbolLayers = allLayerIdentifiers.filter { layer in
return layer.type == .symbol
}
Comment on lines +11 to +13
Copy link
Contributor

@1ec5 1ec5 Jun 22, 2021

Choose a reason for hiding this comment

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

Not every symbol layer should be forced to have a name-based text-field, which is specific to the Mapbox Streets source. A symbol layer may be backed by a vector tileset other than Mapbox Streets or a GeoJSON source; that source’s features may have name_en properties but not name properties. There’s code elsewhere that checks the source URL; it would be useful to conditionalize all localization on it, not just detection of Streets source v7.


/// Expression to be applied to the `SymbolLayer.textField`to localize the language
/// Sample Expression JSON: `["format",["coalesce",["get","name_en"],["get","name"]],{}]`
let replacement = "[\"get\",\"name_\(getLocaleValue(locale: locale))\"]"

let expressionCoalesce = try NSRegularExpression(pattern: "\\[\"get\",\\s*\"(name_.{2,7})\"\\]",
options: .caseInsensitive)
let expressionAbbr = try NSRegularExpression(pattern: "\\[\"get\",\\s*\"abbr\"\\]",
options: .caseInsensitive)
Comment on lines +19 to +22
Copy link
Contributor

Choose a reason for hiding this comment

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

It’s unnecessary to convert the entire expression to JSON, then to a string, then monkeypatch the string. That defeats the purpose of having a structured, type-safe representation of an expression.

Consider adapting the implementation in mapbox/mapbox-navigation-ios#2933 that recurses into the expression, surgically replacing occurrences of name or name_* with the coalescing expression. That approach is much more reliable and maintainable than trying to account for the variety of possible text-field values with finding-and-replacing in source code.


for layerInfo in symbolLayers {
let tempLayer = try layer(withId: layerInfo.id) as SymbolLayer

if var stringExpression = String(data: try JSONEncoder().encode(tempLayer.textField), encoding: .utf8),
stringExpression != "null" {
stringExpression.updateExpression(replacement: replacement, regex: expressionCoalesce)
stringExpression.updateExpression(replacement: replacement, regex: expressionAbbr)

// Turn the new json string back into an Expression
let data = stringExpression.data(using: .utf8)
let convertedExpression = try JSONSerialization.jsonObject(with: data!, options: [])

try setLayerProperty(for: tempLayer.id, property: "text-field", value: convertedExpression)
}
Comment on lines +25 to +37
Copy link
Contributor

Choose a reason for hiding this comment

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

This code should be extracted into a public method, so that a map optimized for turn-by-turn navigation can localize place and POI labels but not road labels.

}
}

/// Filters through source to determine supported locale styles.
/// This is needed for v7 support
internal func getLocaleValue(locale: Locale) -> String {
let vectorSources = allSourceIdentifiers.filter { source in
return source.type == .vector
}

for sourceInfo in vectorSources {
do {
let tempSource = try source(withId: sourceInfo.id) as VectorSource

if tempSource.url?.contains("mapbox.mapbox-streets-v7") == true {
Copy link
Contributor

Choose a reason for hiding this comment

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

The tileset ID always occurs in the hostname part of the URL, so compare against URL.host for additional robustness.

if locale.identifier.contains("zh") {
// v7 styles does not support value of "name_zh-Hant"
if locale.identifier == SupportedLanguage.traditionalChinese.rawValue {
Copy link
Contributor

Choose a reason for hiding this comment

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

Replace SupportedLanguage with an array of Locales to avoid unchecked usage of raw values.

return SupportedLanguage.chinese.rawValue
}
}
}
} catch {
Log.warning(forMessage: "Error localizing textField for Symbol Layer", category: "Style")
}
}

return locale.identifier
}
}

extension String {

/// Updates string using a regex
/// - Parameters:
/// - replacement: New string to replace the matched pattern
/// - regex: The regex pattern that will be matched for replacement
internal mutating func updateExpression(replacement: String, regex: NSRegularExpression) {
let range = NSRange(location: 0, length: self.count)

self = regex.stringByReplacingMatches(in: self,
options: [],
range: range,
withTemplate: replacement)
}
}
Loading