diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9275cb5..98040f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: run: brew install swiftformat - name: Checkout maplibre-swiftui-dsl-playground - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Check format run: swiftformat . --lint @@ -38,10 +38,10 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '15.0' + xcode-version: '15.2' - name: Checkout maplibre-swiftui-dsl-playground - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Test ${{ matrix.scheme }} on ${{ matrix.destination }} run: xcodebuild -scheme ${{ matrix.scheme }} test -skipMacroValidation -destination '${{ matrix.destination }}' | xcbeautify && exit ${PIPESTATUS[0]} diff --git a/Package.swift b/Package.swift index 99c1f1b..b0ba5b1 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( ], swiftSettings: [ .define("MOCKING", .when(configuration: .debug)), + .enableExperimentalFeature("StrictConcurrency"), ] ), .target( @@ -46,10 +47,16 @@ let package = Package( .target(name: "InternalUtils"), .product(name: "MapLibre", package: "maplibre-gl-native-distribution"), .product(name: "MapLibreSwiftMacros", package: "maplibre-swift-macros"), + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), ] ), .target( - name: "InternalUtils" + name: "InternalUtils", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] ), // MARK: Tests diff --git a/Sources/MapLibreSwiftDSL/MapControls.swift b/Sources/MapLibreSwiftDSL/MapControls.swift new file mode 100644 index 0000000..6593b6b --- /dev/null +++ b/Sources/MapLibreSwiftDSL/MapControls.swift @@ -0,0 +1,137 @@ +import Foundation +import MapLibre + +public protocol MapControl { + /// Overrides the position of the control. Default values are control-specfiic. + var position: MLNOrnamentPosition? { get set } + /// Overrides the offset of the control. + var margins: CGPoint? { get set } + /// Overrides whether the control is hidden. + var isHidden: Bool { get set } + + @MainActor func configureMapView(_ mapView: MLNMapView) +} + +public extension MapControl { + /// Sets position of the control. + func position(_ position: MLNOrnamentPosition) -> Self { + var result = self + + result.position = position + + return result + } + + /// Sets the position offset of the control. + func margins(_ margins: CGPoint) -> Self { + var result = self + + result.margins = margins + + return result + } + + /// Hides the control. + func hidden(_: Bool) -> Self { + var result = self + + result.isHidden = true + + return result + } +} + +public struct CompassView: MapControl { + public var position: MLNOrnamentPosition? + public var margins: CGPoint? + public var isHidden: Bool = false + + public func configureMapView(_ mapView: MLNMapView) { + if let position { + mapView.compassViewPosition = position + } + + if let margins { + mapView.compassViewMargins = margins + } + + mapView.compassView.isHidden = isHidden + } + + public init() {} +} + +public struct LogoView: MapControl { + public var position: MLNOrnamentPosition? + public var margins: CGPoint? + public var isHidden: Bool = false + public var image: UIImage? + + public init() {} + + public func configureMapView(_ mapView: MLNMapView) { + if let position { + mapView.logoViewPosition = position + } + + if let margins { + mapView.logoViewMargins = margins + } + + mapView.logoView.isHidden = isHidden + + if let image { + mapView.logoView.image = image + } + } +} + +public extension LogoView { + /// Sets the logo image (defaults to the MapLibre logo). + func image(_ image: UIImage?) -> Self { + var result = self + + result.image = image + + return result + } +} + +@resultBuilder +public enum MapControlsBuilder: DefaultResultBuilder { + public static func buildExpression(_ expression: MapControl) -> [MapControl] { + [expression] + } + + public static func buildExpression(_ expression: [MapControl]) -> [MapControl] { + expression + } + + public static func buildExpression(_: Void) -> [MapControl] { + [] + } + + public static func buildBlock(_ components: [MapControl]...) -> [MapControl] { + components.flatMap { $0 } + } + + public static func buildArray(_ components: [MapControl]) -> [MapControl] { + components + } + + public static func buildArray(_ components: [[MapControl]]) -> [MapControl] { + components.flatMap { $0 } + } + + public static func buildEither(first components: [MapControl]) -> [MapControl] { + components + } + + public static func buildEither(second components: [MapControl]) -> [MapControl] { + components + } + + public static func buildOptional(_ components: [MapControl]?) -> [MapControl] { + components ?? [] + } +} diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index 3d7d025..a7f0f11 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -1,22 +1,20 @@ import CoreLocation import SwiftUI -private let switzerland = CLLocationCoordinate2D(latitude: 46.801111, longitude: 8.226667) - struct CameraDirectManipulationPreview: View { @State private var camera = MapViewCamera.center(switzerland, zoom: 4) let styleURL: URL var onStyleLoaded: (() -> Void)? = nil + var targetCameraAfterDelay: MapViewCamera? = nil var body: some View { MapView(styleURL: styleURL, camera: $camera) .onStyleLoaded { _ in - print("Style is loaded") onStyleLoaded?() } .overlay(alignment: .bottom, content: { - Text("\(String(describing: camera.state)) z \(camera.zoom)") + Text("\(String(describing: camera.state))") .padding() .foregroundColor(.white) .background( @@ -27,16 +25,19 @@ struct CameraDirectManipulationPreview: View { .padding(.bottom, 42) }) .task { - try? await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) + if let targetCameraAfterDelay { + try? await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) - camera = MapViewCamera.center(switzerland, zoom: 6) + camera = targetCameraAfterDelay + } } } } -#Preview("Camera Preview") { +#Preview("Camera Zoom after delay") { CameraDirectManipulationPreview( - styleURL: URL(string: "https://demotiles.maplibre.org/style.json")! + styleURL: demoTilesURL, + targetCameraAfterDelay: .center(switzerland, zoom: 6) ) .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index 3278ada..4c464ef 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -3,10 +3,9 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - // A collection of points with various // attributes +@MainActor let pointSource = ShapeSource(identifier: "points") { // Uses the DSL to quickly construct point features inline MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139)) diff --git a/Sources/MapLibreSwiftUI/Examples/Polyline.swift b/Sources/MapLibreSwiftUI/Examples/Polyline.swift index 1e636a4..df5c3f9 100644 --- a/Sources/MapLibreSwiftUI/Examples/Polyline.swift +++ b/Sources/MapLibreSwiftUI/Examples/Polyline.swift @@ -8,7 +8,7 @@ struct PolylinePreview: View { var body: some View { MapView(styleURL: styleURL, - constantCamera: .center(samplePedestrianWaypoints.first!, zoom: 14)) + camera: .constant(.center(samplePedestrianWaypoints.first!, zoom: 14))) { // Note: This line does not add the source to the style as if it // were a statement in an imperative programming language. @@ -43,8 +43,6 @@ struct PolylinePreview: View { struct Polyline_Previews: PreviewProvider { static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - PolylinePreview(styleURL: demoTilesURL) .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift new file mode 100644 index 0000000..eca5e64 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift @@ -0,0 +1,6 @@ +// This file contains helpers that are used in the SwiftUI preview examples +import CoreLocation + +let switzerland = CLLocationCoordinate2D(latitude: 47.03041, longitude: 8.29470) +let demoTilesURL = + URL(string: "https://demotiles.maplibre.org/style.json")! diff --git a/Sources/MapLibreSwiftUI/Examples/User Location.swift b/Sources/MapLibreSwiftUI/Examples/User Location.swift new file mode 100644 index 0000000..3bafb2f --- /dev/null +++ b/Sources/MapLibreSwiftUI/Examples/User Location.swift @@ -0,0 +1,37 @@ +import CoreLocation +import MapLibreSwiftDSL +import SwiftUI + +@MainActor +private let locationManager = StaticLocationManager(initialLocation: CLLocation( + coordinate: switzerland, + altitude: 0, + horizontalAccuracy: 1, + verticalAccuracy: 1, + course: 8, + speed: 28, + timestamp: Date() +)) + +#Preview("Track user location") { + MapView( + styleURL: demoTilesURL, + camera: .constant(.trackUserLocation(zoom: 4, pitch: .fixed(45))), + locationManager: locationManager + ) + .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0)) + .ignoresSafeArea(.all) +} + +#Preview("Track user location with Course") { + MapView( + styleURL: demoTilesURL, + camera: .constant(.trackUserLocationWithCourse(zoom: 4, pitch: .fixed(45))), + locationManager: locationManager + ) + .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0)) + .mapControls { + LogoView() + } + .ignoresSafeArea(.all) +} diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift index d46e238..7568bc2 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift @@ -3,17 +3,18 @@ import Foundation import MapLibre import Mockable +// NOTE: We should eventually mark the entire protocol @MainActor, but Mockable generates some unsafe code at the moment @Mockable protocol MLNMapViewCameraUpdating: AnyObject { - var userTrackingMode: MLNUserTrackingMode { get set } - var minimumPitch: CGFloat { get set } - var maximumPitch: CGFloat { get set } - func setCenter(_ coordinate: CLLocationCoordinate2D, - zoomLevel: Double, - direction: CLLocationDirection, - animated: Bool) - func setZoomLevel(_ zoomLevel: Double, animated: Bool) - func setVisibleCoordinateBounds( + @MainActor var userTrackingMode: MLNUserTrackingMode { get set } + @MainActor var minimumPitch: CGFloat { get set } + @MainActor var maximumPitch: CGFloat { get set } + @MainActor func setCenter(_ coordinate: CLLocationCoordinate2D, + zoomLevel: Double, + direction: CLLocationDirection, + animated: Bool) + @MainActor func setZoomLevel(_ zoomLevel: Double, animated: Bool) + @MainActor func setVisibleCoordinateBounds( _ bounds: MLNCoordinateBounds, edgePadding: UIEdgeInsets, animated: Bool, diff --git a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift index 22d376e..34f46e3 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift @@ -9,7 +9,7 @@ extension MapView { /// - mapView: The MLNMapView that will host the gesture itself. /// - context: The UIViewRepresentable context that will orchestrate the response sender /// - gesture: The gesture definition. - func registerGesture(_ mapView: MLNMapView, _ context: Context, gesture: MapGesture) { + @MainActor func registerGesture(_ mapView: MLNMapView, _ context: Context, gesture: MapGesture) { switch gesture.method { case let .tap(numberOfTaps: numberOfTaps): let gestureRecognizer = UITapGestureRecognizer(target: context.coordinator, @@ -41,7 +41,7 @@ extension MapView { /// - mapView: The MapView emitting the gesture. This is used to calculate the point and coordinate of the /// gesture. /// - sender: The UIGestureRecognizer - func processGesture(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) { + @MainActor func processGesture(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) { guard let gesture = gestures.first(where: { $0.gestureRecognizer == sender }) else { assertionFailure("\(sender) is not a registered UIGestureRecongizer on the MapView") return @@ -60,11 +60,11 @@ extension MapView { /// - gesture: The gesture definition for this event. /// - sender: The UIKit gesture emitting from the map view. /// - Returns: The calculated context from the sending UIKit gesture - func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, - sender: UIGestureRecognizing) -> MapGestureContext + @MainActor func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, + sender: UIGestureRecognizing) -> MapGestureContext { // Build the context of the gesture's event. - var point: CGPoint = switch gesture.method { + let point: CGPoint = switch gesture.method { case let .tap(numberOfTaps: numberOfTaps): // Calculate the CGPoint of the last gesture tap sender.location(ofTouch: numberOfTaps - 1, in: mapView) diff --git a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift index 09b68d8..c662593 100644 --- a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift +++ b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift @@ -3,9 +3,9 @@ import UIKit @Mockable protocol UIGestureRecognizing: AnyObject { - var state: UIGestureRecognizer.State { get } - func location(in view: UIView?) -> CGPoint - func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint + @MainActor var state: UIGestureRecognizer.State { get } + @MainActor func location(in view: UIView?) -> CGPoint + @MainActor func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint } extension UIGestureRecognizer: UIGestureRecognizing { diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 3212f9e..5b10d9e 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -12,28 +12,31 @@ public struct MapView: UIViewRepresentable { var gestures = [MapGesture]() var onStyleLoaded: ((MLNStyle) -> Void)? + public var mapViewContentInset: UIEdgeInsets = .zero + public var isLogoViewHidden = false + public var isCompassViewHidden = false + /// 'Escape hatch' to MLNMapView until we have more modifiers. /// See ``unsafeMapViewModifier(_:)`` var unsafeMapViewModifier: ((MLNMapView) -> Void)? + var controls: [MapControl] = [ + CompassView(), + LogoView(), + ] + + private var locationManager: MLNLocationManager? + public init( styleURL: URL, camera: Binding = .constant(.default()), + locationManager: MLNLocationManager? = nil, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { styleSource = .url(styleURL) _camera = camera userLayers = makeMapContent() - } - - public init( - styleURL: URL, - constantCamera: MapViewCamera, - @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } - ) { - self.init(styleURL: styleURL, - camera: .constant(constantCamera), - makeMapContent) + self.locationManager = locationManager } public func makeCoordinator() -> MapViewCoordinator { @@ -49,6 +52,14 @@ public struct MapView: UIViewRepresentable { mapView.delegate = context.coordinator context.coordinator.mapView = 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) + context.coordinator.suppressCameraUpdatePropagation = false + + mapView.locationManager = locationManager + switch styleSource { case let .url(styleURL): mapView.styleURL = styleURL @@ -57,9 +68,7 @@ public struct MapView: UIViewRepresentable { context.coordinator.updateCamera(mapView: mapView, camera: $camera.wrappedValue, animated: false) - - // TODO: Make this settable via a modifier - mapView.logoView.isHidden = true + mapView.locationManager = mapView.locationManager // Link the style loaded to the coordinator that emits the delegate event. context.coordinator.onStyleLoaded = onStyleLoaded @@ -75,11 +84,7 @@ public struct MapView: UIViewRepresentable { public func updateUIView(_ mapView: MLNMapView, context: Context) { context.coordinator.parent = self - // MARK: Modifiers - - unsafeMapViewModifier?(mapView) - - // MARK: End Modifiers + applyModifiers(mapView, runUnsafe: true) // FIXME: This should be a more selective update context.coordinator.updateStyleSource(styleSource, mapView: mapView) @@ -92,6 +97,23 @@ public struct MapView: UIViewRepresentable { camera: $camera.wrappedValue, animated: isStyleLoaded) } + + @MainActor private func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) { + 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 + + // Apply each control configuration + for control in controls { + control.configureMapView(mapView) + } + + if runUnsafe { + unsafeMapViewModifier?(mapView) + } + } } #Preview { diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index a07963c..d242491 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -11,6 +11,12 @@ public class MapViewCoordinator: NSObject { // every update cycle so we can avoid unnecessary updates private var snapshotUserLayers: [StyleLayerDefinition] = [] private var snapshotCamera: MapViewCamera? + + // Indicates whether we are currently in a push-down camera update cycle. + // This is necessary in order to ensure we don't keep trying to reset a state value which we were already processing + // an update for. + var suppressCameraUpdatePropagation = false + var onStyleLoaded: ((MLNStyle) -> Void)? var onGesture: (MLNMapView, UIGestureRecognizer) -> Void @@ -41,28 +47,44 @@ public class MapViewCoordinator: NSObject { /// camera related MLNMapView functionality. /// - camera: The new camera from the binding. /// - animated: Whether to animate. - func updateCamera(mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool) { + @MainActor func updateCamera(mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool) { guard camera != snapshotCamera else { // No action - camera has not changed. return } + suppressCameraUpdatePropagation = true + defer { + suppressCameraUpdatePropagation = false + } + switch camera.state { - case let .centered(onCoordinate: coordinate): + case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): mapView.userTrackingMode = .none mapView.setCenter(coordinate, - zoomLevel: camera.zoom, - direction: camera.direction, + zoomLevel: zoom, + direction: direction, animated: animated) - case .trackingUserLocation: + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound + case let .trackingUserLocation(zoom: zoom, pitch: pitch): mapView.userTrackingMode = .follow - mapView.setZoomLevel(camera.zoom, animated: false) - case .trackingUserLocationWithHeading: + // Needs to be non-animated or else it messes up following + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound + case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch): mapView.userTrackingMode = .followWithHeading - mapView.setZoomLevel(camera.zoom, animated: false) - case .trackingUserLocationWithCourse: + // Needs to be non-animated or else it messes up following + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound + case let .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch): mapView.userTrackingMode = .followWithCourse - mapView.setZoomLevel(camera.zoom, animated: false) + // Needs to be non-animated or else it messes up following + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound case let .rect(boundingBox, padding): mapView.setVisibleCoordinateBounds(boundingBox, edgePadding: padding, @@ -73,16 +95,12 @@ public class MapViewCoordinator: NSObject { break } - // Set the correct pitch range. - mapView.minimumPitch = camera.pitch.rangeValue.lowerBound - mapView.maximumPitch = camera.pitch.rangeValue.upperBound - snapshotCamera = camera } // MARK: - Coordinator API - Styles + Layers - func updateStyleSource(_ source: MapStyleSource, mapView: MLNMapView) { + @MainActor func updateStyleSource(_ source: MapStyleSource, mapView: MLNMapView) { switch (source, parent.styleSource) { case let (.url(newURL), .url(oldURL)): if newURL != oldURL { @@ -91,7 +109,7 @@ public class MapViewCoordinator: NSObject { } } - func updateLayers(mapView: MLNMapView) { + @MainActor func updateLayers(mapView: MLNMapView) { // TODO: Figure out how to selectively update layers when only specific props changed. New function in addition to makeMLNStyleLayer? // TODO: Extract this out into a separate function or three... @@ -186,27 +204,55 @@ extension MapViewCoordinator: MLNMapViewDelegate { onStyleLoaded?(mglStyle) } + @MainActor private func updateParentCamera(mapView: MLNMapView, reason: MLNCameraChangeReason) { + // If any of these are a mismatch, we know the camera is no longer following a desired method, so we should + // detach and revert to a .centered camera. If any one of these is true, the desired camera state still + // matches the mapView's userTrackingMode + // NOTE: The use of assumeIsolated is just to make Swift strict concurrency checks happy. + // This invariant is upheld by the MLNMapView. + let userTrackingMode = mapView.userTrackingMode + let isProgrammaticallyTracking: Bool = switch parent.camera.state { + case .trackingUserLocation: + userTrackingMode == .follow + case .trackingUserLocationWithHeading: + userTrackingMode == .followWithHeading + case .trackingUserLocationWithCourse: + userTrackingMode == .followWithCourse + case .centered, .rect, .showcase: + false + } + + guard !isProgrammaticallyTracking else { + // Programmatic tracking is still active, we can ignore camera updates until we unset/fail this boolean + // check + return + } + + // Publish the MLNMapView's "raw" camera state to the MapView camera binding. + // This path only executes when the map view diverges from the parent state, so this is a "matter of fact" + // state propagation. + let newCamera: MapViewCamera = .center(mapView.centerCoordinate, + zoom: mapView.zoomLevel, + // TODO: Pitch doesn't really describe current state + pitch: .freeWithinRange( + minimum: mapView.minimumPitch, + maximum: mapView.maximumPitch + ), + direction: mapView.direction, + reason: CameraChangeReason(reason)) + snapshotCamera = newCamera + parent.camera = newCamera + } + /// The MapView's region has changed with a specific reason. public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { - // Validate that the mapView.userTrackingMode still matches our desired camera state for each tracking type. - let isFollowing = parent.camera.state == .trackingUserLocation && mapView.userTrackingMode == .follow - let isFollowingHeading = parent.camera.state == .trackingUserLocationWithHeading && mapView - .userTrackingMode == .followWithHeading - let isFollowingCourse = parent.camera.state == .trackingUserLocationWithCourse && mapView - .userTrackingMode == .followWithCourse - - // If any of these are a mismatch, we know the camera is no longer following a desired method, so we should - // detach and revert to a .centered camera. If any one of these is true, the desired camera state still matches - // the mapView's userTrackingMode - if isFollowing || isFollowingHeading || isFollowingCourse { - // User tracking is still active, we can ignore camera updates until we unset/fail this boolean check + guard !suppressCameraUpdatePropagation else { return } - // The user's desired camera is not a user tracking method, now we need to publish the MLNMapView's camera state - // to the MapView camera binding. - parent.camera = .center(mapView.centerCoordinate, - zoom: mapView.zoomLevel, - reason: CameraChangeReason(reason)) + // FIXME: CI complains about MainActor.assumeIsolated being unavailable before iOS 17, despite building on iOS 17.2... This is an epic hack to fix it for now. I can only assume this is an issue with Xcode pre-15.3 + Task { @MainActor in + updateParentCamera(mapView: mapView, reason: reason) + } } } diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index bc8aa96..7f4641a 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -1,5 +1,6 @@ import Foundation import MapLibre +import MapLibreSwiftDSL import SwiftUI public extension MapView { @@ -82,4 +83,20 @@ public extension MapView { 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 + } } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift index d6a9252..45d3cbd 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift @@ -2,7 +2,7 @@ import Foundation import MapLibre /// The current pitch state for the MapViewCamera -public enum CameraPitch: Hashable { +public enum CameraPitch: Hashable, Sendable { /// The user is free to control pitch from it's default min to max. case free diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index e600626..ecb36f2 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -4,25 +4,30 @@ import MapLibre /// The CameraState is used to understand the current context of the MapView's camera. public enum CameraState: Hashable { /// Centered on a coordinate - case centered(onCoordinate: CLLocationCoordinate2D) + case centered( + onCoordinate: CLLocationCoordinate2D, + zoom: Double, + pitch: CameraPitch, + direction: CLLocationDirection + ) /// Follow the user's location using the MapView's internal camera. /// /// This feature uses the MLNMapView's userTrackingMode to .follow which automatically /// follows the user from within the MLNMapView. - case trackingUserLocation + case trackingUserLocation(zoom: Double, pitch: CameraPitch) /// Follow the user's location using the MapView's internal camera with the user's heading. /// /// This feature uses the MLNMapView's userTrackingMode to .followWithHeading which automatically /// follows the user from within the MLNMapView. - case trackingUserLocationWithHeading + case trackingUserLocationWithHeading(zoom: Double, pitch: CameraPitch) /// Follow the user's location using the MapView's internal camera with the users' course /// /// This feature uses the MLNMapView's userTrackingMode to .followWithCourse which automatically /// follows the user from within the MLNMapView. - case trackingUserLocationWithCourse + case trackingUserLocationWithCourse(zoom: Double, pitch: CameraPitch) /// Centered on a bounding box/rectangle. case rect( @@ -37,16 +42,16 @@ public enum CameraState: Hashable { extension CameraState: CustomDebugStringConvertible { public var debugDescription: String { switch self { - case let .centered(onCoordinate: onCoordinate): - "CameraState.centered(onCoordinate: \(onCoordinate)" - case .trackingUserLocation: - "CameraState.trackingUserLocation" - case .trackingUserLocationWithHeading: - "CameraState.trackingUserLocationWithHeading" - case .trackingUserLocationWithCourse: - "CameraState.trackingUserLocationWithCourse" - case let .rect(boundingBox: boundingBox, _): - "CameraState.rect(northeast: \(boundingBox.ne), southwest: \(boundingBox.sw))" + case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): + "CameraState.centered(onCoordinate: \(coordinate), zoom: \(zoom), pitch: \(pitch), direction: \(direction))" + case let .trackingUserLocation(zoom: zoom): + "CameraState.trackingUserLocation(zoom: \(zoom))" + case let .trackingUserLocationWithHeading(zoom: zoom): + "CameraState.trackingUserLocationWithHeading(zoom: \(zoom))" + case let .trackingUserLocationWithCourse(zoom: zoom): + "CameraState.trackingUserLocationWithCourse(zoom: \(zoom))" + case let .rect(boundingBox: boundingBox, edgePadding: edgePadding): + "CameraState.rect(northeast: \(boundingBox.ne), southwest: \(boundingBox.sw), edgePadding: \(edgePadding))" case let .showcase(shapeCollection: shapeCollection): "CameraState.showcase(shapeCollection: \(shapeCollection))" } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift index fee9715..cb1345e 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -14,9 +14,6 @@ public struct MapViewCamera: Hashable { } public var state: CameraState - public var zoom: Double - public var pitch: CameraPitch - public var direction: CLLocationDirection /// The reason the camera was changed. /// @@ -30,11 +27,15 @@ public struct MapViewCamera: Hashable { /// /// - Returns: The constructed MapViewCamera. public static func `default`() -> MapViewCamera { - MapViewCamera(state: .centered(onCoordinate: Defaults.coordinate), - zoom: Defaults.zoom, - pitch: Defaults.pitch, - direction: Defaults.direction, - lastReasonForChange: .programmatic) + MapViewCamera( + state: .centered( + onCoordinate: Defaults.coordinate, + zoom: Defaults.zoom, + pitch: Defaults.pitch, + direction: Defaults.direction + ), + lastReasonForChange: .programmatic + ) } /// Center the map on a specific location. @@ -51,10 +52,7 @@ public struct MapViewCamera: Hashable { direction: CLLocationDirection = Defaults.direction, reason: CameraChangeReason? = nil) -> MapViewCamera { - MapViewCamera(state: .centered(onCoordinate: coordinate), - zoom: zoom, - pitch: pitch, - direction: direction, + MapViewCamera(state: .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction), lastReasonForChange: reason) } @@ -71,10 +69,7 @@ public struct MapViewCamera: Hashable { pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. - MapViewCamera(state: .trackingUserLocation, - zoom: zoom, - pitch: pitch, - direction: Defaults.direction, + MapViewCamera(state: .trackingUserLocation(zoom: zoom, pitch: pitch), lastReasonForChange: .programmatic) } @@ -91,10 +86,7 @@ public struct MapViewCamera: Hashable { pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. - MapViewCamera(state: .trackingUserLocationWithHeading, - zoom: zoom, - pitch: pitch, - direction: Defaults.direction, + MapViewCamera(state: .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch), lastReasonForChange: .programmatic) } @@ -111,10 +103,7 @@ public struct MapViewCamera: Hashable { pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. - MapViewCamera(state: .trackingUserLocationWithCourse, - zoom: zoom, - pitch: pitch, - direction: Defaults.direction, + MapViewCamera(state: .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch), lastReasonForChange: .programmatic) } @@ -128,11 +117,7 @@ public struct MapViewCamera: Hashable { _ box: MLNCoordinateBounds, edgePadding: UIEdgeInsets = .init(top: 20, left: 20, bottom: 20, right: 20) ) -> MapViewCamera { - // zoom, pitch & direction are ignored. MapViewCamera(state: .rect(boundingBox: box, edgePadding: edgePadding), - zoom: 1, - pitch: Defaults.pitch, - direction: Defaults.direction, lastReasonForChange: .programmatic) } } diff --git a/Sources/MapLibreSwiftUI/StaticLocationManager.swift b/Sources/MapLibreSwiftUI/StaticLocationManager.swift new file mode 100644 index 0000000..ecee763 --- /dev/null +++ b/Sources/MapLibreSwiftUI/StaticLocationManager.swift @@ -0,0 +1,71 @@ +import CoreLocation +import MapLibre + +/// A simple class that provides static location updates to a MapLibre view. +/// +/// This is not actually driven by a location manager (such as CLLocationManager) internally, but rather by updates +/// provided one at a time. Beyond the obvious use case in testing and SwiftUI previews, this is also useful if you are +/// doing some processing of raw location data (ex: determining whether to snap locations to a road) and selectively +/// passing the updates on to the map view. +/// +/// You can provide a new location by setting the ``lastLocation`` property. +/// +/// This class does not ever perform any authorization checks. That is the responsiblity of the caller. +public final class StaticLocationManager: NSObject, @unchecked Sendable { + public var delegate: (any MLNLocationManagerDelegate)? + + public var authorizationStatus: CLAuthorizationStatus = .authorizedAlways { + didSet { + delegate?.locationManagerDidChangeAuthorization(self) + } + } + + // TODO: Investigate what this does and document it + public var headingOrientation: CLDeviceOrientation = .portrait + + public var lastLocation: CLLocation { + didSet { + delegate?.locationManager(self, didUpdate: [lastLocation]) + } + } + + public init(initialLocation: CLLocation) { + lastLocation = initialLocation + } +} + +extension StaticLocationManager: MLNLocationManager { + public func requestAlwaysAuthorization() { + // Do nothing + } + + public func requestWhenInUseAuthorization() { + // Do nothing + } + + public func startUpdatingLocation() { + // This has to be async dispatched or else the map view will not update immediately if the camera is set to + // follow the user's location. This leads to some REALLY (unbearably) bad artifacts. We should find a better + // solution for this at some point. This is the reason for the @unchecked Sendable conformance by the way (so + // that we don't get a warning about using non-sendable self; it should be safe though). + DispatchQueue.main.async { + self.delegate?.locationManager(self, didUpdate: [self.lastLocation]) + } + } + + public func stopUpdatingLocation() { + // Do nothing + } + + public func startUpdatingHeading() { + // Do nothing + } + + public func stopUpdatingHeading() { + // Do nothing + } + + public func dismissHeadingCalibrationDisplay() { + // Do nothing + } +} diff --git a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift new file mode 100644 index 0000000..0d087bd --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift @@ -0,0 +1,53 @@ +import MapLibreSwiftDSL +import SnapshotTesting +import XCTest +@testable import MapLibreSwiftUI + +final class MapControlsTests: XCTestCase { + func testEmptyControls() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + // No controls + } + } + } + + func testLogoOnly() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + LogoView() + } + } + } + + func testLogoChangePosition() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + LogoView() + .position(.topLeft) + } + } + } + + func testCompassOnly() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + CompassView() + } + } + } + + func testCompassChangePosition() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + CompassView() + .position(.topLeft) + } + } + } +} diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassChangePosition.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassChangePosition.1.png new file mode 100644 index 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassChangePosition.1.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassOnly.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassOnly.1.png new file mode 100644 index 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassOnly.1.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testEmptyControls.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testEmptyControls.1.png new file mode 100644 index 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testEmptyControls.1.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoChangePosition.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoChangePosition.1.png new file mode 100644 index 0000000..7bbb8f1 Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoChangePosition.1.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoOnly.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoOnly.1.png new file mode 100644 index 0000000..321b769 Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoOnly.1.png differ diff --git a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift index f2f0593..4baacde 100644 --- a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -27,7 +27,7 @@ final class MapViewGestureTests: XCTestCase { // MARK: Gesture Processing - func testTapGesture() { + @MainActor func testTapGesture() { let gesture = MapGesture(method: .tap(numberOfTaps: 2)) { _ in // Do nothing } @@ -52,7 +52,7 @@ final class MapViewGestureTests: XCTestCase { XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1) } - func testLongPressGesture() { + @MainActor func testLongPressGesture() { let gesture = MapGesture(method: .longPress(minimumDuration: 1)) { _ in // Do nothing } diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index 411a7bc..e181a80 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -16,7 +16,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { } } - func testUnchangedCamera() { + @MainActor func testUnchangedCamera() { let camera: MapViewCamera = .default() coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) @@ -50,7 +50,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(count: 0) } - func testCenterCameraUpdate() { + @MainActor func testCenterCameraUpdate() { let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) let newCamera: MapViewCamera = .center(coordinate, zoom: 13) @@ -80,7 +80,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(count: 0) } - func testUserTrackingCameraUpdate() { + @MainActor func testUserTrackingCameraUpdate() { let newCamera: MapViewCamera = .trackUserLocation() coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) @@ -109,7 +109,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(count: 1) } - func testUserTrackingWithCourseCameraUpdate() { + @MainActor func testUserTrackingWithCourseCameraUpdate() { let newCamera: MapViewCamera = .trackUserLocationWithCourse() coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) @@ -138,7 +138,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(count: 1) } - func testUserTrackingWithHeadingUpdate() { + @MainActor func testUserTrackingWithHeadingUpdate() { let newCamera: MapViewCamera = .trackUserLocationWithHeading() coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift index 6dad5c9..0bbbae1 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -1,34 +1,33 @@ import CoreLocation +import SnapshotTesting import XCTest @testable import MapLibreSwiftUI final class CameraStateTests: XCTestCase { + let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + func testCenterCameraState() { - let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) - let state: CameraState = .centered(onCoordinate: expectedCoordinate) - XCTAssertEqual(state, .centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) - XCTAssertEqual( - String(describing: state), - "CameraState.centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)" - ) + let state: CameraState = .centered(onCoordinate: coordinate, zoom: 4, pitch: .free, direction: 42) + XCTAssertEqual(state, .centered(onCoordinate: coordinate, zoom: 4, pitch: .free, direction: 42)) + assertSnapshot(of: state, as: .description) } func testTrackingUserLocation() { - let state: CameraState = .trackingUserLocation - XCTAssertEqual(state, .trackingUserLocation) - XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocation") + let state: CameraState = .trackingUserLocation(zoom: 4, pitch: .free) + XCTAssertEqual(state, .trackingUserLocation(zoom: 4, pitch: .free)) + assertSnapshot(of: state, as: .description) } func testTrackingUserLocationWithHeading() { - let state: CameraState = .trackingUserLocationWithHeading - XCTAssertEqual(state, .trackingUserLocationWithHeading) - XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocationWithHeading") + let state: CameraState = .trackingUserLocationWithHeading(zoom: 4, pitch: .free) + XCTAssertEqual(state, .trackingUserLocationWithHeading(zoom: 4, pitch: .free)) + assertSnapshot(of: state, as: .description) } func testTrackingUserLocationWithCourse() { - let state: CameraState = .trackingUserLocationWithCourse - XCTAssertEqual(state, .trackingUserLocationWithCourse) - XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocationWithCourse") + let state: CameraState = .trackingUserLocationWithCourse(zoom: 4, pitch: .free) + XCTAssertEqual(state, .trackingUserLocationWithCourse(zoom: 4, pitch: .free)) + assertSnapshot(of: state, as: .description) } func testRect() { @@ -38,9 +37,6 @@ final class CameraStateTests: XCTestCase { let state: CameraState = .rect(boundingBox: .init(sw: southwest, ne: northeast)) XCTAssertEqual(state, .rect(boundingBox: .init(sw: southwest, ne: northeast))) - XCTAssertEqual( - String(describing: state), - "CameraState.rect(northeast: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), southwest: CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6))" - ) + assertSnapshot(of: state, as: .description) } } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index 0b27f62..31b0cad 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -1,49 +1,39 @@ import CoreLocation import MapLibre +import SnapshotTesting import XCTest @testable import MapLibreSwiftUI final class MapViewCameraTests: XCTestCase { func testCenterCamera() { - let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) - let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) - let direction: CLLocationDirection = 23 - - let camera = MapViewCamera.center(expectedCoordinate, zoom: 12, pitch: pitch, direction: direction) + let camera = MapViewCamera.center( + CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), + zoom: 5, + pitch: .freeWithinRange(minimum: 12, maximum: 34), + direction: 23 + ) - XCTAssertEqual(camera.state, .centered(onCoordinate: expectedCoordinate)) - XCTAssertEqual(camera.zoom, 12) - XCTAssertEqual(camera.pitch, pitch) - XCTAssertEqual(camera.direction, direction) + assertSnapshot(of: camera, as: .dump) } func testTrackingUserLocation() { let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) - let camera = MapViewCamera.trackUserLocation(pitch: pitch) + let camera = MapViewCamera.trackUserLocation(zoom: 10, pitch: pitch) - XCTAssertEqual(camera.state, .trackingUserLocation) - XCTAssertEqual(camera.zoom, 10) - XCTAssertEqual(camera.pitch, pitch) - XCTAssertEqual(camera.direction, 0) + assertSnapshot(of: camera, as: .dump) } func testTrackUserLocationWithCourse() { let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) let camera = MapViewCamera.trackUserLocationWithCourse(zoom: 18, pitch: pitch) - XCTAssertEqual(camera.state, .trackingUserLocationWithCourse) - XCTAssertEqual(camera.zoom, 18) - XCTAssertEqual(camera.pitch, pitch) - XCTAssertEqual(camera.direction, 0) + assertSnapshot(of: camera, as: .dump) } func testTrackUserLocationWithHeading() { - let camera = MapViewCamera.trackUserLocationWithHeading() + let camera = MapViewCamera.trackUserLocationWithHeading(zoom: 10, pitch: .free) - XCTAssertEqual(camera.state, .trackingUserLocationWithHeading) - XCTAssertEqual(camera.zoom, 10) - XCTAssertEqual(camera.pitch, .free) - XCTAssertEqual(camera.direction, 0) + assertSnapshot(of: camera, as: .dump) } func testBoundingBox() { @@ -52,14 +42,6 @@ final class MapViewCameraTests: XCTestCase { let bounds = MLNCoordinateBounds(sw: southwest, ne: northeast) let camera = MapViewCamera.boundingBox(bounds) - XCTAssertEqual( - camera.state, - .rect(boundingBox: bounds, edgePadding: .init(top: 20, left: 20, bottom: 20, right: 20)) - ) - XCTAssertEqual(camera.zoom, 1) - XCTAssertEqual(camera.pitch, .free) - XCTAssertEqual(camera.direction, 0) + assertSnapshot(of: camera, as: .dump) } - - // TODO: Add additional camera tests once behaviors are added (e.g. rect) } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt new file mode 100644 index 0000000..6d2f3a0 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt @@ -0,0 +1 @@ +CameraState.centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), zoom: 4.0, pitch: free, direction: 42.0) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testRect.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testRect.1.txt new file mode 100644 index 0000000..be59d49 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testRect.1.txt @@ -0,0 +1 @@ +CameraState.rect(northeast: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), southwest: CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6), edgePadding: UIEdgeInsets(top: 20.0, left: 20.0, bottom: 20.0, right: 20.0)) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt new file mode 100644 index 0000000..8c216f7 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt @@ -0,0 +1 @@ +CameraState.trackingUserLocation(zoom: (4.0, MapLibreSwiftUI.CameraPitch.free)) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt new file mode 100644 index 0000000..639e899 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt @@ -0,0 +1 @@ +CameraState.trackingUserLocationWithCourse(zoom: (4.0, MapLibreSwiftUI.CameraPitch.free)) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt new file mode 100644 index 0000000..13adebe --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt @@ -0,0 +1 @@ +CameraState.trackingUserLocationWithHeading(zoom: (4.0, MapLibreSwiftUI.CameraPitch.free)) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testBoundingBox.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testBoundingBox.1.txt new file mode 100644 index 0000000..092ae5f --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testBoundingBox.1.txt @@ -0,0 +1,17 @@ +▿ MapViewCamera + ▿ lastReasonForChange: Optional + - some: CameraChangeReason.programmatic + ▿ state: CameraState + ▿ rect: (2 elements) + ▿ boundingBox: MLNCoordinateBounds + ▿ ne: CLLocationCoordinate2D + - latitude: 24.6993808 + - longitude: 46.7709285 + ▿ sw: CLLocationCoordinate2D + - latitude: 24.6056011 + - longitude: 46.67369842529297 + ▿ edgePadding: UIEdgeInsets + - bottom: 20.0 + - left: 20.0 + - right: 20.0 + - top: 20.0 diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt new file mode 100644 index 0000000..d3c277d --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt @@ -0,0 +1,13 @@ +▿ MapViewCamera + - lastReasonForChange: Optional.none + ▿ state: CameraState + ▿ centered: (4 elements) + ▿ onCoordinate: CLLocationCoordinate2D + - latitude: 12.3 + - longitude: 23.4 + - zoom: 5.0 + ▿ pitch: CameraPitch + ▿ freeWithinRange: (2 elements) + - minimum: 12.0 + - maximum: 34.0 + - direction: 23.0 diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt new file mode 100644 index 0000000..4c5c2d5 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt @@ -0,0 +1,10 @@ +▿ MapViewCamera + ▿ lastReasonForChange: Optional + - some: CameraChangeReason.programmatic + ▿ state: CameraState + ▿ trackingUserLocationWithCourse: (2 elements) + - zoom: 18.0 + ▿ pitch: CameraPitch + ▿ freeWithinRange: (2 elements) + - minimum: 12.0 + - maximum: 34.0 diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt new file mode 100644 index 0000000..47942b2 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt @@ -0,0 +1,7 @@ +▿ MapViewCamera + ▿ lastReasonForChange: Optional + - some: CameraChangeReason.programmatic + ▿ state: CameraState + ▿ trackingUserLocationWithHeading: (2 elements) + - zoom: 10.0 + - pitch: CameraPitch.free diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt new file mode 100644 index 0000000..922b6bb --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt @@ -0,0 +1,10 @@ +▿ MapViewCamera + ▿ lastReasonForChange: Optional + - some: CameraChangeReason.programmatic + ▿ state: CameraState + ▿ trackingUserLocation: (2 elements) + - zoom: 10.0 + ▿ pitch: CameraPitch + ▿ freeWithinRange: (2 elements) + - minimum: 12.0 + - maximum: 34.0