Skip to content

Commit a81ebe1

Browse files
OdNairyjohnhammerlund
authored andcommitted
Expand DSL capabilities of ExpressionArgumentBuilder (#6769)
Original PR: - #2355 Resolves MAPSIOS-413 `ExpressionArgumentBuilder` currently only supports the basic `buildBlock(...)` method, but not loop expressions or conditional expressions. There are cases where these can simplify logic, reduce constants, etc. For example, a `SymbolLayer` may have a large matrix of icons: ```swift let iconImageExp = Exp(.match) { "make" "ford" Exp(.match) { "color" "red" "red-ford-icon" "green" "green-ford-icon" } "chevy" Exp(.match) { ... } } ``` We could instead leverage Swift a bit more: ```swift let iconImageExp = Exp(.match) { "make" for make in CarMake.allCases { make.featureValue Exp(.match) { "color" for color in CarColor.allCases { color.featureValue make.iconID(color: color) } } } } ``` The second example is not currently possible. This PR adds support for loops/arrays as well as conditionals. Some feature examples have been updated to leverage this, but happy to revert that if desired. ## Pull request checklist: - [x] Describe the changes in this PR, especially public API changes. - [ ] Include before/after visuals or gifs if this PR includes visual changes. <!-- | Before | After | | ----- | ----- | | <img src="" width = 250/> | <img src="" width = 250/> | or | <video src="" width = 250/> | <video src="" width = 250/> | --> - [x] Write tests for all new functionality. Put tests in correct [Test Plan](https://github.com/mapbox/mapbox-maps-ios/tree/main/Tests/TestPlans) (Unit, Integration, All) - [ ] If tests were not written, please explain why. - [ ] Add documentation comments for any added or updated public APIs. - [ ] Add any new public, top-level symbols to the DocC custom catatlog (Sources/MapboxMaps/Documentation.docc/API Catalogs) - [ ] Add a changelog entry to to bottom of the relevant section (typically the `## main` heading near the top). - [ ] Update the guides (internal access only), README.md, and DEVELOPING.md if their contents are impacted by these changes. - [ ] If this PR is a `v10.[version]` release branch fix / enhancement, merge it to `main` first and then port to `v10.[version]` release branch. PRs must be submitted under the terms of our Contributor License Agreement [CLA](https://github.com/mapbox/mapbox-maps-ios/blob/main/CONTRIBUTING.md#contributor-license-agreement). cc @mapbox/maps-ios cc @mapbox/sdk-ci --------- Co-authored-by: John Hammerlund <[email protected]> GitOrigin-RevId: 1a2bb8e04b44dadf8e1bd901b8b9e6429865daad
1 parent 008f3ed commit a81ebe1

File tree

6 files changed

+236
-74
lines changed

6 files changed

+236
-74
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Mapbox welcomes participation and contributions from everyone.
77
* Added support for `LandmarkIcons` featureset in Mapbox Standard Style. The `LandmarkIcons` featureset allows querying and configuring landmark building icons that appear on the map. Access landmark properties including landmarkId, name, type, and localized names through the `StandardLandmarkIconsFeature` class.
88
* Enhanced `MapStyle.standard()` and `MapStyle.standardSatellite()` with new configuration parameters for color customization, landmark icons visibility, point-of-interest styling, road appearance, and administrative boundaries.
99
* Expose `LineLayer.lineCutoutFadeWidth` to control route line cutout fade width.
10+
* Expanded Expression DSL capabilities with support for control flow constructs in `ExpressionArgumentBuilder`. Now supports `if/else` conditions, `for` loops, optionals, and `#available` checks within expression builders. This allows more natural and readable expression construction, reducing boilerplate code when building complex map styling expressions.
1011

1112
## 11.15.0 - 11 September, 2025
1213

STYLE_README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,77 @@ Exp(.interpolate) {
118118
}
119119
```
120120

121+
#### Control Flow in Expression DSL
122+
123+
The Expression DSL supports advanced Swift control flow constructs, making it easier to build complex expressions dynamically:
124+
125+
**For loops** - Generate multiple expression arguments from collections:
126+
```swift
127+
let zoomLevels: [Double] = [0, 5, 10, 15, 20]
128+
let colors = [UIColor.red, UIColor.orange, UIColor.yellow, UIColor.green, UIColor.blue]
129+
130+
Exp(.interpolate) {
131+
Exp(.linear)
132+
Exp(.zoom)
133+
134+
for (level, color) in zip(zoomLevels, colors) {
135+
level
136+
color
137+
}
138+
}
139+
```
140+
141+
**Conditional expressions** - Use if/else to conditionally include arguments:
142+
```swift
143+
let isDarkMode = traitCollection.userInterfaceStyle == .dark
144+
145+
Exp(.interpolate) {
146+
Exp(.linear)
147+
Exp(.zoom)
148+
149+
if isDarkMode {
150+
0
151+
UIColor.black
152+
} else {
153+
0
154+
UIColor.white
155+
}
156+
157+
10
158+
UIColor.blue
159+
}
160+
```
161+
162+
**Optional handling** - Work with optional values seamlessly:
163+
```swift
164+
let optionalColor: UIColor? = someCondition ? UIColor.red : nil
165+
166+
Exp(.interpolate) {
167+
Exp(.linear)
168+
Exp(.zoom)
169+
170+
if let color = optionalColor {
171+
color
172+
}
173+
174+
0
175+
UIColor.blue
176+
}
177+
```
178+
179+
**Availability checks** - Include platform-specific logic:
180+
```swift
181+
Exp(.interpolate) {
182+
Exp(.linear)
183+
Exp(.zoom)
184+
185+
if #available(iOS 13.0, *) {
186+
UIColor.systemBackground
187+
} else {
188+
UIColor.white
189+
}
190+
}
191+
```
192+
121193
#### Other Expression Examples
122194
- This (example)[Sources/Examples/All%20Examples/DataDrivenSymbolsExample.swift#L74] highlights the use of a match, and a switchcase expression

Sources/Examples/All Examples/DistanceExpressionExample.swift

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -44,43 +44,17 @@ final class DistanceExpressionExample: UIViewController, ExampleProtocol {
4444
// This expression simulates a `CircleLayer` with a radius of 150 meters. For features that will be
4545
// visible at lower zoom levels, add more stops at the zoom levels where the feature will be more
4646
// visible. This keeps the circle's radius more consistent.
47+
48+
let radii: [Double] = [0, 5, 10, 15, 16, 16.5, 17, 17.5, 18, 18.5, 19, 19.5, 20, 20.5, 21, 21.5, 22]
49+
4750
let circleRadiusExp = Exp(.interpolate) {
4851
Exp(.linear)
4952
Exp(.zoom)
50-
0
51-
circleRadius(forZoom: 0)
52-
5
53-
circleRadius(forZoom: 5)
54-
10
55-
circleRadius(forZoom: 10)
56-
15
57-
circleRadius(forZoom: 15)
58-
16
59-
circleRadius(forZoom: 16)
60-
16.5
61-
circleRadius(forZoom: 16.5)
62-
17
63-
circleRadius(forZoom: 17)
64-
17.5
65-
circleRadius(forZoom: 17.5)
66-
18
67-
circleRadius(forZoom: 18)
68-
18.5
69-
circleRadius(forZoom: 18.5)
70-
19
71-
circleRadius(forZoom: 19)
72-
19.5
73-
circleRadius(forZoom: 19.5)
74-
20
75-
circleRadius(forZoom: 20)
76-
20.5
77-
circleRadius(forZoom: 20.5)
78-
21
79-
circleRadius(forZoom: 21)
80-
21.5
81-
circleRadius(forZoom: 21.5)
82-
22
83-
circleRadius(forZoom: 22)
53+
54+
for radius in radii {
55+
radius
56+
circleRadius(forZoom: radius)
57+
}
8458
}
8559
circleLayer.circleRadius = .expression(circleRadiusExp)
8660
circleLayer.circleOpacity = .constant(0.3)

Sources/Examples/All Examples/FeatureStateExample.swift

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import UIKit
22
import MapboxMaps
33

44
final class FeatureStateExample: UIViewController, ExampleProtocol {
5+
private struct EarthquakeThreshold {
6+
let magnitude: Double
7+
let colorHEX: String
8+
let radius: Double
9+
}
10+
511
private var mapView: MapView!
612
private var descriptionView: EarthquakeDescriptionView!
713
private var selectedFeature: FeaturesetFeature?
@@ -16,6 +22,19 @@ final class FeatureStateExample: UIViewController, ExampleProtocol {
1622
return dateFormatter
1723
}()
1824

25+
private let earthquakeThresholds = [
26+
EarthquakeThreshold(magnitude: 1, colorHEX: "#fff7ec", radius: 8),
27+
EarthquakeThreshold(magnitude: 1.5, colorHEX: "#fee8c8", radius: 10),
28+
EarthquakeThreshold(magnitude: 2, colorHEX: "#fdd49e", radius: 12),
29+
EarthquakeThreshold(magnitude: 2.5, colorHEX: "#fdbb84", radius: 14),
30+
EarthquakeThreshold(magnitude: 3, colorHEX: "#fc8d59", radius: 16),
31+
EarthquakeThreshold(magnitude: 3.5, colorHEX: "#ef6548", radius: 18),
32+
EarthquakeThreshold(magnitude: 4.5, colorHEX: "#d7301f", radius: 20),
33+
EarthquakeThreshold(magnitude: 6.5, colorHEX: "#b30000", radius: 22),
34+
EarthquakeThreshold(magnitude: 8.5, colorHEX: "#7f0000", radius: 24),
35+
EarthquakeThreshold(magnitude: 10.5, colorHEX: "#000", radius: 26)
36+
]
37+
1938
override func viewDidLoad() {
2039
super.viewDidLoad()
2140

@@ -96,26 +115,11 @@ final class FeatureStateExample: UIViewController, ExampleProtocol {
96115
Exp(.interpolate) {
97116
Exp(.linear)
98117
Exp(.get) { "mag" }
99-
1
100-
8
101-
1.5
102-
10
103-
2
104-
12
105-
2.5
106-
14
107-
3
108-
16
109-
3.5
110-
18
111-
4.5
112-
20
113-
6.5
114-
22
115-
8.5
116-
24
117-
10.5
118-
26
118+
119+
for threshold in earthquakeThresholds {
120+
threshold.magnitude
121+
threshold.radius
122+
}
119123
}
120124
5
121125
}
@@ -136,26 +140,11 @@ final class FeatureStateExample: UIViewController, ExampleProtocol {
136140
Exp(.interpolate) {
137141
Exp(.linear)
138142
Exp(.get) { "mag" }
139-
1
140-
"#fff7ec"
141-
1.5
142-
"#fee8c8"
143-
2
144-
"#fdd49e"
145-
2.5
146-
"#fdbb84"
147-
3
148-
"#fc8d59"
149-
3.5
150-
"#ef6548"
151-
4.5
152-
"#d7301f"
153-
6.5
154-
"#b30000"
155-
8.5
156-
"#7f0000"
157-
10.5
158-
"#000"
143+
144+
for threshold in earthquakeThresholds {
145+
threshold.magnitude
146+
threshold.colorHEX
147+
}
159148
}
160149
"#000"
161150
}

Sources/MapboxMaps/Style/Types/ExpressionArgumentBuilder.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,52 @@ public struct ExpressionArgumentBuilder {
1010
public static func buildBlock(_ arguments: ExpressionArgumentConvertible...) -> [Exp.Argument] {
1111
return arguments.flatMap { $0.expressionArguments }
1212
}
13+
14+
/// Builds an optional expression argument.
15+
/// Enables support for optional values in Expression DSL, allowing `if let` and `guard let` constructs.
16+
///
17+
/// - Parameter component: An optional expression argument convertible value
18+
/// - Returns: Array of expression arguments, empty if component is nil
19+
public static func buildOptional(_ component: ExpressionArgumentConvertible?) -> [Exp.Argument] {
20+
return component?.expressionArguments ?? []
21+
}
22+
23+
/// Builds expression arguments for the first branch of a conditional.
24+
/// Enables support for `if/else` constructs in Expression DSL.
25+
///
26+
/// - Parameter argument: Expression argument from the first conditional branch
27+
/// - Returns: Array of expression arguments
28+
public static func buildEither(first argument: ExpressionArgumentConvertible) -> [Exp.Argument] {
29+
return argument.expressionArguments
30+
}
31+
32+
/// Builds expression arguments for the second branch of a conditional.
33+
/// Enables support for `if/else` constructs in Expression DSL.
34+
///
35+
/// - Parameter argument: Expression argument from the second conditional branch
36+
/// - Returns: Array of expression arguments
37+
public static func buildEither(second argument: ExpressionArgumentConvertible) -> [Exp.Argument] {
38+
return argument.expressionArguments
39+
}
40+
41+
/// Builds expression arguments from an array of convertible values.
42+
/// Enables support for `for` loops in Expression DSL, allowing iteration over collections
43+
/// to generate multiple expression arguments dynamically.
44+
///
45+
/// - Parameter arguments: Array of expression argument convertible values
46+
/// - Returns: Flattened array of expression arguments
47+
public static func buildArray(_ arguments: [ExpressionArgumentConvertible]) -> [Exp.Argument] {
48+
return arguments.flatMap { $0.expressionArguments }
49+
}
50+
51+
/// Builds expression arguments with limited availability checks.
52+
/// Enables support for `#available` constructs in Expression DSL.
53+
///
54+
/// - Parameter argument: Expression argument from availability-guarded code
55+
/// - Returns: Array of expression arguments
56+
public static func buildLimitedAvailability(_ argument: ExpressionArgumentConvertible) -> [Exp.Argument] {
57+
return argument.expressionArguments
58+
}
1359
}
1460

1561
/// :nodoc:
@@ -67,6 +113,8 @@ extension Array: ExpressionArgumentConvertible {
67113
return [.stringArray(validStringArray)]
68114
} else if let validNumberArray = self as? [Double] {
69115
return [.numberArray(validNumberArray)]
116+
} else if let argumentArray = self as? [Exp.Argument] {
117+
return argumentArray
70118
} else {
71119
Log.warning("Unsupported array provided to Expression. Only [String] and [Double] are supported.", category: "Expressions")
72120
return []

Tests/MapboxMapsTests/Style/ExpressionTests/ExpressionTests.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,84 @@ final class ExpressionTests: XCTestCase {
327327
XCTAssertNotEqual(shortFormExpression, longFormExpression)
328328
}
329329

330+
func testExpressionArgumentResultBuilder() throws {
331+
let values: [Double] = [1, 2, 3, 4]
332+
let eitherFirst: String? = "eitherFirst"
333+
let eitherSecond = "eitherSecond"
334+
let optional: String? = "optional"
335+
let limitedAvailability = "limitedAvailability"
336+
337+
let writtenOutExpression = Exp(.match) {
338+
"foo"
339+
340+
"eitherFirst"
341+
1
342+
343+
eitherSecond
344+
2
345+
346+
"optional"
347+
3
348+
349+
limitedAvailability
350+
4
351+
352+
"array"
353+
Exp(.interpolate) {
354+
Exp(.linear)
355+
Exp(.zoom)
356+
values[0]
357+
values[0]
358+
values[1]
359+
values[1]
360+
values[2]
361+
values[2]
362+
values[3]
363+
values[3]
364+
}
365+
}
366+
367+
let dslExpression = Exp(.match) {
368+
"foo"
369+
370+
if let eitherFirst {
371+
eitherFirst
372+
} else {
373+
"failure"
374+
}
375+
1
376+
377+
if false {
378+
"failure"
379+
} else {
380+
eitherSecond
381+
}
382+
2
383+
384+
if let optional {
385+
optional
386+
}
387+
3
388+
389+
if #available(iOS 13, *) {
390+
limitedAvailability
391+
}
392+
4
393+
394+
"array"
395+
Exp(.interpolate) {
396+
Exp(.linear)
397+
Exp(.zoom)
398+
for value in values {
399+
value
400+
value
401+
}
402+
}
403+
}
404+
405+
XCTAssertEqual(writtenOutExpression, dslExpression)
406+
}
407+
330408
func testEncodeImageOptions() throws {
331409
let color = StyleColor(UIColor.black)
332410
let empty = ImageOptions([:])

0 commit comments

Comments
 (0)