Skip to content

Commit 994f9dd

Browse files
evil159natiginfo
authored andcommitted
feature state expression API (#8049)
This PR exposes feature state expression API for Maps SDK Android and iOS. https://mapbox.atlassian.net/browse/MAPSIOS-2032 https://mapbox.atlassian.net/browse/MAPSAND-2418 cc @mapbox/maps-ios cc @mapbox/maps-android cc @mapbox/sdk-ci --------- Co-authored-by: Natig Babayev <[email protected]> GitOrigin-RevId: bdcebd8d5c0e05d4c412d21f36be17e2e89d452a
1 parent 09d2928 commit 994f9dd

File tree

8 files changed

+287
-4
lines changed

8 files changed

+287
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Mapbox welcomes participation and contributions from everyone.
1010
* Add `GeoJSONSource.minZoom` property.
1111
* Add `RasterArraySource.volatile` experimental property.
1212
* Make `line-emissive-strength` property data-driven.
13+
* Add experimental `MapboxMap.setFeatureStateExpression()`, `removeFeatureStateExpression()`, and `resetFeatureStateExpressions()` APIs to efficiently update feature state for multiple features at once using expressions.
1314

1415
## 11.17.0-beta.1 - 05 November, 2025
1516

Sources/Examples/SwiftUI Examples/StandardInteractiveBuildingsExample.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import SwiftUI
33

44
struct StandardInteractiveBuildingsExample: View {
55
@State var selectedBuildings = [StandardBuildingsFeature]()
6+
@State var shouldSelectAllBuildings: Bool = false
67
@State var lightPreset = StandardLightPreset.day
78
@State var theme = StandardTheme.default
89
@State var buildingSelectColor = StyleColor("hsl(214, 94%, 59%)") // default color
@@ -15,6 +16,10 @@ struct StandardInteractiveBuildingsExample: View {
1516
FeatureState(building, .init(select: true))
1617
}
1718

19+
if shouldSelectAllBuildings {
20+
FeatureState(.standardBuildings, expression: Exp(.boolean) { true }, state: .init(select: true))
21+
}
22+
1823
/// When the user taps the building, it is added to the list of selected buildings.
1924
TapInteraction(.standardBuildings) { building, _ in
2025
self.selectedBuildings.append(building)
@@ -23,6 +28,7 @@ struct StandardInteractiveBuildingsExample: View {
2328

2429
/// Tapping anywhere away from a 3D building will deselect previously selected buildings.
2530
TapInteraction { _ in
31+
shouldSelectAllBuildings = false
2632
selectedBuildings.removeAll()
2733
return true
2834
}
@@ -41,6 +47,9 @@ struct StandardInteractiveBuildingsExample: View {
4147
Text("Yellow").tag(StyleColor("yellow"))
4248
Text("Red").tag(StyleColor(.red))
4349
}.pickerStyle(.segmented)
50+
Button("Select all") {
51+
shouldSelectAllBuildings = true
52+
}
4453
}
4554
HStack {
4655
Text("Light")

Sources/MapboxMaps/ContentBuilders/MapContent/MountedFeatureState.swift

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,44 @@ final class MountedFeatureState<T: FeaturesetFeatureType>: MapContentMountedComp
1010
}
1111

1212
func mount(with context: MapContentNodeContext) throws {
13-
guard let featureId = state.featureId else {
14-
return
13+
if let featureId = state.featureId {
14+
context.content?.mapboxMap.value?.setFeatureState(
15+
featureset: state.featureset,
16+
featureId: featureId,
17+
state: state.state,
18+
callback: { _ in }
19+
)
20+
}
21+
22+
if let expression = state.expression {
23+
Task {
24+
try await context.content?.mapboxMap.value?.setFeatureStateExpression(
25+
expressionId: UInt(bitPattern: expression.description.hashValue),
26+
featureset: state.featureset,
27+
expression: expression,
28+
state: state.state
29+
)
30+
}
1531
}
16-
context.content?.mapboxMap.value?.setFeatureState(featureset: state.featureset, featureId: featureId, state: state.state, callback: { _ in })
1732
}
1833

1934
func unmount(with context: MapContentNodeContext) throws {
35+
try unmountSingleFeature(with: context)
36+
try unmountExpression(with: context)
37+
}
38+
39+
private func unmountExpression(with context: MapContentNodeContext) throws {
40+
guard let expression = state.expression else {
41+
return
42+
}
43+
Task {
44+
try await context.content?.mapboxMap.value?.removeFeatureStateExpression(
45+
expressionId: UInt(bitPattern: expression.description.hashValue)
46+
)
47+
}
48+
}
49+
50+
private func unmountSingleFeature(with context: MapContentNodeContext) throws {
2051
guard let featureId = state.featureId else {
2152
return
2253
}

Sources/MapboxMaps/Foundation/MapboxMap.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ protocol MapboxMapProtocol: AnyObject {
7878
stateKey: T.StateKey?,
7979
callback: ((Error?) -> Void)?
8080
) -> Cancelable
81+
@MainActor
82+
func setFeatureStateExpression<T: FeaturesetFeatureType>(
83+
expressionId: UInt,
84+
featureset: FeaturesetDescriptor<T>,
85+
expression: Exp,
86+
state: T.State
87+
) async throws
88+
@MainActor
89+
func removeFeatureStateExpression(expressionId: UInt) async throws
8190
var screenCullingShape: [CGPoint] { get set }
8291
}
8392

@@ -1666,6 +1675,75 @@ extension MapboxMap {
16661675
concreteErrorType: MapError.self))
16671676

16681677
}
1678+
1679+
/// Sets a feature state expression that applies to features within the specified featureset.
1680+
///
1681+
/// All feature states with expressions that evaluate to true will be applied to the feature.
1682+
/// Feature states from later added feature state expressions have higher priority. Regular feature states have higher priority than feature state expressions.
1683+
/// The final feature state is determined by applying states in order from lower to higher priority. As a result, multiple expressions that set states with different keys can affect the same features simultaneously.
1684+
/// If an expression is added for a feature set, properties from that feature set are used, not the properties from original sources.
1685+
///
1686+
/// Note that updates to feature state expressions are asynchronous, so changes made by this method might not be
1687+
/// immediately visible and will have some delay. The displayed data will not be affected immediately.
1688+
///
1689+
/// - Parameters:
1690+
/// - expressionId: Unique identifier for the state expression.
1691+
/// - featureset: The featureset descriptor that specifies which featureset the expression applies to.
1692+
/// - expression: The expression to evaluate for the state. Should return boolean.
1693+
/// - state: The `state` object with properties to update with their respective new values.
1694+
@_spi(Experimental)
1695+
@MainActor
1696+
public func setFeatureStateExpression<T: FeaturesetFeatureType>(
1697+
expressionId: UInt,
1698+
featureset: FeaturesetDescriptor<T>,
1699+
expression: Exp,
1700+
state: T.State
1701+
) async throws {
1702+
guard let stateJson = encodeState(state) else {
1703+
throw MapError(coreError: "Failed to encode feature state")
1704+
}
1705+
guard let expressionDict = expression.asCore else {
1706+
throw MapError(coreError: "Failed to encode expression")
1707+
}
1708+
1709+
try await handleExpectedOnMain { callback in
1710+
__map.__setFeatureStateExpressionForFeatureStateExpressionId(UInt64(expressionId),
1711+
featureset: featureset.core,
1712+
expression: expressionDict,
1713+
state: stateJson,
1714+
callback: callback)
1715+
}
1716+
}
1717+
1718+
/// Removes a specific feature state expression.
1719+
///
1720+
/// Remove a specific expression from the feature state expressions based on the expression ID.
1721+
///
1722+
/// Note that updates to feature state expressions are asynchronous, so changes made by this method might not be
1723+
/// immediately visible and will have some delay.
1724+
///
1725+
/// - Parameters:
1726+
/// - featureStateExpressionId: The unique identifier of the expression to remove.
1727+
@_spi(Experimental)
1728+
@MainActor
1729+
public func removeFeatureStateExpression(expressionId: UInt) async throws {
1730+
try await handleExpectedOnMain { callback in
1731+
__map.__removeFeatureStateExpression(forFeatureStateExpressionId: UInt64(expressionId),
1732+
callback: callback)
1733+
}
1734+
}
1735+
1736+
/// Reset all feature state expressions.
1737+
///
1738+
/// Note that updates to feature state expressions are asynchronous, so changes made by this method might not be
1739+
/// immediately visible and will have some delay.
1740+
@_spi(Experimental)
1741+
@MainActor
1742+
public func resetFeatureStateExpressions() async throws {
1743+
try await handleExpectedOnMain { callback in
1744+
__map.__resetFeatureStateExpressions(forCallback: callback)
1745+
}
1746+
}
16691747
}
16701748

16711749
// MARK: - View Annotations

Sources/MapboxMaps/Interactions/FeatureState.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
public struct FeatureState<T: FeaturesetFeatureType>: Equatable, MapContent, PrimitiveMapContent {
33
var featureset: FeaturesetDescriptor<T>
44
var featureId: FeaturesetFeatureId?
5+
var expression: Exp?
56
var state: T.State
67

78
/// Sets the feature state using typed descriptor and feature id.
@@ -16,6 +17,12 @@ public struct FeatureState<T: FeaturesetFeatureType>: Equatable, MapContent, Pri
1617
self.state = state
1718
}
1819

20+
public init(_ featureset: FeaturesetDescriptor<T>, expression: Exp, state: T.State) {
21+
self.featureset = featureset
22+
self.expression = expression
23+
self.state = state
24+
}
25+
1926
/// Sets the feature state using the feature.
2027
///
2128
/// The feature should have a valid ``FeaturesetFeatureType/id``. Otherwise this call is no-op.

Sources/MapboxMaps/Style/StyleManager.swift

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,112 @@ extension StyleManagerProtocol {
16061606

16071607
// MARK: - Conversion helpers
16081608

1609+
private actor ActorCancelable {
1610+
private var _isCanceled: Bool = false
1611+
1612+
var isCanceled: Bool {
1613+
_isCanceled
1614+
}
1615+
1616+
private func setCanceled() {
1617+
_isCanceled = true
1618+
}
1619+
1620+
nonisolated func markCanceled() {
1621+
Task { await setCanceled() }
1622+
}
1623+
}
1624+
1625+
// TODO: When Swift 6 actor isolation is available merge main and non-main variants into one
1626+
1627+
// non cancelable variant
1628+
@MainActor
1629+
internal func handleExpectedOnMain<Value, Error>(closure: ((@escaping (Expected<Value, Error>) -> Void) -> Void)) async throws {
1630+
try await handleExpectedOnMain { wrapped in
1631+
closure(wrapped)
1632+
return AnyCancelable { }
1633+
}
1634+
}
1635+
1636+
@MainActor
1637+
internal func handleExpectedOnMain<Value, Error>(closure: ((@escaping (Expected<Value, Error>) -> Void) -> Cancelable)) async throws {
1638+
let sendableCancelable = ActorCancelable()
1639+
1640+
return try await withTaskCancellationHandler {
1641+
return try await withCheckedThrowingContinuation { continuation in
1642+
if Task.isCancelled {
1643+
continuation.resume(throwing: CancellationError())
1644+
return
1645+
}
1646+
1647+
var cancelable: Cancelable!
1648+
cancelable = closure { expected in
1649+
Task { @MainActor in
1650+
if await sendableCancelable.isCanceled {
1651+
cancelable?.cancel()
1652+
cancelable = nil
1653+
continuation.resume(throwing: CancellationError())
1654+
return
1655+
}
1656+
1657+
do {
1658+
try handleExpected(closure: { expected })
1659+
continuation.resume(with: .success(()))
1660+
} catch {
1661+
continuation.resume(throwing: error)
1662+
}
1663+
}
1664+
}
1665+
}
1666+
1667+
} onCancel: {
1668+
sendableCancelable.markCanceled()
1669+
}
1670+
}
1671+
1672+
// non cancelable variant
1673+
internal func handleExpected<Value, Error>(closure: ((@escaping (Expected<Value, Error>) -> Void) -> Void)) async throws {
1674+
try await handleExpected { wrapped in
1675+
closure(wrapped)
1676+
return AnyCancelable { }
1677+
}
1678+
}
1679+
1680+
internal func handleExpected<Value, Error>(closure: ((@escaping (Expected<Value, Error>) -> Void) -> Cancelable)) async throws {
1681+
let sendableCancelable = ActorCancelable()
1682+
1683+
return try await withTaskCancellationHandler {
1684+
return try await withCheckedThrowingContinuation { continuation in
1685+
if Task.isCancelled {
1686+
continuation.resume(throwing: CancellationError())
1687+
return
1688+
}
1689+
1690+
var cancelable: Cancelable!
1691+
cancelable = closure { expected in
1692+
Task {
1693+
if await sendableCancelable.isCanceled {
1694+
cancelable?.cancel()
1695+
cancelable = nil
1696+
continuation.resume(throwing: CancellationError())
1697+
return
1698+
}
1699+
1700+
do {
1701+
try handleExpected(closure: { expected })
1702+
continuation.resume(with: .success(()))
1703+
} catch {
1704+
continuation.resume(throwing: error)
1705+
}
1706+
}
1707+
}
1708+
}
1709+
1710+
} onCancel: {
1711+
sendableCancelable.markCanceled()
1712+
}
1713+
}
1714+
16091715
internal func handleExpected<Value, Error>(closure: () -> (Expected<Value, Error>)) throws {
16101716
let expected = closure()
16111717

Tests/MapboxMapsTests/Foundation/FeaturesetsTests.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@testable import MapboxMaps
1+
@testable @_spi(Experimental) import MapboxMaps
22
import XCTest
33

44
final class FeaturesetsTests: IntegrationTestCase {
@@ -178,6 +178,29 @@ final class FeaturesetsTests: IntegrationTestCase {
178178
wait(for: [resetStatesExp], timeout: 10)
179179
}
180180

181+
func testAsyncFeatureStateMethods() async throws {
182+
let expressionId: UInt = 123
183+
184+
let map = await mapView.mapboxMap!
185+
try await map.setFeatureStateExpression(
186+
expressionId: expressionId,
187+
featureset: .featureset("poi", importId: "nested"),
188+
expression: Exp(.boolean) { true },
189+
state: ["foo": "bar"]
190+
)
191+
192+
try await map.setFeatureStateExpression(
193+
expressionId: 1111111,
194+
featureset: .featureset("poi", importId: "nested"),
195+
expression: Exp(.boolean) { true },
196+
state: ["one": "two"]
197+
)
198+
199+
try await map.removeFeatureStateExpression(expressionId: expressionId)
200+
201+
try await map.resetFeatureStateExpressions()
202+
}
203+
181204
func testStateIsQueried() throws {
182205
let setStateExp = expectation(description: "set state exp")
183206

Tests/MapboxMapsTests/Foundation/Mocks/MockMapboxMap.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,34 @@ final class MockMapboxMap: MapboxMapProtocol {
238238
))
239239
}
240240

241+
struct FeatureStateParamsExpression {
242+
var expressionId: UInt
243+
var featureset: FeaturesetDescriptor<FeaturesetFeature>
244+
var expression: Exp
245+
var state: JSONObject?
246+
}
247+
248+
var setFeatureStateExpressionStub = Stub<FeatureStateParamsExpression, Void>()
249+
func setFeatureStateExpression<T: FeaturesetFeatureType>(
250+
expressionId: UInt,
251+
featureset: FeaturesetDescriptor<T>,
252+
expression: Exp,
253+
state: T.State
254+
) async throws {
255+
setFeatureStateExpressionStub.call(
256+
with: FeatureStateParamsExpression(
257+
expressionId: expressionId,
258+
featureset: featureset.converted(),
259+
expression: expression,
260+
state: encodeState(state).flatMap(JSONObject.init(turfRawValue:)))
261+
)
262+
}
263+
264+
var removeFeatureStateExpressionStub = Stub<UInt, Void>()
265+
func removeFeatureStateExpression(expressionId: UInt) async throws {
266+
removeFeatureStateExpressionStub.call(with: expressionId)
267+
}
268+
241269
let dispatchStub = Stub<CorePlatformEventInfo, Void>()
242270
func dispatch(event: CorePlatformEventInfo) {
243271
dispatchStub.call(with: event)

0 commit comments

Comments
 (0)