diff --git a/Package.resolved b/Package.resolved index 2fb550f..b6caa1e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e5123889438e3e8a4097dfcd17eaffeef3252f808a02de32d517dd5e9c8ead14", + "originHash" : "3ef0341fce60ebda3bd350b1bafb9cebcd8654136f438ae028750da294ee7bf8", "pins" : [ { "identity" : "maplibre-gl-native-distribution", @@ -19,6 +19,15 @@ "version" : "0.3.1" } }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, { "identity" : "swift-macro-testing", "kind" : "remoteSourceControl", @@ -33,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "2e6a85b73fc14e27d7542165ae73b1a10516ca9a", - "version" : "1.17.7" + "revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b", + "version" : "1.18.3" } }, { diff --git a/Package.swift b/Package.swift index e5b9c5d..bab2d3d 100644 --- a/Package.swift +++ b/Package.swift @@ -30,7 +30,7 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-syntax.git", "509.0.0" ..< "601.0.0"), // Testing .package(url: "https://github.com/Kolos65/Mockable.git", from: "0.3.1"), - .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.7"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.3"), // Macro Testing .package(url: "https://github.com/pointfreeco/swift-macro-testing", .upToNextMinor(from: "0.6.0")), ], diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift index 6f76657..a11513c 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift @@ -9,6 +9,8 @@ public protocol MLNMapViewCameraUpdating: AnyObject { @MainActor var userTrackingMode: MLNUserTrackingMode { get set } @MainActor func setUserTrackingMode(_ mode: MLNUserTrackingMode, animated: Bool, completionHandler: (() -> Void)?) + @MainActor var centerCoordinate: CLLocationCoordinate2D { get set } + @MainActor var zoomLevel: Double { get set } @MainActor var minimumPitch: CGFloat { get set } @MainActor var maximumPitch: CGFloat { get set } @MainActor var direction: CLLocationDirection { get set } diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index e84d469..02a8c5f 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -76,9 +76,7 @@ public struct MapView: UIViewControllerRepresentab // 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(controller, runUnsafe: false) - context.coordinator.suppressCameraUpdatePropagation = false controller.mapView.locationManager = locationManager @@ -87,9 +85,12 @@ public struct MapView: UIViewControllerRepresentab controller.mapView.styleURL = styleURL } - context.coordinator.updateCamera(mapView: controller.mapView, - camera: $camera.wrappedValue, - animated: false) + context.coordinator.applyCameraChangeFromStateUpdate( + controller.mapView, + camera: camera, + animated: false + ) + controller.mapView.locationManager = controller.mapView.locationManager // Link the style loaded to the coordinator that emits the delegate event. @@ -116,9 +117,11 @@ public struct MapView: UIViewControllerRepresentab let isStyleLoaded = uiViewController.mapView.style != nil if cameraDisabled == false { - context.coordinator.updateCamera(mapView: uiViewController.mapView, - camera: camera, - animated: isStyleLoaded) + context.coordinator.applyCameraChangeFromStateUpdate( + uiViewController.mapView, + camera: camera, + animated: isStyleLoaded + ) } } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 35954fb..f3e0a2c 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -2,7 +2,8 @@ import Foundation import MapLibre import MapLibreSwiftDSL -public class MapViewCoordinator: NSObject, @preconcurrency MLNMapViewDelegate { +public class MapViewCoordinator: NSObject, @preconcurrency +MLNMapViewDelegate { // This must be weak, the UIViewRepresentable owns the MLNMapView. weak var mapView: MLNMapView? var parent: MapView @@ -13,21 +14,25 @@ public class MapViewCoordinator: NSObject, @precon private var snapshotCamera: MapViewCamera? private var snapshotStyleSource: MapStyleSource? - // 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 + /// An asyncronous task that applies the camera update and awaits the map to reach idle state. + var cameraUpdateTask: Task? + + /// This continuation is closed with the MapView becomes idle at the end of a camera update. + /// A canceled camera update (think a gesture midway through another camera update) + /// should cancel and reset this. + var cameraUpdateContinuation: CheckedContinuation? var onStyleLoaded: ((MLNStyle) -> Void)? var onGesture: (MLNMapView, UIGestureRecognizer) -> Void var onViewProxyChanged: (MapViewProxy) -> Void var proxyUpdateMode: ProxyUpdateMode - init(parent: MapView, - onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void, - onViewProxyChanged: @escaping (MapViewProxy) -> Void, - proxyUpdateMode: ProxyUpdateMode) - { + init( + parent: MapView, + onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void, + onViewProxyChanged: @escaping (MapViewProxy) -> Void, + proxyUpdateMode: ProxyUpdateMode + ) { self.parent = parent self.onGesture = onGesture self.onViewProxyChanged = onViewProxyChanged @@ -44,170 +49,6 @@ public class MapViewCoordinator: NSObject, @precon onGesture(mapView, sender) } - // MARK: - Coordinator API - Camera + Manipulation - - /// Update the camera based on the MapViewCamera binding change. - /// - /// - Parameters: - /// - mapView: This is the camera updating protocol representation of the MLNMapView. This allows mockable testing - /// for - /// camera related MLNMapView functionality. - /// - camera: The new camera from the binding. - /// - animated: Whether to animate. - @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, - zoom: zoom, - pitch: pitch, - pitchRange: pitchRange, - direction: direction - ): - mapView.userTrackingMode = .none - - if mapView.frame.size == .zero { - // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, - // so let's do something else instead. - mapView.setCenter(coordinate, - zoomLevel: zoom, - direction: direction, - animated: animated) - - // this is a workaround for no camera - minimum and maximum will be reset below, but this adjusts it. - mapView.minimumPitch = pitch - mapView.maximumPitch = pitch - - } else { - let camera = mapView.camera - camera.centerCoordinate = coordinate - camera.heading = direction - camera.pitch = pitch - - let altitude = MLNAltitudeForZoomLevel(zoom, pitch, coordinate.latitude, mapView.frame.size) - camera.altitude = altitude - mapView.setCamera(camera, animated: animated) - } - - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - case let .trackingUserLocation(zoom: zoom, pitch: pitch, pitchRange: pitchRange, direction: direction): - if mapView.frame.size == .zero { - // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, - // so let's do something else instead. - // Needs to be non-animated or else it messes up following - - mapView.userTrackingMode = .follow - - mapView.setZoomLevel(zoom, animated: false) - mapView.direction = direction - - mapView.minimumPitch = pitch - mapView.maximumPitch = pitch - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - - } else { - mapView.setUserTrackingMode(.follow, animated: animated) { - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - let camera = mapView.camera - camera.heading = direction - camera.pitch = pitch - - let altitude = MLNAltitudeForZoomLevel( - zoom, - pitch, - mapView.camera.centerCoordinate.latitude, - mapView.frame.size - ) - camera.altitude = altitude - mapView.setCamera(camera, animated: animated) - } - } - case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch, pitchRange: pitchRange): - if mapView.frame.size == .zero { - // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, - // so let's do something else instead. - // Needs to be non-animated or else it messes up following - - mapView.userTrackingMode = .followWithHeading - mapView.setZoomLevel(zoom, animated: false) - mapView.minimumPitch = pitch - mapView.maximumPitch = pitch - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - - } else { - mapView.setUserTrackingMode(.followWithHeading, animated: animated) { - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - let camera = mapView.camera - - let altitude = MLNAltitudeForZoomLevel( - zoom, - pitch, - mapView.camera.centerCoordinate.latitude, - mapView.frame.size - ) - camera.altitude = altitude - camera.pitch = pitch - mapView.setCamera(camera, animated: animated) - } - } - case let .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch, pitchRange: pitchRange): - if mapView.frame.size == .zero { - mapView.userTrackingMode = .followWithCourse - // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, - // so let's do something else instead. - // Needs to be non-animated or else it messes up following - - mapView.setZoomLevel(zoom, animated: false) - mapView.minimumPitch = pitch - mapView.maximumPitch = pitch - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - - } else { - mapView.setUserTrackingMode(.followWithCourse, animated: animated) { - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - - let camera = mapView.camera - - let altitude = MLNAltitudeForZoomLevel( - zoom, - pitch, - mapView.camera.centerCoordinate.latitude, - mapView.frame.size - ) - camera.altitude = altitude - camera.pitch = pitch - mapView.setCamera(camera, animated: animated) - } - } - case let .rect(boundingBox, padding): - mapView.setVisibleCoordinateBounds(boundingBox, - edgePadding: padding, - animated: animated, - completionHandler: nil) - case .showcase: - // TODO: Need a method these/or to finalize a goal here. - break - } - - snapshotCamera = camera - } - // MARK: - Coordinator API - Styles + Layers @MainActor func updateStyleSource(_ source: MapStyleSource, mapView: MLNMapView) { @@ -319,78 +160,271 @@ public class MapViewCoordinator: NSObject, @precon } } - // MARK: - MLNMapViewDelegate + // MARK: - Camera - public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { - addLayers(to: mglStyle) - onStyleLoaded?(mglStyle) - } + /// Apply a camera change based on the current @State of the camera. + /// + /// - Parameters: + /// - mapView: The MapView that the state is being updated for. + /// - camera: The new camera state + /// - animated: Whether the camera change should be animated. Defaults to `true`. + @MainActor func applyCameraChangeFromStateUpdate( + _ mapView: MLNMapViewCameraUpdating, + camera: MapViewCamera, + animated: Bool = true + ) { + guard camera != snapshotCamera else { + // No action - camera has not changed. + return + } + + snapshotCamera = camera - // MARK: MapViewCamera - - @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 - 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 + // Cancel any existing camera update completion task. + cameraUpdateTask?.cancel() + cameraUpdateContinuation = nil + + cameraUpdateTask = Task { @MainActor in + return await withCheckedContinuation { continuation in + // Store the continuation to be resumed in mapViewDidBecomeIdle + cameraUpdateContinuation = continuation + + // Apply the camera settings based on camera.state + switch camera.state { + case let .centered( + onCoordinate: coordinate, + zoom: zoom, + pitch: pitch, + pitchRange: pitchRange, + direction: direction + ): + mapView.userTrackingMode = .none + + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + mapView.setCenter(coordinate, + zoomLevel: zoom, + direction: direction, + animated: animated) + + // this is a workaround for no camera - minimum and maximum will be reset below, but this + // adjusts it. + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + + } else { + let camera = mapView.camera + camera.centerCoordinate = coordinate + camera.heading = direction + camera.pitch = pitch + + let altitude = MLNAltitudeForZoomLevel(zoom, pitch, coordinate.latitude, mapView.frame.size) + camera.altitude = altitude + mapView.setCamera(camera, animated: animated) + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + case let .trackingUserLocation(zoom: zoom, pitch: pitch, pitchRange: pitchRange, direction: direction): + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + // Needs to be non-animated or else it messes up following + + mapView.userTrackingMode = .follow + + mapView.setZoomLevel(zoom, animated: false) + mapView.direction = direction + + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + + } else { + mapView.setUserTrackingMode(.follow, animated: animated) { + guard mapView.userTrackingMode == .follow else { + // Exit early if the user tracking mode is no longer set to follow + return + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + let camera = mapView.camera + camera.heading = direction + camera.pitch = pitch + + let altitude = MLNAltitudeForZoomLevel( + zoom, + pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + camera.altitude = altitude + mapView.setCamera(camera, animated: animated) + } + } + case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch, pitchRange: pitchRange): + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + // Needs to be non-animated or else it messes up following + + mapView.userTrackingMode = .followWithHeading + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + + } else { + mapView.setUserTrackingMode(.followWithHeading, animated: animated) { + guard mapView.userTrackingMode == .followWithHeading else { + // Exit early if the user tracking mode is no longer set to followWithHeading + return + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + let camera = mapView.camera + + let altitude = MLNAltitudeForZoomLevel( + zoom, + pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + camera.altitude = altitude + camera.pitch = pitch + mapView.setCamera(camera, animated: animated) + } + } + case let .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch, pitchRange: pitchRange): + if mapView.frame.size == .zero { + mapView.userTrackingMode = .followWithCourse + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + // Needs to be non-animated or else it messes up following + + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + + } else { + mapView.setUserTrackingMode(.followWithCourse, animated: animated) { + guard mapView.userTrackingMode == .followWithCourse else { + // Exit early if the user tracking mode is no longer set to followWithCourse + return + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + + let camera = mapView.camera + + let altitude = MLNAltitudeForZoomLevel( + zoom, + pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + camera.altitude = altitude + camera.pitch = pitch + mapView.setCamera(camera, animated: animated) + } + } + case let .rect(boundingBox, padding): + mapView.setVisibleCoordinateBounds(boundingBox, + edgePadding: padding, + animated: animated, + completionHandler: nil) + case .showcase: + // TODO: Need a method these/or to finalize a goal here. + break + } + } } + } - guard !isProgrammaticallyTracking else { - // Programmatic tracking is still active, we can ignore camera updates until we unset/fail this boolean - // check + /// Apply a gesture based camera change. This behavior will only be triggered on the first gesture and will + /// only be completed when the mapView becomes idle. + /// + /// - Parameters: + /// - mapView: The MapView that is being manipulated by a gesture. + /// - reason: The reason for the camera change. + @MainActor func applyCameraChangeFromGesture(_ mapView: MLNMapViewCameraUpdating, reason: CameraChangeReason) { + guard cameraUpdateTask == nil else { + // Gestures emit many updates, so we only want to launch the first one and rely on idle to close the event. 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. - - // Determine camera pitch range based on current MapView settings - let pitchRange: CameraPitchRange = if mapView.minimumPitch == 0 && mapView.maximumPitch > 59.9 { - .free - } else if mapView.minimumPitch == mapView.maximumPitch { - .fixed(mapView.minimumPitch) - } else { - .freeWithinRange(minimum: mapView.minimumPitch, maximum: mapView.maximumPitch) - } + cameraUpdateTask = Task { @MainActor in + await withCheckedContinuation { continuation in + // Store the continuation to be resumed in mapViewDidBecomeIdle + cameraUpdateContinuation = continuation - let newCamera: MapViewCamera = .center(mapView.centerCoordinate, - zoom: mapView.zoomLevel, - pitch: mapView.camera.pitch, - pitchRange: pitchRange, - direction: mapView.direction, - reason: CameraChangeReason(reason)) - snapshotCamera = newCamera - DispatchQueue.main.async { - self.parent.camera = newCamera + let pitchRange: CameraPitchRange = if mapView.minimumPitch == 0 && mapView.maximumPitch > 59.9 { + .free + } else if mapView.minimumPitch == mapView.maximumPitch { + .fixed(mapView.minimumPitch) + } else { + .freeWithinRange(minimum: mapView.minimumPitch, maximum: mapView.maximumPitch) + } + + parent.camera = .center( + mapView.centerCoordinate, + zoom: mapView.zoomLevel, + pitch: mapView.camera.pitch, + pitchRange: pitchRange, + direction: mapView.direction, + reason: reason + ) + } } } + // MARK: - MLNMapViewDelegate + + public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { + addLayers(to: mglStyle) + onStyleLoaded?(mglStyle) + } + /// The MapView's region has changed with a specific reason. - public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { + public func mapView( + _ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool + ) { // TODO: We could put this in regionIsChangingWith if we calculate significant change/debounce. MainActor.assumeIsolated { // regionIsChangingWith is not called for the final update, so we need to call updateViewProxy // in both modes here. updateViewProxy(mapView: mapView, reason: reason) - guard !suppressCameraUpdatePropagation else { + guard let changeReason = CameraChangeReason(reason) else { + // Invalid state - we cannot process this camera change. return } - updateParentCamera(mapView: mapView, reason: reason) + switch changeReason { + case .gesturePan, .gesturePinch, .gestureRotate, + .gestureZoomIn, .gestureZoomOut, .gestureOneFingerZoom, + .gestureTilt: + applyCameraChangeFromGesture(mapView, reason: changeReason) + default: + break + } } } + public func mapViewDidBecomeIdle(_: MLNMapView) { + cameraUpdateContinuation?.resume() + cameraUpdateContinuation = nil + cameraUpdateTask = nil + } + @MainActor public func mapView(_ mapView: MLNMapView, regionIsChangingWith reason: MLNCameraChangeReason) { if proxyUpdateMode == .realtime { @@ -402,8 +436,10 @@ public class MapViewCoordinator: NSObject, @precon @MainActor private func updateViewProxy(mapView: MLNMapView, reason: MLNCameraChangeReason) { // Calculate the Raw "ViewProxy" - let calculatedViewProxy = MapViewProxy(mapView: mapView, - lastReasonForChange: CameraChangeReason(reason)) + let calculatedViewProxy = MapViewProxy( + mapView: mapView, + lastReasonForChange: CameraChangeReason(reason) + ) onViewProxyChanged(calculatedViewProxy) } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index ab81192..1038d64 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -2,7 +2,7 @@ import Foundation import MapLibre /// The CameraState is used to understand the current context of the MapView's camera. -public enum CameraState: Hashable { +public enum CameraState: Hashable, Equatable, Sendable { /// Centered on a coordinate case centered( onCoordinate: CLLocationCoordinate2D, diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift index f6181af..ac0e779 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -5,7 +5,7 @@ import MapLibre /// The SwiftUI MapViewCamera. /// /// This manages the camera state within the MapView. -public struct MapViewCamera: Hashable { +public struct MapViewCamera: Hashable, Equatable, Sendable { public enum Defaults { public static let coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0) public static let zoom: Double = 10 diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index d4359c4..8acb94a 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -1,6 +1,7 @@ import CoreLocation import Mockable import XCTest + @testable import MapLibreSwiftUI final class MapViewCoordinatorCameraTests: XCTestCase { @@ -13,27 +14,39 @@ final class MapViewCoordinatorCameraTests: XCTestCase { maplibreMapView = MockMLNMapViewCameraUpdating() given(maplibreMapView).frame.willReturn(.zero) mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) - coordinator = MapView.Coordinator(parent: mapView, onGesture: { _, _ in - // No action - }, onViewProxyChanged: { _ in - // No action - }, proxyUpdateMode: .onFinish) + coordinator = MapView.Coordinator( + parent: mapView, + onGesture: { _, _ in + // No action + }, + onViewProxyChanged: { _ in + // No action + }, proxyUpdateMode: .onFinish + ) } - @MainActor func testUnchangedCamera() { - let camera: MapViewCamera = .default() + @MainActor func testUnchangedCamera() async throws { + let coordinate = CLLocationCoordinate2D(latitude: 45.0, longitude: -127.0) + let camera: MapViewCamera = .center(coordinate, zoom: 10) given(maplibreMapView) - .setCenter(.any, - zoomLevel: .any, - direction: .any, - animated: .any) + .setCenter( + .any, + zoomLevel: .any, + direction: .any, + animated: .any + ) .willReturn() - coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) - // Run a second update. We're testing that the snapshotCamera correctly exits the function - // when nothing changed. - coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) + try await simulateCameraUpdateAndWait { + self.coordinator.applyCameraChangeFromStateUpdate( + self.maplibreMapView, camera: camera, animated: false + ) + } + + coordinator.applyCameraChangeFromStateUpdate( + maplibreMapView, camera: camera, animated: false + ) // All of the actions only allow 1 count of set even though we've run the action twice. // This verifies the comment above. @@ -42,10 +55,12 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .setCalled(1) verify(maplibreMapView) - .setCenter(.value(MapViewCamera.Defaults.coordinate), - zoomLevel: .value(10), - direction: .value(0), - animated: .value(false)) + .setCenter( + .value(coordinate), + zoomLevel: .value(10), + direction: .value(0), + animated: .value(false) + ) .called(1) // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the @@ -67,28 +82,36 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(0) } - @MainActor func testCenterCameraUpdate() { + @MainActor func testCenterCameraUpdate() async throws { let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) let newCamera: MapViewCamera = .center(coordinate, zoom: 13) given(maplibreMapView) - .setCenter(.any, - zoomLevel: .any, - direction: .any, - animated: .any) + .setCenter( + .any, + zoomLevel: .any, + direction: .any, + animated: .any + ) .willReturn() - coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + try await simulateCameraUpdateAndWait { + self.coordinator.applyCameraChangeFromStateUpdate( + self.maplibreMapView, camera: newCamera, animated: false + ) + } verify(maplibreMapView) .userTrackingMode(newValue: .value(.none)) .setCalled(1) verify(maplibreMapView) - .setCenter(.value(coordinate), - zoomLevel: .value(13), - direction: .value(0), - animated: .value(false)) + .setCenter( + .value(coordinate), + zoomLevel: .value(13), + direction: .value(0), + animated: .value(false) + ) .called(1) // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the @@ -110,24 +133,30 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(0) } - @MainActor func testUserTrackingCameraUpdate() { + @MainActor func testUserTrackingCameraUpdate() async throws { let newCamera: MapViewCamera = .trackUserLocation() given(maplibreMapView) .setZoomLevel(.any, animated: .any) .willReturn() - coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + try await simulateCameraUpdateAndWait { + self.coordinator.applyCameraChangeFromStateUpdate( + self.maplibreMapView, camera: newCamera, animated: false + ) + } verify(maplibreMapView) .userTrackingMode(newValue: .value(.follow)) .setCalled(1) verify(maplibreMapView) - .setCenter(.any, - zoomLevel: .any, - direction: .any, - animated: .any) + .setCenter( + .any, + zoomLevel: .any, + direction: .any, + animated: .any + ) .called(0) // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the @@ -149,24 +178,30 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(1) } - @MainActor func testUserTrackingWithCourseCameraUpdate() { + @MainActor func testUserTrackingWithCourseCameraUpdate() async throws { let newCamera: MapViewCamera = .trackUserLocationWithCourse() given(maplibreMapView) .setZoomLevel(.any, animated: .any) .willReturn() - coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + try await simulateCameraUpdateAndWait { + self.coordinator.applyCameraChangeFromStateUpdate( + self.maplibreMapView, camera: newCamera, animated: false + ) + } verify(maplibreMapView) .userTrackingMode(newValue: .value(.followWithCourse)) .setCalled(1) verify(maplibreMapView) - .setCenter(.any, - zoomLevel: .any, - direction: .any, - animated: .any) + .setCenter( + .any, + zoomLevel: .any, + direction: .any, + animated: .any + ) .called(0) // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the @@ -188,24 +223,30 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(1) } - @MainActor func testUserTrackingWithHeadingUpdate() { + @MainActor func testUserTrackingWithHeadingUpdate() async throws { let newCamera: MapViewCamera = .trackUserLocationWithHeading() given(maplibreMapView) .setZoomLevel(.any, animated: .any) .willReturn() - coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + try await simulateCameraUpdateAndWait { + self.coordinator.applyCameraChangeFromStateUpdate( + self.maplibreMapView, camera: newCamera, animated: false + ) + } verify(maplibreMapView) .userTrackingMode(newValue: .value(.followWithHeading)) .setCalled(1) verify(maplibreMapView) - .setCenter(.any, - zoomLevel: .any, - direction: .any, - animated: .any) + .setCenter( + .any, + zoomLevel: .any, + direction: .any, + animated: .any + ) .called(0) // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the @@ -228,4 +269,25 @@ final class MapViewCoordinatorCameraTests: XCTestCase { } // TODO: Test Rect & Showcase once we build it! + + @MainActor + private func simulateCameraUpdateAndWait(action: @escaping () -> Void) async throws { + let expectation = XCTestExpectation(description: "Camera update completed") + + Task { + // Execute the provided camera action + action() + + // Simulate the map becoming idle after a short delay + try await Task.sleep(nanoseconds: 100 * NSEC_PER_MSEC) + coordinator.cameraUpdateContinuation?.resume(returning: ()) + + // Wait for the update task to complete + _ = await coordinator.cameraUpdateTask?.value + + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 1.0) + } }