Declarative Map Styling
Starting from version 11.4.0
of Mapbox Maps SDK you can now use a declarative approach to add or update style primitives such as Sources, Layers, Images, and Lights at runtime. This paradigm simplifies working with all Mapbox styles at runtime and works well in SwiftUI and UIKit applications.
Getting Started
In UIKit applications you can use the setMapStyleContent(content:)
method, which is available in MapboxMap
and Snapshotter
instances. In SwiftUI applications, place the style primitives inside the Map
content:
Map {
VectorSource(id: "traffic")
.tiles(["..."])
LineLayer(id: "traffic-layer", source: "traffic")
.lineColor(.red)
}
let mapView = MapView()
mapView.mapboxMap.setMapStyleContent {
VectorSource(id: "traffic-source")
.tiles(["..."])
LineLayer(id: "traffic-layer", source: "traffic-source")
.lineColor(.red)
}
You don't need to observe any events to modify the map style. Changes will be automatically applied when the map style is loaded.
Styling Primitives
Style primitives are pieces of MapStyleContent
that can be added, updated, and removed at runtime. These primitives represent the data sources and visualization choices for your map:
Category | Types supported |
---|---|
Source | VectorSource , RasterSource , RasterDemSource , GeoJSONSource , ImageSource , Model , CustomGeometrySource , CustomRasterSource |
Layer | FillLayer , LineLayer , SymbolLayer , CircleLayer , HeatmapLayer , FillExtrusionLayer , RasterLayer , HillshadeLayer , BackgroundLayer , LocationIndicatorLayer , SkyLayer , ModelLayer , SlotLayer , CustomLayer , ClipLayer , RasterParticleLayer |
Lights | FlatLight , AmbientLight , DirectionalLight |
Map properties | Projection , Atmosphere , Terrain , TransitionOptions |
Fragments | StyleImport |
All can be used inside of MapStyleContentBuilder
(UIKit) or MapContentBuilder
(SwiftUI). You can use declarative syntax to change the properties of your style primitives when you add them to your map. If you update these properties the changes will be reflected on your map.
/// Create an atmosphere primitive and set properties for range, start intensity, and color
Atmosphere()
.range(start: 0, end: 12)
.horizonBlend(0.1)
.starIntensity(0.2)
.color(StyleColor(red: 240, green: 196, blue: 152, alpha: 1)!)
.highColor(StyleColor(red: 221, green: 209, blue: 197, alpha: 1)!)
.spaceColor(StyleColor(red: 153, green: 180, blue: 197, alpha: 1)!)
Using Style Primitives Conditionally
You can also use conditionals to add and remove map style primitives. In the example below, useTerrain
is a variable controlled by your code, perhaps toggled when a user selects a button or changes a setting. When true, your code sets the Style Projection to Globe, adds terrain data to a ‘RasterDem’ source, and visualizes it as Terrain map content. When toggled false, it removes the terrain source and content, and switches the projection to Mercator. Other content on the map is not affected by this change so updates are lightweight.
swift
@State var useTerrain = true
var body: some View {
Map {
if useTerrain {
StyleProjection(name: .globe)
RasterDemSource(id: "mapbox-dem")
.url("mapbox://mapbox.mapbox-terrain-dem-v1")
.maxzoom(14.0)
Terrain(sourceId: "mapbox-dem")
.exaggeration(5)
} else {
StyleProjection(name: .mercator)
}
}
}
var useTerrain = true
// To change your style content you should set new map primitives using
// 'StyleManager.setMapStyleContent(content:)' like below.
// This will fully change the displayed style content,
// so be sure to include all the content you want.
func updateStyle(useTerrain: Bool) {
mapView.mapboxMap.setMapStyleContent {
if useTerrain {
StyleProjection(name: .globe)
RasterDemSource(id: "mapbox-dem")
.url("mapbox://mapbox.mapbox-terrain-dem-v1")
.maxzoom(14.0)
Terrain(sourceId: "mapbox-dem")
.exaggeration(5)
} else {
StyleProjection(name: .mercator)
}
}
}
It’s totally fine to call the setMapStyleContent(content:)
method each time you need to update part of the content. The method applies updates incrementally, so it skips elements that haven’t changed instead of re-adding them. If you run into a performance issue, read Performance Optimization section.
Building Custom Style Components
You can create your own style components besides built-in styling primitives. Defining your own components gives you greater control over when the content is updated, and allows for cleaner organization of your code.
If you are familiar with SwiftUI principles, the custom style components follow the same pattern.
To create a custom style component, declare a new struct which conforms to MapStyleContent
and add your primitives to the body
property.
/// A custom style component
struct CarModel: MapStyleContent {
var body: some MapStyleContent {
GeoJSONSource(id: "models-geojson")
.data(.featureCollection(carFeatureCollection))
Model(
id: "car",
uri: Bundle.main.url(forResource: "sportcar", withExtension: "glb")!
)
ModelLayer(id: "models", source: "models-geojson")
.modelId(Exp(.get) { "model" })
.modelType(.common3d)
.modelScale([40, 40, 40])
.modelTranslation([0, 0, 0])
.modelRotation([0, 0, 90])
.modelOpacity(0.7)
}
}
/// Usage:
Map {
CarModel()
}
The code above creates a CarModel
component which in turn contains all primitives to display a sport care Model on your map: the GeoJSONSource
for the data, the Model
to display, and the ModelLayer
used to position the model. Add your CarModelPrimitive
to your style body like any other Mapbox style primitive.
We don't recommend to use @State
or @Binding
in your custom component. Instead, use @State
in the SwiftUI views and pass data to custom components as properties.
Content positioning
One of the most important properties of declarative styling is stable content ordering. Essentially, this means that all layers defined in the declarative description will be positioned on the map relative to each other, following a similar pattern as SwiftUI's ZStack
.
let coordinate = CLLocationCoordinate2D(latitude: 60.167488, longitude: 24.942747)
var body: some View {
Map(initialViewport: .camera(center: .init(latitude: 27.2, longitude: -26.9), zoom: 1.53, bearing: 0, pitch: 0)) {
MapViewAnnotation(coordinate: coordinate) {
Circle()
.fill(.purple)
.frame(width: 40, height: 40)
}
if showYellowPolygon {
PolygonAnnotation(polygon: Polygon(center: coordinate, radius: 8 * 100, vertices: 60))
.fillColor(StyleColor(.yellow))
}
GeoJSONSource(id: "source")
.data(.geometry(.polygon(Polygon(center: coordinate, radius: 4 * 100, vertices: 60))))
/// The green polygon is displayed on top of the yellow polygon.
FillLayer(id: "green", source: "source")
.fillColor(.green)
.fillOpacity(0.7)
}
}
In the example above, the position will be the following:
Basemap (Standard Style layers) <-- bottom
Yellow polygon
Green Polygon
Purple circle <-- top
The purple circle displayed on top of all content because View Annotations are always rendered on top of the Map
view. The yellow polygon is always displayed below the green one, even if the showYellowPolygon
property was toggled.
In the declarative API there's no LayerPosition
to override the positioning. Instead, use new SlotLayer
and Slot
constants to have a finer control.
For example, the Standard Style is shipped with predefined slots, such as middle
:
Map {
/// The green polygon is displayd in the middle of Standard Style layers.
FillLayer(id: "green", source: "green-source")
.fillColor(.green)
.slot(.middle)
/// By default, this layer id displayed on top of other layers.
FillLayer(id: "purple", source: "purple-source")
.fillColor(.purple)
}
Using the custom slots let you alter the ordering:
Map {
SlotLayer(id: "my-custom-slot")
FillLayer(id: "green", source: "green-source")
.fillColor(.green)
FillLayer(id: "purple", source: "purple-source")
.fillColor(.purple)
.slot("my-custom-slot")
}
In the example above, the purple layer will be displayed below the green one, because it is assigned to the custom my-custom-slot
that comes before the green polygon.
In general, the ordering rule is as follows:
- First, the slot matters the most. All layers on the map are ordered by their slot position.
- If there is more than one layer in the slot, they are ordered following the order in the code.
Using Layer Position
In some rare use-cases you may want to use LayerPosition
with declarative styling. The SlotLayer
is the only layer that has layer position in the declarative styling API. This effectively resolves the scenario where you need to insert a runtime-added layer between style layers that are part of the Style JSON.
Note that setting both slot(_:)
and position(_:)
in SlotLayer
is incorrect and the slot
will always have priority over the position
.
Map {
/// The "roads" layer id comes from the Streets Style.
SlotLayer(id: "below-roads")
.position(.below("roads"))
FillLayer(id: "square", source: "square-data")
.fillColor(.systemPink)
.slot(Slot(rawValue: "below-roads")
}
.mapStyle(.streets)
In the example above the FillLayer
will be placed below the roads
layer from the Streets Style.
Performance Optimization
To optimize performance, break out MapStyleContent
or MapContent
into smaller components to localize updates.
This is because whenever you update the MapStyleContent
(via setMapStyleContent(content:)
) or MapContent
(via SwiftUI @State
change) internally the full content tree must be compared with its previous version to do the most efficient update. Having a larger tree of components instead a flat list makes internal caching to be more efficient.
This is important if you use a large JSON arrays inside of styling primitives. Lets consider the following example:
struct MyView: View {
@State var features: FeatureCollection?
@State var counter = 0
var body: some View {
Map {
if let features {
LineLayer(id: "route", source: "route-source")
GeoJSONSource(id: "route-source")
/// WARNING: This code may be not optimal for large feature collection.
.data(.featureCollection(features))
}
}
.onAppear {
features = loadHeavyGeojson()
}
Button("Trigger update \(counter)") {
/// Counter updates here will trigger the `body` re-evaluation. This in turn
/// triggers the Map update that need to check if the `features` are changed.
/// This may be a costly operation.
counter += 1
}
}
}
This code may not be optimal, because on every View.body
re-evaluation, the large feature set needs to be re-evaluated too.
To optimize it, create a custom component that will consume data by a reference type instead. In the example below, the RouteComponent.body
will be re-evaluated only when the actual RouteData
object is changed. If only the counter
is updated, the large route JSON won't be re-evaluated.
struct MyView: View {
/// A simple reference wrapper around FeatureCollection.
private class RouteData {
let features: FeatureCollection
init(features: FeatureCollection) { self.features = features }
}
/// A custom route component that renders the line layer and creates the data source for it.
private struct RouteComponent: MapContent {
/// The `body` is called only when component's properties are changed.
/// In this case the `route` is a reference type, which guarantees the fast equality check.
let route: RouteData
var body: some MapContent {
LineLayer(id: "route", source: "route-source")
GeoJSONSource(id: "route-source")
.data(.featureCollection(route.features))
}
}
@State private var routeData: RouteData?
@State private var counter = 0
var body: some View {
Map {
if let routeData {
RouteComponent(route: routeData)
}
}
.onAppear {
routeData = RouteData(features: loadHeavyGeojson())
}
Button("Trigger update \(counter)") {
/// Update of the counter state won't trigger the RouteComponent.body evaluation because
/// routeData points to the same data.
counter += 1
}
Button("Update the route") {
/// This will correctly update the rendered route, only once.
routeData = RouteData(features: loadANewVersionOfRoute())
}
}
}
For reference, see the DynamicStylingExample
in the Examples application.