Migrate to v10
The Mapbox Maps SDK v10 introduces improvements to how Mapbox works on iOS platforms, as well as changes to how developers use the SDK. This document summarizes the most important changes and walks you through how to upgrade an application using a previous version of the Mapbox Maps SDK to v10.
Requirements
- Xcode 12.2+
- Swift version 5.3+
- iOS 11 or greater
Starting with v10.0.0-beta.15, building with Apple Silicon is supported.
New MapView
Structured API surface
The Maps SDK v10 aims to provide a single point of entry to all map-related tasks. The API interface of the new MapView
is now hierarchical in nature. Methods are strictly separated based on their components (for example MapboxMap
, Style
, Camera
and Location
).
All capabilities related to the map style will be part of the style
property of the MapView
. Other capabilities like annotations or user location have their own corresponding properties that encapsulate their feature set.
ResourceOptions
configuration (including access token)
In pre-v10 the access token was configured either using the Info.plist key MGLMapboxAccessToken
or by programmatically setting MGLAccountManager.accessToken
.
In v10, a new ResourceOptionsManager
has been introduced to manage the application-scoped ResourceOptions
, which includes an access token.
The shared ResourceOptionsManager.default
is an application wide convenience. It's used by default by MapInitOptions
(and in turn by MapView.init()
) if you do not provide a custom ResourceOptions
. You should use this convenience to set an application wide access token:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
ResourceOptionsManager.default.resourceOptions.accessToken = "my-application-access-token"
return true
}
If you choose not to set the token programmatically, the shared instance will look in the application's Info.plist for the value associated with the MBXAccessToken
key.
You can create ResourceOptionsManager
s as you need them. This can be useful if you need a mechanism to share ResourceOptions
between parts of your application.
If you are working with Storyboards or Xibs, ResourceOptionsManager.default
will be used when creating the required MapInitOptions
. If you wish to
provide a custom MapInitOptions
(which includes the ResourceOptions
), you
can connect a custom type conforming to the MapInitOptionsProvider
protocol
to the mapInitOptionsProvider
IBOutlet. This gives you a mechanism to
override the access token used by a MapView
.
Display a map
The primary interface in v10 is the MapView
, which, like in earlier versions of the SDK, is a UIView
.
MapboxMap
has also been introduced, which grants access to map properties (as opposed to UIView
associated properties). The MapView.mapboxMap
property is the equivalent of Android's getMapboxMap()
function.
The example below displays a full-screen map with a default style. Initializing a MapView
creates a default set of map options, providing a strong foundation.
pre-v10:
import UIKit
import Mapbox
class BasicMapViewController: UIViewController {
var mapView: MGLMapView!
override func viewDidLoad() {
super.viewDidLoad()
mapView = MGLMapView(frame: view.bounds)
view.addSubview(mapView)
}
}
v10:
import UIKit
import MapboxMaps
class BasicMapViewController: UIViewController {
var mapView: MapView!
var accessToken: String!
override func viewDidLoad() {
super.viewDidLoad()
ResourceOptionsManager.default.resourceOptions.accessToken = self.accessToken
mapView = MapView(frame: view.bounds)
view.addSubview(mapView)
}
}
The result will look like this:
Style loading functions
You can separate style loading from map view creation using the new loadStyleURI
and loadStyleJSON
functions. These have convenient closures to return the loaded Style
and any map loading error.
Function | Description |
---|---|
loadStyleURI(_:completion:) | Load a new map style asynchronous from the specified URI. |
loadStyleJSON(_:completion:) | Load style from a JSON string. |
Here's an example illustrating how to delay loading a map style using a style URL:
// Pass a nil StyleURI to stop the map from loading a default style
mapView = MapView(frame: bounds, mapInitOptions: MapInitOptions(styleURI: nil))
...
mapView.mapboxMap.loadStyleURI(.streets) { result in
switch result {
case .success(let style):
print("The map has finished loading the style")
// Do something with `style`
case let .failure(error):
print("The map failed to load the style: \(error)")
}
}
Listening to the map's lifecycle
The Map
has several stages to its lifecycle and communicates transitions via a callback mechanism. This callback mechanism replaces the MGLMapViewDelegate
protocol seen in pre-v10 versions of the Maps SDK.
The container view controller in the last example can listen for when the map has finished loading using the following code:
class BasicMapViewController: UIViewController {
var mapView: MapView!
var accessToken: String!
var handler: ((Event) -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
ResourceOptionsManager.default.resourceOptions.accessToken = self.accessToken
mapView = MapView(frame: view.bounds)
view.addSubview(mapView)
/**
The closure is called when style data has been loaded. This is called
multiple times. Use the event data to determine what kind of style data
has been loaded.
When the type is `style` this event most closely matches
`-[MGLMapViewDelegate mapView:didFinishLoadingStyle:]` in SDK versions
prior to v10.
*/
mapView.mapboxMap.onEvery(.styleDataLoaded) { [weak self] (event) in
guard let data = event.data as? [String: Any],
let type = data["type"],
let handler = self?.handler else {
return
}
print("The map has finished loading style data of type = \(type)")
handler(event)
}
/**
The closure is called during the initialization of the map view and
after any `styleDataLoaded` events; it is called when the requested
style has been fully loaded, including the style, specified sprite and
source metadata.
This event is the last opportunity to modify the layout or appearance
of the current style before the map view is displayed to the user.
Adding a layer at this time to the map may result in the layer being
presented BEFORE the rest of the map has finished rendering.
Changes to sources or layers of the current style do not cause this
event to be emitted.
*/
mapView.mapboxMap.onNext(.styleLoaded) { (event) in
print("The map has finished loading style ... Event = \(event)")
self.handler?(event)
}
/**
The closure is called whenever the map finishes loading and the map has
rendered all visible tiles, either after the initial load OR after a
style change has forced a reload.
This is an ideal time to add any runtime styling or annotations to the
map and ensures that these layers would only be shown after the map has
been fully rendered.
*/
mapView.mapboxMap.onNext(.mapLoaded) { (event) in
print("The map has finished loading... Event = \(event)")
self.handler?(event)
}
/**
The closure is called whenever the map view is entering an idle state,
and no more drawing will be necessary until new data is loaded or there
is some interaction with the map.
- All currently requested tiles have been rendered
- All fade/transition animations have completed
*/
mapView.mapboxMap.onNext(.mapIdle) { (event) in
print("The map is idle... Event = \(event)")
self.handler?(event)
}
/**
The closure is called whenever the map has failed to load. This could
be because of a variety of reasons, including a network connection
failure or a failure to fetch the style from the server.
You can use the associated error message to notify the user that map
data is unavailable.
*/
mapView.mapboxMap.onNext(.mapLoadingError) { (event) in
guard let data = event.data as? [String: Any],
let type = data["type"],
let message = data["message"] else {
return
}
print("The map failed to load.. \(type) = \(message)")
}
}
}
It's worth noting that the closest equivalent of pre-v10's -[MGLMapViewDelegate mapView:didFinishLoadingStyle:]
is the .styleDataLoaded
event when its associated type
is style
, though we recommended using the MapboxMap.loadStyleURI()
function instead. loadStyleURI()
listens for the .styleLoaded
event.
The following simplified diagram helps explain the event lifecycle:
┌─────────────┐ ┌─────────┐ ┌──────────────┐
│ Application │ │ Map │ │ResourceLoader│
└──────┬──────┘ └────┬────┘ └───────┬──────┘
│ │ │
├───── Set style URL ──────▶│ │
│ ├───────────get style───────────▶│
│ │ │
│ │◀─────────style data────────────┤
│ │ │
│ ├─parse style─┐ │
│ │ │ │
│ styleDataLoaded ◀─────────────┘ │
│◀────{"type": "style"}─────┤ │
│ ├─────────get sprite────────────▶│
│ │ │
│ │◀────────sprite data────────────┤
│ │ │
│ ├──────parse sprite───────┐ │
│ │ │ │
│ styleDataLoaded ◀─────────────────────────┘ │
│◀───{"type": "sprite"}─────┤ │
│ ├─────get source TileJSON(s)────▶│
│ │ │
│ sourceDataLoaded │◀─────parse TileJSON data───────┤
│◀──{"type": "metadata"}────┤ │
│ │ │
│ │ │
│ styleDataLoaded │ │
│◀───{"type": "sources"}────┤ │
│ ├──────────get tiles────────────▶│
│ │ │
│◀───────styleLoaded────────┤ │
│ │ │
│ sourceDataLoaded │◀─────────tile data─────────────┤
│◀────{"type": "tile"}──────┤ │
│ │ │
│ │ │
│◀────renderFrameStarted────┤ │
│ ├─────render─────┐ │
│ │ │ │
│ ◀────────────────┘ │
│◀───renderFrameFinished────┤ │
│ ├──render, all tiles loaded──┐ │
│ │ │ │
│ ◀────────────────────────────┘ │
│◀────────mapLoaded─────────┤ │
│ │ │
│ │ │
│◀─────────mapIdle──────────┤ │
│ ┌ ─── ─┴─ ─── ┐ │
│ │ offline │ │
│ └ ─── ─┬─ ─── ┘ │
│ │ │
├──────── Set camera ──────▶│ │
│ ├───────────get tiles───────────▶│
│ │ │
│ │┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│◀─────────mapIdle──────────┤ waiting for connectivity │ │
│ ││ Map renders cached data │
│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │
│ │ │
Map
options
Configure the map
The map created in the example above is initialized with default configurations for map elements such as gestures, ornaments, and the map's camera.
The map's configurations can be updated via the MapView
and its mapboxMap
property.
pre-v10:
In pre-v10 versions of the Maps SDK, options were set on the MGLMapView
or using an MGLMapViewDelegate
method.
private var restrictedBounds: MGLCoordinateBounds!
override func viewDidLoad() {
super.viewDidLoad()
let mapView = MGLMapView(frame: view.bounds)
mapView.delegate = self
mapView.showsScale = YES;
view.addSubview(mapView)
}
func mapView(_ mapView: MGLMapView, shouldChangeFrom oldCamera: MGLMapCamera, to newCamera: MGLMapCamera) -> Bool {
let currentCamera = mapView.camera
let newCameraCenter = newCamera.centerCoordinate
mapView.camera = newCamera
let newVisibleCoordinates = mapView.visibleCoordinateBounds
mapView.camera = currentCamera
// Test if the newCameraCenter and newVisibleCoordinates are inside self.restrictedBounds.
let inside = MGLCoordinateInCoordinateBounds(newCameraCenter, self.restrictedBounds)
let intersects = MGLCoordinateInCoordinateBounds(newVisibleCoordinates.ne, self.colorado) && MGLCoordinateInCoordinateBounds(newVisibleCoordinates.sw, self.restrictedBounds)
return inside && intersects
}
v10:
In the example below, the restricted bounds are set directly via the MapView.mapboxMap
.
let restrictedBounds = CoordinateBounds(southwest: CLLocationCoordinate2D(latitude: 10, longitude: 10),
northeast: CLLocationCoordinate2D(latitude: 11, longitude: 11))
let cameraBoundsOptions = CameraBoundsOptions(bounds: restrictedBounds)
// Configure map to show a scale bar
mapView.ornaments.options.scaleBar.visibility = .visible
try mapView.mapboxMap.setCameraBounds(with: cameraBoundsOptions)
Modular architecture
The Maps SDK v10 is being developed with a goal of providing a modular architecture. This sets the foundation for a future plugin-based architecture that can be minimized or extended.
Example: Replaceable HTTP stack
You can supply custom implementations for some components of the SDK.
The example below shows you how to replace the HTTP stack. You must replace the stack early in the application lifecycle, before making any API requests, and you should call setUserDefinedForCustom
once only.
import UIKit
import MapboxMaps
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
let customHTTPService = CustomHttpService()
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
HttpServiceFactory.setUserDefinedForCustom(customHTTPService)
return true
}
...
CustomHttpService
referenced in the code snippet above implements HttpServiceInterface
. The following is an example of a partial implementation:
class CustomHttpService: HttpServiceInterface {
func request(for request: HttpRequest, callback: @escaping HttpResponseCallback) -> UInt64 {
// Make an API request
var urlRequest = URLRequest(url: URL(string: request.url)!)
let methodMap: [HttpMethod: String] = [
.get: "GET",
.head: "HEAD",
.post: "POST"
]
urlRequest.httpMethod = methodMap[request.method]!
urlRequest.httpBody = request.body
urlRequest.allHTTPHeaderFields = request.headers
let task = URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in
let result: Result<HttpResponseData, HttpRequestError>
if let error = error {
// Map NSURLError to HttpRequestErrorType
let requestError = HttpRequestError(type: .otherError, message: error.localizedDescription)
result = .failure(requestError)
} else if let response = response as? HTTPURLResponse,
let data = data {
// Keys are expected to be lowercase
var headers: [String: String] = [:]
for (key, value) in response.allHeaderFields {
guard let key = key as? String,
let value = value as? String else {
continue
}
headers[key.lowercased()] = value
}
let responseData = HttpResponseData(headers: headers, code: Int64(response.statusCode), data: data)
result = .success(responseData)
} else {
// Error
let requestError = HttpRequestError(type: .otherError, message: "Invalid response")
result = .failure(requestError)
}
let response = HttpResponse(request: request, result: result)
callback(response)
}
task.resume()
// Handle used to cancel requests
return UInt64(task.taskIdentifier)
}
...
Camera
Manipulate the camera
In v10, the map’s camera is handled by the CameraAnimationsManager
. The MapView
has a reference to the CameraAnimationsManager
called camera
.
CameraOptions
is the v10 equivalent to pre-v10's MGLMapCamera
. The map’s CameraOptions
contain the center coordinate, padding, anchor, zoom, bearing, and pitch for a map. The camera options can be configured either as a whole or individually. The map’s camera can be configured via programmatic or gesture-based events. The current camera state of the map can be accessed via the mapView's cameraState
property.
Set the map’s camera
The camera manager can be configured with an initial camera view.
To set the map’s camera, first create a CameraOptions
object, then direct the map to use it via the MapInitOptions
.
CameraOptions
parameters are optional, so only the required properties
need to be set.
pre-v10:
let centerCoordinate = CLLocationCoordinate2D(latitude: 21.3069,
longitude: -157.8583)
mapView.setCenter(centerCoordinate, zoomLevel: 14, direction: 0, animated: false)
v10:
// Set the center coordinate of the map to Honolulu, Hawaii
let centerCoordinate = CLLocationCoordinate2D(latitude: 21.3069,
longitude: -157.8583)
// Create a camera
let camera = CameraOptions(center: centerCoordinate, zoom: 14)
let options = MapInitOptions(cameraOptions: camera)
let mapView = MapView(frame: frame, mapInitOptions: options)
You can set the map’s zoom level, pitch, and center coordinate programmatically by initializing a CameraOptions
object with those values. You can the set the camera using mapView.mapboxMap.setCamera()
Camera values should be set using the MapboxMap.setCamera()
method, although they can be accessed as read-only properties on the map view's cameraState
(for example, MapView.cameraState.centerCoordinate
).
Fit the camera to a given shape
In the Maps SDK v10, the approach to fitting the camera to a given shape like that of pre-v10 versions.
In v10, call camera(for:)
functions on the MapboxMap
to create: a
camera for a given geometry, a camera that fits a set of rectangular
coordinate bounds or a camera based off of a collection of coordinates.
Then, call ease(to:)
on MapView.camera
to visibly transition
to the new camera.
Below is an example of setting the camera to a set of coordinates:
pre-v10:
// Fitting a camera to a set of coordinate bounds
let sw = CLLocationCoordinate2D(latitude: 24, longitude: -89)
let ne = CLLocationCoordinate2D(latitude: 26, longitude: -88)
let coordinateBounds = MGLCoordinateBounds(sw: sw, ne: ne)
let camera = mapView.cameraThatFitsCoordinateBounds(coordinateBounds)
mapView.setCamera(camera, animated: true)
v10:
let coordinates = [
CLLocationCoordinate2DMake(24, -89),
CLLocationCoordinate2DMake(24, -88),
CLLocationCoordinate2DMake(26, -88),
CLLocationCoordinate2DMake(26, -89),
CLLocationCoordinate2DMake(24, -89)
]
let camera = mapView.mapboxMap.camera(for: coordinates,
padding: .zero,
bearing: nil,
pitch: nil)
mapView.camera.ease(to: camera, duration: 10.0)
Configure the default behavior of the camera
pre-v10
In pre-v10 versions of the Maps SDK, default camera behavior was configured directly on the map view using MGLMapView
properties like minimumZoomLevel
and minimumPitch
. To limit the camera to a specific area you would use the mapView:shouldChangeFromCamera:toCamera:
method on MGLMapViewDelegate
as shown in the Restrict map panning to an area example.
v10:
In v10, there are new CameraBounds
and CameraBoundsOptions
struct that allows configuration of default behaviors for the camera positions, with the options outlined below:
public struct CameraBoundsOptions: Hashable {
/// The latitude and longitude bounds to which the camera center are constrained.
public var bounds: CoordinateBounds?
/// The maximum zoom level, in mapbox zoom levels 0-25.5. At low zoom levels,
/// a small set of map tiles covers a large geographical area. At higher
/// zoom levels, a larger number of tiles cover a smaller geographical area.
public var maxZoom: CGFloat?
/// The minimum zoom level, in mapbox zoom levels 0-25.5.
public var minZoom: CGFloat?
/// The maximum allowed pitch value in degrees.
public var maxPitch: CGFloat?
/// The minimum allowed pitch value degrees.
public var minPitch: CGFloat?
...
Use the setCameraBounds(with:)
function on MapView.mapboxMap
to configure the above properties.
let sw = CLLocationCoordinate2DMake(-12, -46)
let ne = CLLocationCoordinate2DMake(2, 43)
let restrictedBounds = CoordinateBounds(southwest: sw, northeast: ne)
try mapView.mapboxMap.setCameraBounds(with: CameraBoundsOptions(bounds: restrictedBounds,
maxZoom: 15.0,
minZoom: 8.0))
The biggest change to this approach is how you restrict the camera to a given set of coordinate bounds, which you can now do using a simpler function-based approach instead of using the delegate method outlined earlier.
Map styles
Style Specification
In v10 the style API is aligned with the Mapbox Style Specification. Source
s, Layer
s, and Light
all work in the exact same manner as in the Style Specification.
The Style
object in the MapView.mapboxMap
handles all functionality related to runtime styling.
Add sources and layers to the map only after it has finished loading its initial style. Use the lifecycle callbacks to be informed of when the style has finished loading.
Type-safe API
Every Source
and Layer
declared in the Style Specification exists in v10 as an exactly-typed Swift struct. Every property within those sources and layers is modeled using familiar Swift types. This means that creating new sources and layers should feel familiar to iOS developers who write in Swift.
Example: GeoJSON Source
For example, a minimal GeoJSON source can be created using the following code:
var myGeoJSONSource = GeoJSONSource()
myGeoJSONSource.maxzoom = 14
GeoJSON sources have a data property that you can set to either a URL or inline GeoJSON. The Swift struct representing a GeoJSON source is modeled 1:1 with this expectation.
The SDK uses turf-swift to model GeoJSON. This means that crafting GeoJSON at runtime is both uncomplicated and backed by the type-safety and codable conformance that Turf provides.
// Setting the `data` property with a url pointing to a GeoJSON document
myGeoJSONSource.data = .url(someGeoJSONDocumentURL)
// Setting a Turf feature to the `data` property
myGeoJSONSource.data = .featureCollection(someTurfFeatureCollection)
The MapView.mapboxMap
holds a style object that can be used to add, remove, or update sources and layers.
try mapView.mapboxMap.style.addSource(myGeoJSONSource, id: "my-geojson-source")
pre-v10:
This functionality used to required creation of an MGLShapeSource
:
let shapeSource = MGLShapeSource(identifier:"my-geojson", url: URL(string: "<path-to-geojson-file>"), options: nil)
self.mapView.addSource(shapeSource)
Updating GeoJSON sources happens differently in v10. You’ll need to call
style.updateGeoJSONSource(withId:geoJSON:)
to update the data belonging to a
GeoJSONSource
.
In v10, objects like MGLPointFeature
, MGLPolylineFeature
,
MGLPolygonFeature
and MGLShapeCollectionFeature
are replaced by
GeoJSONSource
in conjunction with Turf’s Point
, LineString
,
MultiLineString
, Polygon
, etc.
Example: Background layer
Adding a background layer further demonstrates the type-safety provided in v10.
As mentioned earlier, all Layer
s are also Swift structs. The following code sets up a background layer and sets its background color to red:
var myBackgroundLayer = BackgroundLayer(id: "my-background-layer")
myBackgroundLayer.backgroundColor = .constant(StyleColor(.red))
Once a layer is created, add it to the map:
try mapView.mapboxMap.style.addLayer(myBackgroundLayer)
Localization
v10 allows developers to change the language displayed by a map style based on a given locale. This ability can be enabled with the following code:
// Changes locale to Spanish
mapView.mapboxMap.style.localizeLabels(into: Locale(identifier: "en"))
There is also an opportunity to provide an array of layer ids so that only a subset of layers will be localized. This can be achieved with the following code:
// Changes all country labels to Japanese
mapView.mapboxMap.style.localizeLabels(into: Locale(identifier: "ja"), forLayerIds: ["country-label"])
Expressions
In the background layer example above, the backgroundColor
property of the layer is set to a constant value. But the backgroundColor
property (and all layout and paint properties) also support expressions.
In v10, expressions have been redesigned from the ground up. The new Expression domain-specific language (DSL) directly models expressions based on the Mapbox Style Specification and removes the dependency on NSExpression
.
In v10, expressions exist as familiar and type-safe Swift structs. They are also backed by Swift function builders to make the experience of writing an expression like the way SwiftUI works.
Consider an interpolate expression written in raw JSON:
[
"interpolate",
["linear"],
["zoom"],
0,
"hsl(0, 79%, 53%)",
14,
"hsl(233, 80%, 47%)"
]
If this expression is applied to the backgroundColor
paint property of a layer (the "land" layer, in the example screenshot above), then the backgroundColor
would be interpolated from red to blue based on the zoom level of the map's camera.
pre-v10:
In pre-v10 versions of the Maps SDK, you would write the expression like this:
let colorStops: [NSNumber: UIColor] = [
0: .red,
14: .blue
];
layer.backgroundColor = NSExpression(format:
"mgl_interpolate:withCurveType:parameters:stops:($zoomLevel, 'linear', nil, %@)",
colorStops];
v10:
In the Maps SDK v10, a JSON expression from Mapbox Studio can be used directly or can be translated to Swift.
To use the JSON expression directly:
let expressionString =
"""
[
"interpolate",
["linear"],
["zoom"],
0,
"hsl(0, 79%, 53%)",
14,
"hsl(233, 80%, 47%)"
]
"""
if let expressionData = expressionString.data(using: .utf8) {
let expJSONObject = try JSONSerialization.jsonObject(with: expressionData, options: [])
try mapView.mapboxMap.style.setLayerProperty(for: "land",
property: "background-color",
value: expJSONObject)
}
To translate the same JSON expression to Swift:
Exp(.interpolate) {
Exp(.linear)
Exp(.zoom)
0
UIColor.red
14
UIColor.blue
}
Use the full power of Swift and the iOS runtime when defining this expression. For example, to adjust this expression based on whether the user has dark mode enabled:
var isDarkModeEnabled = traitCollection.userInterfaceStyle == .dark ? true : false
let lowZoomColor = isDarkModeEnabled? UIColor.black.expression : UIColor.red
let highZoomColor = isDarkModeEnabled? UIColor.grey.expression : UIColor.blue
Exp(.interpolate) {
Exp(.linear)
Exp(.zoom)
0
lowZoomColor
14
highZoomColor
}
Compose expressions
In v10, expressions can also be composed and built in a modular way. They can be infinitely nested or defined separately and added to the stack.
Exp(.interpolate) {
Exp(.linear)
Exp(.zoom)
Exp(.subtract) {
10
3
}
UIColor.red
Exp(.sum) {
7
7
}
UIColor.blue
}
Annotations
You can add "annotations" to the map using point, line, polygon and circle shapes with the MapView
’s AnnotationOrchestrator
. Use the AnnotationOrchestrator
to create annotation managers based on the type of annotation that you're interested in. Every annotation manager handles a collection of annotations. Once a manager has been created, you can create and add individually styled instances of the corresponding annotation type.
Annotations were also supported pre-v10 by way of MGLShape
objects that could conform to the MGLAnnotation
protocol. For example, adding a single point annotation that marks a coordinate with an image looked like this in previous versions:
pre-v10
class ViewController: UIViewController, MGLMapViewDelegate {
override func viewDidLoad() {
super.viewDidLoad()
...
mapView.delegate = self
let pointAnnotation = MGLPointAnnotation()
pointAnnotation.coordinate = CLLocationCoordinate2D(latitude: 43, longitude: 10)
mapView.addAnnotation(pisa)
}
func mapView(_ mapView: MGLMapView, imageFor annotation: MGLAnnotation) -> MGLAnnotationImage? {
var customImage = UIImage(named: "star")!
return MGLAnnotationImage(image: customImage, reuseIdentifier: "star")
}
}
In the Maps SDK v10, the delegate-based approach is no longer used, moving in favor of custom Swift structs to represent annotations that can be customized with a property-based approach.
The largest change to annotations is that they are all powered internally by style layers.
In v6, view annotations were possible with the
MGLAnnotationView
class.In v10, view annotations are only available in v10.2.0 and higher. For
more details see the View
annotations guide.
See code examples for implementing point, line, polygon and circle annotations in Maps SDK v10 below.
Point annotations
A point annotation is a marker that is placed at a developer-specified coordinate:
// Make a `PointAnnotationManager` which will be responsible for managing a collection of `PointAnnotion`s.
let pointAnnotationManager = mapView.annotations.makePointAnnotationManager()
// Initialize a point annotation with a geometry ("coordinate" in this case)
// and configure it with a custom image (sourced from the asset catalogue)
var customPointAnnotation = PointAnnotation(coordinate: customCoordinate)
// Make the annotation show a red pin. See /ios/maps/examples/point-annotation/ for a complete example.
customPointAnnotation.image = .init(image: UIImage(named: "red_pin")!, name: "red_pin")
// Add the annotation to the manager in order to render it on the mao.
pointAnnotationManager.annotations = [customPointAnnotation]
This results in a marker at the customCoordinate
specified.
The PointAnnotationManager
created above also controls the lifecycle of the annotations in its purview. Annotation managers are kept alive by the AnnotationOrchestrator
until they are removed explicitly via a call to removeAnnotationManager(withId:)
or implicitly by creating another annotation manager with the same ID.
Line annotations
A line annotation connects a list of coordinates on the map with a line. Like the PointAnnotation
example, you must create instances of LineAnnotation
s with some geometry and then add it to a LineAnnotationManager
:
// Line from New York City, NY to Washington, D.C.
let lineCoordinates = [
CLLocationCoordinate2DMake(40.7128, -74.0060),
CLLocationCoordinate2DMake(38.9072, -77.0369)
]
// Create the line annotation.
var lineAnnotation = PolylineAnnotation(lineCoordinates: lineCoordinates)
// Customize the style of the line annotation
lineAnnotation.lineColor = StyleColor(.red)
lineAnnotation.lineOpacity = 0.8
lineAnnotation.lineWidth = 10.0
// Create the PolylineAnnotationManager responsible for managing
// this line annotations (and others if you so choose)
let lineAnnnotationManager = mapView.annotations.makePolylineAnnotationManager()
// Add the annotation to the manager.
lineAnnnotationManager.annotations = [lineAnnotation]
Polygon annotations
The AnnotationManager
can also add a polygon to the map by taking a list of coordinates that it will then try to connect. You can also create a polygon with an empty space in the middle (like a doughnut) by taking a separate list of coordinates. The order of the coordinates in the list matter and should conform to the GeoJSON specification.
// Describe the polygon's geometry
let outerRingCoords = [
CLLocationCoordinate2DMake(24.51713945052515, -89.857177734375),
CLLocationCoordinate2DMake(24.51713945052515, -87.967529296875),
CLLocationCoordinate2DMake(26.244156283890756, -87.967529296875),
CLLocationCoordinate2DMake(26.244156283890756, -89.857177734375),
CLLocationCoordinate2DMake(24.51713945052515, -89.857177734375)
]
// This polygon has an intererior polygon which represents a hole in the shape.
let innerRingCoords = [
CLLocationCoordinate2DMake(25.085598897064752, -89.20898437499999),
CLLocationCoordinate2DMake(25.085598897064752, -88.61572265625),
CLLocationCoordinate2DMake(25.720735134412106, -88.61572265625),
CLLocationCoordinate2DMake(25.720735134412106, -89.20898437499999),
CLLocationCoordinate2DMake(25.085598897064752, -89.20898437499999)
]
/// Create the Polygon with the outer ring and inner ring
let outerRing = Turf.Ring(coordinates: outerRingCoords)
let innerRing = Turf.Ring(coordinates: innerRingCoords)
let polygon = Turf.Polygon(outerRing: outerRing, innerRings: [innerRing])
// Create the PolygonAnnotationManager
let polygonAnnotationManager = mapView.annotations.makePolygonAnnotationManager()
// Create the polygon annotation
var polygonAnnotation = PolygonAnnotation(polygon: makePolygon())
// Style the polygon annotation
polygonAnnotation.fillColor = StyleColor(.red)
polygonAnnotation.fillOpacity = 0.8
// Add the polygon annotation to the manager
polygonAnnotationManager.annotations = [polygonAnnotation]
Selecting annotations
Annotations can also be interacted with via a tap gesture. Ensuring that a delegate conforms to AnnotationInteractionDelegate
enables tap events:
class MyViewController: UIViewController, AnnotationInteractionDelegate {
@IBOutlet var mapView: MapView!
override func viewDidLoad() {
super.viewDidLoad()
// Create the point annotation, which will be rendered with a red pin.
let coordinate = mapView.cameraState.center
var pointAnnotation = PointAnnotation(coordinate: coordinate)
pointAnnotation.image = .init(image: UIImage(named: "red_pin")!, name: "red_pin")
// Create the point annotation manager
let pointAnnotationManager = mapView.annotations.makePointAnnotationManager()
// Allow the view controller to accept annotation selection events.
pointAnnotationManager.delegate = self
// Add the annotation to the map.
pointAnnotationManager.annotations = [pointAnnotation]
}
// MARK: - AnnotationInteractionDelegate
public func annotationManager(_ manager: AnnotationManager,
didDetectTappedAnnotations annotations: [Annotation]) {
print("Annotations tapped: \(annotations)")
}
}
View-based annotations are not supported. All point annotations must be configured with a static image.
Drag and drop support is not supported. But this can be implemented with an advanced usage of various style APIs to update the data source of a GeoJSONSource
after responding to a UIGestureRecognizer
.
User location
The User Location component provides a mechanism by which the device’s current location can be observed and responded to. It also handles permissions and privacy, including full vs. reduced accuracy (which was added in iOS 14).
Requesting permissions
Users must grant permission before an iOS app can access information about their location. During this permission prompt, a custom string may be presented explaining how location will be used. This is specified by adding the key NSLocationWhenInUseUsageDescription
to the Info.plist
file with a value that describes why the application is requesting these permissions.
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to improve the map experience.</string>
Showing the device's location
Display the device's location with the following code:
mapView.location.options.puckType = .puck2D()
The above code will enable rendering of a puck that represents the device's current location. It will also handle requesting permissions and all the nuances that come with different permission levels.
Following the device's location
v6 tracking modes have been replaced by the Viewport API (available starting in v10.3). Besides following objects on a map, it can also be extended with custom states and transitions.
For more details, see the User location guide.
Custom Location Provider
The location framework has its own default location provider which will handle location updates, permissions, status, and more. But, a custom location provider can be used instead.
LocationProvider
is a protocol, encapsulating functions and properties that will handle location updates, permission changes, and more. It notifies its delegate when changes in location, heading, authorization, or error status occur. This is like MGLLocationManager
in pre-v10 versions of the Maps SDK.
pre-v10
In pre-v10 versions of the Maps SDK, a custom location provider could be provided by making a custom MGLLocationManager
object:
class CustomLocationManager: NSObject, MGLLocationManager {
// Implement protocol required functions
}
self.mapView.locationManager = CustomLocationManager()
v10:
A custom location provider must conform to the LocationProvider
protocol and implement the required functions from the protocol. The custom provider can be passed to the LocationManager
with the following code:
mapView.location.overrideLocationProvider(with: customLocationProvider)
Handling privacy
In iOS 14, Apple introduced a new layer of privacy protection to location. There is now a new property called accuracyAuthorization
. This allows the user to select between full accuracy and reduced accuracy modes for their location tracking. When full accuracy mode is enabled, the code will function as it did in previous iOS versions. But, in reduced accuracy mode, the device will send location updates less often and will specify a large radius in which the device is located rather than a precise location.
If a user decides to turn off full accuracy location and deny temporary full accuracy, then the 2D puck will be rendered as an approximate ring, which looks like this:
To handle changes in location privacy, there are two options: let the Maps SDK handle the changes or implement the delegate.
Let the SDK handle changes in location privacy
The Maps SDK's default implementation will listen for a change in accuracy authorization. If the user has decided to turn off full accuracy location, then in the next app session, the user will be prompted with an alert to request a temporary full accuracy authorization. You can set this functionality and the contents of the dialogue in the app’s Info.plist
file. Add the key NSLocationTemporaryUsageDescriptionDictionary
and add the following key-value pair:
- Key:
LocationAccuracyAuthorizationDescription
- Value: A sentence describing why the application needs full accuracy locations
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
<key>LocationAccuracyAuthorizationDescription</key>
<string>This application temporarily requires your precise location.</string>
</dict>
Implement the location permissions delegate
The LocationPermissionsDelegate
protocol has a set of delegate methods that will be called when the user makes changes to the app's accuracy authorization.
class ViewController: UIViewController {
// This controller's initialization has been omitted in this code snippet
var mapView: MapView!
override func viewDidLoad() {
super.viewDidLoad()
mapView.location.delegate = self
}
// Selector that will be called as a result of the delegate below
func requestPermissionsButtonTapped() {
mapView.location.requestTemporaryFullAccuracyPermissions(withPurposeKey: "CustomKey")
}
}
extension ViewController: LocationPermissionsDelegate {
func locationManager(_ locationManager: LocationManager, didChangeAccuracyAuthorization accuracyAuthorization: CLAccuracyAuthorization) {
if accuracyAuthorization == .reducedAccuracy {
// Present a button on screen that asks for full accuracy
// This button can have a selector as defined above
}
}
}
This code snippet also has our recommendation for how to handle a location permission change. A withPurposeKey
parameter is specified. This value must correspond to the key of a key-value pair that was specified in Info.plist
.
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
<key>CustomKey<key>
<string>We temporarily require your precise location for an optimal experience.</string>
</dict>
Offline
Retaining your offline cache from previous Maps SDK versions
Migrating from previous versions of the Maps SDK requires the following steps to make sure that resources created in v6 are available to users in v10 as the offline database has moved.
The offline database file, which was located at /Library/Application Support/<bundle id>/.mapbox/cache.db
in v6, is now located at /Library/Application Support/.mapbox/map_data/map_data.db
. The SDK will not automatically detect the legacy database and an application level migration is necessary to move the existing database.
Below is a sample method to do this migration:
func migrateOfflineCache() {
// Old and new cache file paths
let srcURL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("/Library/Application Support/com.mapbox.examples/.mapbox/cache.db")
let destURL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("/Library/Application Support/.mapbox/map_data/map_data.db")
let fileManager = FileManager.default
do {
try fileManager.createDirectory(at: destURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
try fileManager.moveItem(at: srcURL, to: destURL)
print("Move successful")
} catch {
print("Error: \(error.localizedDescription)")
}
}
Note that this method will need to be called before the new cache is created.
New features and improvements
Platform-driven camera animation system
The Maps SDK v10 introduces a new camera animation system which leverages CoreAnimation
. Along with providing familiar high-level animations such as easeTo
or flyTo
, the SDK provides expressive new ways of controlling animations with more granularity. Support for a variety of animation curves that a transition could follow is included in the Maps SDK v10.
For example, consider a situation that necessitates zooming into the map while also rotating the map. The mapView.camera
exposes a set of powerful new makeAnimator*
functions that allow for building this in an expressive manner:
lazy var myCustomAnimator: BasicCameraAnimator = {
mapView.camera.makeAnimator(duration: 5, curve: .easeInOut) { (transition) in
// Transition the zoom level of the map's camera to `7`
transition.zoom.toValue = 7
// Transition the bearing of the map's camera to `180` degrees
transition.bearing.toValue = 180
}
}()
....
....
// Call `startAnimation()` to start the animation (at some point after the `.mapLoaded` event)
myCustomAnimator.startAnimation()
This animation will result in the following changes to the map's camera:
- The map's zoom will animate to a zoom level of
7
from the value it was beforestartAnimation()
was called. - The map's bearing will animate to
180
degrees from the value it was beforestartAnimation()
is called. - The entire animation will last for
5
seconds and will interpolate with a.easeInOut
curve.
Control camera transitions with more granularity
The transition
construct passed into every "animation" block allows you to control both where the animation completes (in other words, the final map camera) and its starting position. The snippet below illustrates this:
lazy var myCustomAnimator: BasicCameraAnimator = {
mapView.camera.makeAnimator(duration: 5, curve: .easeInOut) { (transition) in
// Transition the zoom level of the map's camera from `10` to `7`
transition.zoom.fromValue = 10
transition.zoom.toValue = 7
// Transition the bearing of the map's camera from `45` degrees to `180` degrees
transition.bearing.fromValue = 45
transition.bearing.toValue = 180
}
}()
....
....
// Call `startAnimation()` to start the animation (at some point after the `.mapLoaded` event)
myCustomAnimator.startAnimation()
This animation will result in the following changes to the map's camera:
- First, the map's zoom will change to
10
and the bearing will change to45
without an animated transition. - The map's zoom will then animate to a zoom level of
7
from an initial value of10
. - The map's bearing will animate to
180
degrees from an initial value of45
degrees. - The entire animation will last for
5
seconds and will interpolate with a.easeInOut
curve.
Chain animations
BasicCameraAnimator
also supports completion blocks with the following API:
myCustomAnimator.addCompletion { position in
print("Animation complete at position: \(position)")
}
These completion blocks can be used to chain animations to execute one after another, as in this example:
// Store the CameraAnimators so they don't fall out of scope
lazy var zoomAnimator: BasicCameraAnimator = {
let animator = mapView.camera.makeAnimator(duration: 4, curve: .easeInOut) { (transition) in
transition.zoom.toValue = 14
}
animator.addCompletion { [unowned self] (_) in
print("Animating camera pitch from 0 degrees -> 55 degrees")
self.pitchAnimator.startAnimation()
}
return animator
}()
lazy var pitchAnimator: BasicCameraAnimator = {
let animator = mapView.camera.makeAnimator(duration: 2, curve: .easeInOut) { (transition) in
transition.pitch.toValue = 55
}
animator.addCompletion { [unowned self] (_) in
print("Animating camera bearing from 0 degrees -> 45 degrees")
self.bearingAnimator.startAnimation()
}
return animator
}()
lazy var bearingAnimator: BasicCameraAnimator = {
let animator = mapView.camera.makeAnimator(duration: 4, curve: .easeInOut) { (transition) in
transition.bearing.toValue = -45
}
animator.addCompletion { (_) in
print("All animations complete!")
}
return animator
}()
// Start the zoomAnimator at some point after the `.mapLoaded` event
zoomAnimation.startAnimation()
AnimationOwner
AnimationOwner
is a struct used to keep track the source (or owner) for each animation. AnimationOwner
has the following predefined values:
gestures
: support for animations run by gestures.unspecified
: support for a non-specific animation.
Custom values may be created via AnimationOwner(rawValue:)
.
Notes on camera animation
- A
BasicCameraAnimator
's lifecycle is important. If aBasicCameraAnimator
is destroyed before an animation is complete, the animation will stop at once. CameraAnimator
s are created with a default owner of.unspecified
.- The
CameraManager
will hold a list of all active animators.
3D terrain and sky layers
In the Maps SDK v10, you can show dramatic elevation changes against an atmospheric backdrop by enabling 3D terrain and using the new sky layer.
To configure this, first add a RasterDemSource
to a map style, with a URL source pointed to Mapbox's global digital elevation model (DEM), Mapbox Terrain RGB. Then, create a Terrain
object and define the exaggeration
factor, which acts as a scale to represent extrusion. The higher the value, the more exaggerated land features at higher elevations will appear. Adding a new sky layer gives the appearance of the sun illuminating land, especially when the camera’s pitch is changed.
To enable 3D terrain and add a new sky layer:
// Add terrain
var demSource = RasterDemSource()
demSource.url = "mapbox://mapbox.mapbox-terrain-dem-v1"
demSource.tileSize = 512
demSource.maxzoom = 14.0
try mapView.mapboxMap.style.addSource(demSource, id: "mapbox-dem")
var terrain = Terrain(sourceId: "mapbox-dem")
terrain.exaggeration = .constant(1.5)
// Add sky layer
try mapView.mapboxMap.style.setTerrain(terrain)
var skyLayer = SkyLayer(id: "sky-layer")
skyLayer.skyType = .constant(.atmosphere)
skyLayer.skyAtmosphereSun = .constant([0.0, 0.0])
skyLayer.skyAtmosphereSunIntensity = .constant(15.0)
try mapView.mapboxMap.style.addLayer(skyLayer)
3D terrain and sky layers are still in an experimental state. It might not work as expected with the map camera animation system.
Custom rendered layers
For more advanced rendering operations, the Maps SDK v10 supports inserting custom Metal layers within the map’s style. This unlocks many new opportunities for customization and is specifically tailored to those looking to add their own custom rendering code. For an implementation demo, see the “Add a custom rendered layer” example within the Examples application for more details.
New OfflineManager
v10 introduces a new OfflineManager
API that manages style packs and produces tileset descriptors for the tile store. Read more about OfflineManager
in the Offline guide.
The OfflineManager
API can be used to create offline style packs that contain style data, such as style definition, sprites, fonts and other resources. Tileset descriptors created by the OfflineManager
API are used to create tile packs via TileStore
API. Mobile maps SDKs use tile packs for rendering map content.
Deprecations and removals
- Expression support using
NSExpression
has been removed. - OpenGL map rendering has been removed in favor of Metal.
- Storing your access token in the application's
Info.plist
is discouraged. - The legacy
OfflineManager
has been deprecated and renamed toOfflineRegionManager
.