diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme index a45b7e5..b7b77e7 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme @@ -1,6 +1,6 @@ (makeViewController: NavigationViewController(dayStyleURL: self.styleURL), styleURL: self.styleURL, camera: self.$mapStore.camera) { + + } + .unsafeMapViewControllerModifier { navigationViewController in + navigationViewController.delegate = self.mapStore + if let route = self.route, self.navigationInProgress == false { + let locationManager = SimulatedLocationManager(route: route) + navigationViewController.startNavigation(with: route, locationManager: locationManager) + self.navigationInProgress = true + } else if self.route == nil, self.navigationInProgress == true { + navigationViewController.endNavigation() + self.navigationInProgress = false + } + + navigationViewController.mapView.showsUserLocation = self.showUserLocation && self.mapStore.streetView == .disabled + } + .cameraModifierDisabled(self.route != nil) +} +``` +We choose this approach so MapLibreSwiftUI is not depdending on maplibre-navigation as most users don't need it. + ## Developer Quick Start This project uses [`swiftformat`](https://github.com/nicklockwood/SwiftFormat) to automatically handle basic swift formatting diff --git a/Sources/MapLibreSwiftUI/Examples/Gestures.swift b/Sources/MapLibreSwiftUI/Examples/Gestures.swift index e7f9fc3..a2f0d38 100644 --- a/Sources/MapLibreSwiftUI/Examples/Gestures.swift +++ b/Sources/MapLibreSwiftUI/Examples/Gestures.swift @@ -18,7 +18,7 @@ import SwiftUI .iconColor(.white) } .onTapMapGesture(on: [tappableID], onTapChanged: { _, features in - print("Tapped on \(features.first)") + print("Tapped on \(features.first?.description ?? "")") }) .ignoresSafeArea(.all) } @@ -26,7 +26,7 @@ import SwiftUI #Preview("Tappable Countries") { MapView(styleURL: demoTilesURL) .onTapMapGesture(on: ["countries-fill"], onTapChanged: { _, features in - print("Tapped on \(features.first)") + print("Tapped on \(features.first?.description ?? "")") }) .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index 27e09bd..bbd40fd 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -124,7 +124,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c .predicate(NSPredicate(format: "cluster != YES")) } .onTapMapGesture(on: ["simple-circles-non-clusters"], onTapChanged: { _, features in - print("Tapped on \(features.first)") + print("Tapped on \(features.first?.debugDescription ?? "")") }) .expandClustersOnTapping(clusteredLayers: [ClusterLayer( layerIdentifier: "simple-circles-clusters", diff --git a/Sources/MapLibreSwiftUI/Examples/Other.swift b/Sources/MapLibreSwiftUI/Examples/Other.swift index 7e5b6a8..5c76bd9 100644 --- a/Sources/MapLibreSwiftUI/Examples/Other.swift +++ b/Sources/MapLibreSwiftUI/Examples/Other.swift @@ -27,11 +27,11 @@ import SwiftUI SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) .iconImage(UIImage(systemName: "mappin")!) } - .unsafeMapViewModifier { mapView in + .unsafeMapViewControllerModifier { viewController in // Not all properties have modifiers yet. Until they do, you can use this 'escape hatch' to the underlying // MLNMapView. Be careful: if you modify properties that the DSL controls already, they may be overridden. This // modifier is a "hack", not a final function. - mapView.logoView.isHidden = false - mapView.compassViewPosition = .topLeft + viewController.mapView.logoView.isHidden = false + viewController.mapView.compassViewPosition = .topLeft } } diff --git a/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift index eca5e64..918fb47 100644 --- a/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift @@ -2,5 +2,4 @@ import CoreLocation let switzerland = CLLocationCoordinate2D(latitude: 47.03041, longitude: 8.29470) -let demoTilesURL = - URL(string: "https://demotiles.maplibre.org/style.json")! +public let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift index 60bc362..2deaf4b 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift @@ -5,7 +5,7 @@ import Mockable // NOTE: We should eventually mark the entire protocol @MainActor, but Mockable generates some unsafe code at the moment @Mockable -protocol MLNMapViewCameraUpdating: AnyObject { +public protocol MLNMapViewCameraUpdating: AnyObject { @MainActor var userTrackingMode: MLNUserTrackingMode { get set } @MainActor var minimumPitch: CGFloat { get set } @MainActor var maximumPitch: CGFloat { get set } diff --git a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift index c662593..1800fea 100644 --- a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift +++ b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift @@ -2,7 +2,7 @@ import Mockable import UIKit @Mockable -protocol UIGestureRecognizing: AnyObject { +public protocol UIGestureRecognizing: AnyObject { @MainActor var state: UIGestureRecognizer.State { get } @MainActor func location(in view: UIView?) -> CGPoint @MainActor func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint diff --git a/Sources/MapLibreSwiftUI/MLNMapViewController.swift b/Sources/MapLibreSwiftUI/MLNMapViewController.swift new file mode 100644 index 0000000..65f3903 --- /dev/null +++ b/Sources/MapLibreSwiftUI/MLNMapViewController.swift @@ -0,0 +1,17 @@ +import MapLibre +import UIKit + +public protocol MapViewHostViewController: UIViewController { + associatedtype MapType: MLNMapView + var mapView: MapType { get } +} + +public final class MLNMapViewController: UIViewController, MapViewHostViewController { + public var mapView: MLNMapView { + view as! MLNMapView + } + + override public func loadView() { + view = MLNMapView(frame: .zero) + } +} diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 7dc92b4..8ed40eb 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -3,9 +3,13 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -public struct MapView: UIViewRepresentable { +public struct MapView: UIViewControllerRepresentable { + public typealias UIViewControllerType = T + var cameraDisabled: Bool = true + @Binding var camera: MapViewCamera + let makeViewController: () -> T let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] @@ -16,9 +20,7 @@ public struct MapView: UIViewRepresentable { public var mapViewContentInset: UIEdgeInsets = .zero - /// 'Escape hatch' to MLNMapView until we have more modifiers. - /// See ``unsafeMapViewModifier(_:)`` - var unsafeMapViewModifier: ((MLNMapView) -> Void)? + var unsafeMapViewControllerModifier: ((T) -> Void)? var controls: [MapControl] = [ CompassView(), @@ -31,93 +33,115 @@ public struct MapView: UIViewRepresentable { var clusteredLayers: [ClusterLayer]? public init( + makeViewController: @autoclosure @escaping () -> T, styleURL: URL, camera: Binding = .constant(.default()), locationManager: MLNLocationManager? = nil, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { + self.makeViewController = makeViewController styleSource = .url(styleURL) _camera = camera userLayers = makeMapContent() self.locationManager = locationManager } - public func makeCoordinator() -> MapViewCoordinator { - MapViewCoordinator( + public func makeCoordinator() -> MapViewCoordinator { + MapViewCoordinator( parent: self, onGesture: { processGesture($0, $1) }, onViewPortChanged: { onViewPortChanged?($0) } ) } - public func makeUIView(context: Context) -> MLNMapView { + public func makeUIViewController(context: Context) -> T { // Create the map view - let mapView = MLNMapView(frame: .zero) - mapView.delegate = context.coordinator - context.coordinator.mapView = mapView + let controller = makeViewController() + controller.mapView.delegate = context.coordinator + context.coordinator.mapView = controller.mapView // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as // content insets can trigger a change) context.coordinator.suppressCameraUpdatePropagation = true - applyModifiers(mapView, runUnsafe: false) + applyModifiers(controller, runUnsafe: false) context.coordinator.suppressCameraUpdatePropagation = false - mapView.locationManager = locationManager + controller.mapView.locationManager = locationManager switch styleSource { case let .url(styleURL): - mapView.styleURL = styleURL + controller.mapView.styleURL = styleURL } - context.coordinator.updateCamera(mapView: mapView, + context.coordinator.updateCamera(mapView: controller.mapView, camera: $camera.wrappedValue, animated: false) - mapView.locationManager = mapView.locationManager + controller.mapView.locationManager = controller.mapView.locationManager // Link the style loaded to the coordinator that emits the delegate event. context.coordinator.onStyleLoaded = onStyleLoaded // Add all gesture recognizers for gesture in gestures { - registerGesture(mapView, context, gesture: gesture) + registerGesture(controller.mapView, context, gesture: gesture) } - return mapView + return controller } - public func updateUIView(_ mapView: MLNMapView, context: Context) { + public func updateUIViewController(_ uiViewController: T, context: Context) { context.coordinator.parent = self - applyModifiers(mapView, runUnsafe: true) + applyModifiers(uiViewController, runUnsafe: true) // FIXME: This should be a more selective update - context.coordinator.updateStyleSource(styleSource, mapView: mapView) - context.coordinator.updateLayers(mapView: mapView) + context.coordinator.updateStyleSource(styleSource, mapView: uiViewController.mapView) + context.coordinator.updateLayers(mapView: uiViewController.mapView) // FIXME: This isn't exactly telling us if the *map* is loaded, and the docs for setCenter say it needs to be. - let isStyleLoaded = mapView.style != nil + let isStyleLoaded = uiViewController.mapView.style != nil - context.coordinator.updateCamera(mapView: mapView, - camera: $camera.wrappedValue, - animated: isStyleLoaded) + if cameraDisabled == false { + context.coordinator.updateCamera(mapView: uiViewController.mapView, + camera: $camera.wrappedValue, + animated: isStyleLoaded) + } } - @MainActor private func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) { - mapView.contentInset = mapViewContentInset + @MainActor private func applyModifiers(_ mapViewController: T, runUnsafe: Bool) { + mapViewController.mapView.contentInset = mapViewContentInset // Assume all controls are hidden by default (so that an empty list returns a map with no controls) - mapView.logoView.isHidden = true - mapView.compassView.isHidden = true - mapView.attributionButton.isHidden = true + mapViewController.mapView.logoView.isHidden = true + mapViewController.mapView.compassView.isHidden = true + mapViewController.mapView.attributionButton.isHidden = true // Apply each control configuration for control in controls { - control.configureMapView(mapView) + control.configureMapView(mapViewController.mapView) } if runUnsafe { - unsafeMapViewModifier?(mapView) + unsafeMapViewControllerModifier?(mapViewController) + } + } +} + +public extension MapView where T == MLNMapViewController { + @MainActor + init( + styleURL: URL, + camera: Binding = .constant(.default()), + locationManager: MLNLocationManager? = nil, + @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } + ) { + makeViewController = { + MLNMapViewController() } + styleSource = .url(styleURL) + _camera = camera + userLayers = makeMapContent() + self.locationManager = locationManager } } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index d0886ed..52051df 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -2,10 +2,10 @@ import Foundation import MapLibre import MapLibreSwiftDSL -public class MapViewCoordinator: NSObject { +public class MapViewCoordinator: NSObject, MLNMapViewDelegate { // This must be weak, the UIViewRepresentable owns the MLNMapView. weak var mapView: MLNMapView? - var parent: MapView + var parent: MapView // Storage of variables as they were previously; these are snapshot // every update cycle so we can avoid unnecessary updates @@ -22,7 +22,7 @@ public class MapViewCoordinator: NSObject { var onGesture: (MLNMapView, UIGestureRecognizer) -> Void var onViewPortChanged: (MapViewPort) -> Void - init(parent: MapView, + init(parent: MapView, onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void, onViewPortChanged: @escaping (MapViewPort) -> Void) { @@ -296,11 +296,9 @@ public class MapViewCoordinator: NSObject { } } } -} -// MARK: - MLNMapViewDelegate + // MARK: - MLNMapViewDelegate -extension MapViewCoordinator: MLNMapViewDelegate { public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { addLayers(to: mglStyle) onStyleLoaded?(mglStyle) diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index 8b82d14..29a309b 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -33,14 +33,14 @@ public extension MapView { /// Example: /// ```swift /// MapView() - /// .mapViewModifier { mapView in - /// mapView.showUserLocation = true + /// .unsafeMapViewControllerModifier { controller in + /// controller.mapView.showUserLocation = true /// } /// ``` /// - func unsafeMapViewModifier(_ modifier: @escaping (MLNMapView) -> Void) -> MapView { + func unsafeMapViewControllerModifier(_ modifier: @escaping (T) -> Void) -> MapView { var newMapView = self - newMapView.unsafeMapViewModifier = modifier + newMapView.unsafeMapViewControllerModifier = modifier return newMapView } @@ -114,25 +114,19 @@ public extension MapView { /// - Returns: The modified MapView func expandClustersOnTapping(clusteredLayers: [ClusterLayer]) -> MapView { var newMapView = self - newMapView.clusteredLayers = clusteredLayers - return newMapView } func mapViewContentInset(_ inset: UIEdgeInsets) -> Self { var result = self - result.mapViewContentInset = inset - return result } func mapControls(@MapControlsBuilder _ buildControls: () -> [MapControl]) -> Self { var result = self - result.controls = buildControls() - return result } @@ -141,4 +135,14 @@ public extension MapView { result.onViewPortChanged = onViewPortChanged return result } + + /// Prevent Maplibre-DSL from updating the camera, useful when the underlying ViewController is managing the camera, + /// for example during navigation when Maplibre-Navigation is used. + /// - Parameter disabled: if true, prevents Maplibre-DSL from updating the camera + /// - Returns: The modified MapView + func cameraModifierDisabled(_ disabled: Bool) -> Self { + var view = self + view.cameraDisabled = disabled + return view + } } diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift index a54821d..e0c1cea 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift @@ -17,20 +17,20 @@ public class MapGesture: NSObject { } /// The Gesture's method, this is used to register it for the correct user interaction on the MapView. - let method: Method + public let method: Method /// The onChange action that runs when the gesture changes on the map view. - let onChange: GestureAction + public let onChange: GestureAction /// The underlying gesture recognizer - weak var gestureRecognizer: UIGestureRecognizer? + public weak var gestureRecognizer: UIGestureRecognizer? /// Create a new gesture recognizer definition for the MapView /// /// - Parameters: /// - method: The gesture recognizer method /// - onChange: The action to perform when the gesture is changed - init(method: Method, onChange: GestureAction) { + public init(method: Method, onChange: GestureAction) { self.method = method self.onChange = onChange } diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift index bb70afe..8c87213 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift @@ -14,4 +14,16 @@ public struct MapGestureContext { /// The underlying geographic coordinate at the point of the gesture. public let coordinate: CLLocationCoordinate2D + + public init( + gestureMethod: MapGesture.Method, + state: UIGestureRecognizer.State, + point: CGPoint, + coordinate: CLLocationCoordinate2D + ) { + self.gestureMethod = gestureMethod + self.state = state + self.point = point + self.coordinate = coordinate + } } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift index 368ae84..4212e0b 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -19,7 +19,7 @@ public enum CameraChangeReason: Hashable { /// If you need a full history of the full bit range, use MLNCameraChangeReason directly /// /// - Parameter mlnCameraChangeReason: The camera change reason options list from the MapLibre MapViewDelegate - init?(_ mlnCameraChangeReason: MLNCameraChangeReason) { + public init?(_ mlnCameraChangeReason: MLNCameraChangeReason) { switch mlnCameraChangeReason.largestBitwiseReason { case .programmatic: self = .programmatic diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift index 9379339..11315bc 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift @@ -15,7 +15,7 @@ public enum CameraPitchRange: Hashable, Sendable { /// The range of acceptable pitch values. /// /// This is applied to the map view on camera updates. - var rangeValue: ClosedRange { + public var rangeValue: ClosedRange { switch self { case .free: 0 ... 60 // TODO: set this to a maplibre constant (this is available on Android, but maybe not iOS)? diff --git a/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift index 862c7de..b1e98cb 100644 --- a/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift +++ b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift @@ -23,6 +23,7 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testRoseTint() { assertView { MapView(styleURL: demoTilesURL) { @@ -34,6 +35,7 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testSimpleSymbol() { assertView { MapView(styleURL: demoTilesURL) { @@ -44,6 +46,7 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testRotatedSymbolConst() { assertView { MapView(styleURL: demoTilesURL) { @@ -55,6 +58,7 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testRotatedSymboleDynamic() { assertView { MapView(styleURL: demoTilesURL) { @@ -66,6 +70,7 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testCirclesWithSymbols() { assertView { MapView(styleURL: demoTilesURL) { diff --git a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift index 4389911..529dfec 100644 --- a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift +++ b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift @@ -4,6 +4,7 @@ import XCTest @testable import MapLibreSwiftUI final class MapControlsTests: XCTestCase { + @MainActor func testEmptyControls() { assertView { MapView(styleURL: demoTilesURL) @@ -13,6 +14,7 @@ final class MapControlsTests: XCTestCase { } } + @MainActor func testLogoOnly() { assertView { MapView(styleURL: demoTilesURL) @@ -22,6 +24,7 @@ final class MapControlsTests: XCTestCase { } } + @MainActor func testLogoChangePosition() { assertView { MapView(styleURL: demoTilesURL) @@ -32,6 +35,7 @@ final class MapControlsTests: XCTestCase { } } + @MainActor func testCompassOnly() { assertView { MapView(styleURL: demoTilesURL) @@ -41,6 +45,7 @@ final class MapControlsTests: XCTestCase { } } + @MainActor func testCompassChangePosition() { assertView { MapView(styleURL: demoTilesURL) @@ -51,6 +56,7 @@ final class MapControlsTests: XCTestCase { } } + @MainActor func testAttributionOnly() { assertView { MapView(styleURL: demoTilesURL) @@ -60,6 +66,7 @@ final class MapControlsTests: XCTestCase { } } + @MainActor func testAttributionChangePosition() { assertView { MapView(styleURL: demoTilesURL) diff --git a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift index 1332d24..047ee49 100644 --- a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -5,6 +5,8 @@ import XCTest final class MapViewGestureTests: XCTestCase { let maplibreMapView = MLNMapView() + + @MainActor let mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) // MARK: Gesture View Modifiers diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index aad4f21..47efe30 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -5,9 +5,10 @@ import XCTest final class MapViewCoordinatorCameraTests: XCTestCase { var maplibreMapView: MockMLNMapViewCameraUpdating! - var mapView: MapView! - var coordinator: MapView.Coordinator! + var mapView: MapView! + var coordinator: MapView.Coordinator! + @MainActor override func setUp() async throws { maplibreMapView = MockMLNMapViewCameraUpdating() given(maplibreMapView).frame.willReturn(.zero)