From 5a347ddb01419ecbdbd36682dd3d5d74150bba55 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Tue, 29 Apr 2025 07:16:02 -0700 Subject: [PATCH 1/7] feat: task based camera --- Sources/MapLibreSwiftUI/MapView.swift | 22 +- .../MapLibreSwiftUI/MapViewCoordinator.swift | 264 +++--------------- .../MapViewCoordinatorCamera.swift | 228 +++++++++++++++ .../Models/MapCamera/CameraState.swift | 2 +- .../Models/MapCamera/MapViewCamera.swift | 2 +- 5 files changed, 279 insertions(+), 239 deletions(-) create mode 100644 Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 5f24829..f9ab4c5 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -64,9 +64,9 @@ 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 + // context.coordinator.suppressCameraUpdatePropagation = true applyModifiers(controller, runUnsafe: false) - context.coordinator.suppressCameraUpdatePropagation = false + // context.coordinator.suppressCameraUpdatePropagation = false controller.mapView.locationManager = locationManager @@ -75,9 +75,13 @@ public struct MapView: UIViewControllerRepresentab controller.mapView.styleURL = styleURL } - context.coordinator.updateCamera(mapView: controller.mapView, - camera: $camera.wrappedValue, - animated: false) + print("MapView setting initial camera to \(camera.state)") + 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. @@ -104,9 +108,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..70496b1 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -2,7 +2,9 @@ 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 @@ -10,24 +12,23 @@ public class MapViewCoordinator: NSObject, @precon // Storage of variables as they were previously; these are snapshot // every update cycle so we can avoid unnecessary updates private var snapshotUserLayers: [StyleLayerDefinition] = [] - private var snapshotCamera: MapViewCamera? + 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 + var cameraUpdateTask: Task? + 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 +45,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) { @@ -326,71 +163,39 @@ public class MapViewCoordinator: NSObject, @precon onStyleLoaded?(mglStyle) } - // 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 - } - - 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. - - // 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) - } - - 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 - } - } - /// 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(_ mapView: MLNMapView) { + cameraUpdateContinuation?.resume() + cameraUpdateContinuation = nil + cameraUpdateTask = nil + } + @MainActor public func mapView(_ mapView: MLNMapView, regionIsChangingWith reason: MLNCameraChangeReason) { if proxyUpdateMode == .realtime { @@ -402,8 +207,9 @@ 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/MapViewCoordinatorCamera.swift b/Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift new file mode 100644 index 0000000..212ae26 --- /dev/null +++ b/Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift @@ -0,0 +1,228 @@ +import MapLibre + +/// MapViewCamera application behaviors for the MapViewCoordinator. +extension MapViewCoordinator { + + /// 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: MLNMapView, + camera: MapViewCamera, + animated: Bool = true + ) { + guard camera != snapshotCamera else { + // No action - camera has not changed. + return + } + + snapshotCamera = camera + + // 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 + } + } + } + } + + /// 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. + func applyCameraChangeFromGesture(_ mapView: MLNMapView, 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 + } + + cameraUpdateTask = Task { @MainActor in + return await withCheckedContinuation { continuation in + // Store the continuation to be resumed in mapViewDidBecomeIdle + cameraUpdateContinuation = continuation + + 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) + } + } + } +} 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 From 1c1abeea9c9b2ed9fa14116d27a8c6b7930452c2 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Tue, 29 Apr 2025 21:17:14 -0700 Subject: [PATCH 2/7] fix: modern concurrency camera --- Package.resolved | 15 +- Package.swift | 2 +- .../MapLibre/MLNMapViewCameraUpdating.swift | 2 + Sources/MapLibreSwiftUI/MapView.swift | 2 +- .../MapLibreSwiftUI/MapViewCoordinator.swift | 9 +- .../MapViewCoordinatorCamera.swift | 70 ++++---- .../MapViewCoordinatorCameraTests.swift | 156 ++++++++++++------ 7 files changed, 164 insertions(+), 92 deletions(-) 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 ac63981..721bea2 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -93,7 +93,7 @@ public struct MapView: UIViewControllerRepresentab camera: camera, animated: false ) - + controller.mapView.locationManager = controller.mapView.locationManager // Link the style loaded to the coordinator that emits the delegate event. diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 70496b1..117ede5 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -3,8 +3,7 @@ import MapLibre import MapLibreSwiftDSL public class MapViewCoordinator: NSObject, @preconcurrency - MLNMapViewDelegate -{ +MLNMapViewDelegate { // This must be weak, the UIViewRepresentable owns the MLNMapView. weak var mapView: MLNMapView? var parent: MapView @@ -179,7 +178,6 @@ public class MapViewCoordinator: NSObject, @precon } switch changeReason { - case .gesturePan, .gesturePinch, .gestureRotate, .gestureZoomIn, .gestureZoomOut, .gestureOneFingerZoom, .gestureTilt: @@ -190,7 +188,7 @@ public class MapViewCoordinator: NSObject, @precon } } - public func mapViewDidBecomeIdle(_ mapView: MLNMapView) { + public func mapViewDidBecomeIdle(_: MLNMapView) { cameraUpdateContinuation?.resume() cameraUpdateContinuation = nil cameraUpdateTask = nil @@ -209,7 +207,8 @@ public class MapViewCoordinator: NSObject, @precon // Calculate the Raw "ViewProxy" let calculatedViewProxy = MapViewProxy( mapView: mapView, - lastReasonForChange: CameraChangeReason(reason)) + lastReasonForChange: CameraChangeReason(reason) + ) onViewProxyChanged(calculatedViewProxy) } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift b/Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift index 212ae26..da0b4b4 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift @@ -2,16 +2,14 @@ import MapLibre /// MapViewCamera application behaviors for the MapViewCoordinator. extension MapViewCoordinator { - /// 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: MLNMapView, + @MainActor func applyCameraChangeFromStateUpdate( + _ mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool = true ) { @@ -19,13 +17,13 @@ extension MapViewCoordinator { // No action - camera has not changed. return } - + snapshotCamera = camera - + // 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 @@ -41,7 +39,7 @@ extension MapViewCoordinator { 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. @@ -49,22 +47,23 @@ extension MapViewCoordinator { zoomLevel: zoom, direction: direction, animated: animated) - - // this is a workaround for no camera - minimum and maximum will be reset below, but this adjusts it. + + // 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): @@ -72,30 +71,30 @@ extension MapViewCoordinator { // 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, @@ -111,25 +110,25 @@ extension MapViewCoordinator { // 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, @@ -147,25 +146,25 @@ extension MapViewCoordinator { // 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, @@ -189,24 +188,24 @@ extension MapViewCoordinator { } } } - + /// 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. - func applyCameraChangeFromGesture(_ mapView: MLNMapView, reason: CameraChangeReason) { + @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 } - + cameraUpdateTask = Task { @MainActor in - return await withCheckedContinuation { continuation in + await withCheckedContinuation { continuation in // Store the continuation to be resumed in mapViewDidBecomeIdle cameraUpdateContinuation = continuation - + let pitchRange: CameraPitchRange = if mapView.minimumPitch == 0 && mapView.maximumPitch > 59.9 { .free } else if mapView.minimumPitch == mapView.maximumPitch { @@ -214,14 +213,15 @@ extension MapViewCoordinator { } 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) + reason: reason + ) } } } diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index d4359c4..90b6a8c 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_000_000) + coordinator.cameraUpdateContinuation?.resume(returning: ()) + + // Wait for the update task to complete + _ = await coordinator.cameraUpdateTask?.value + + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 1.0) + } } From 185343a3259bb896becd30964b2ef2ab149decca Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Tue, 29 Apr 2025 21:20:32 -0700 Subject: [PATCH 3/7] fix: modern concurrency camera --- Sources/MapLibreSwiftUI/MapView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 721bea2..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,7 +85,6 @@ public struct MapView: UIViewControllerRepresentab controller.mapView.styleURL = styleURL } - print("MapView setting initial camera to \(camera.state)") context.coordinator.applyCameraChangeFromStateUpdate( controller.mapView, camera: camera, From 42478979449d8d05282e1ec8e07b0874ccd456b6 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Thu, 1 May 2025 10:38:42 -0700 Subject: [PATCH 4/7] Update MapViewCoordinatorCameraTests.swift Co-authored-by: Ian Wagner --- .../MapViewCoordinator/MapViewCoordinatorCameraTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index 90b6a8c..d740262 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -279,7 +279,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { action() // Simulate the map becoming idle after a short delay - try await Task.sleep(nanoseconds: 100_000_000) + try await Task.sleep(for: .milliseconds(100)) coordinator.cameraUpdateContinuation?.resume(returning: ()) // Wait for the update task to complete From f347a9e3c6515cfe24fef98c377d8b9144bcbd9d Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Thu, 1 May 2025 18:19:58 -0700 Subject: [PATCH 5/7] Correcting task sleep --- .../MapViewCoordinator/MapViewCoordinatorCameraTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index d740262..8acb94a 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -279,7 +279,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { action() // Simulate the map becoming idle after a short delay - try await Task.sleep(for: .milliseconds(100)) + try await Task.sleep(nanoseconds: 100 * NSEC_PER_MSEC) coordinator.cameraUpdateContinuation?.resume(returning: ()) // Wait for the update task to complete From e0ab880d96e60fd30440d943cf220e00912a4fb3 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Thu, 1 May 2025 18:26:38 -0700 Subject: [PATCH 6/7] Adds docs, reverts structure for private --- .../MapLibreSwiftUI/MapViewCoordinator.swift | 234 +++++++++++++++++- .../MapViewCoordinatorCamera.swift | 228 ----------------- 2 files changed, 233 insertions(+), 229 deletions(-) delete mode 100644 Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 117ede5..75e4704 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -11,10 +11,15 @@ MLNMapViewDelegate { // Storage of variables as they were previously; these are snapshot // every update cycle so we can avoid unnecessary updates private var snapshotUserLayers: [StyleLayerDefinition] = [] - var snapshotCamera: MapViewCamera? + private var snapshotCamera: MapViewCamera? private var snapshotStyleSource: MapStyleSource? + /// 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)? @@ -155,6 +160,233 @@ MLNMapViewDelegate { } } + // MARK: - Camera + + /// 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 + + // 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 + } + } + } + } + + /// 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 + } + + cameraUpdateTask = Task { @MainActor in + await withCheckedContinuation { continuation in + // Store the continuation to be resumed in mapViewDidBecomeIdle + cameraUpdateContinuation = continuation + + 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) { diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift b/Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift deleted file mode 100644 index da0b4b4..0000000 --- a/Sources/MapLibreSwiftUI/MapViewCoordinatorCamera.swift +++ /dev/null @@ -1,228 +0,0 @@ -import MapLibre - -/// MapViewCamera application behaviors for the MapViewCoordinator. -extension MapViewCoordinator { - /// 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 - - // 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 - } - } - } - } - - /// 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 - } - - cameraUpdateTask = Task { @MainActor in - await withCheckedContinuation { continuation in - // Store the continuation to be resumed in mapViewDidBecomeIdle - cameraUpdateContinuation = continuation - - 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 - ) - } - } - } -} From 244d07e43d90f551acab4bcdf9209a3bfc54d411 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Thu, 1 May 2025 18:28:52 -0700 Subject: [PATCH 7/7] Adds docs, reverts structure for private --- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 75e4704..f3e0a2c 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -16,7 +16,7 @@ MLNMapViewDelegate { /// 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. @@ -161,7 +161,7 @@ MLNMapViewDelegate { } // MARK: - Camera - + /// Apply a camera change based on the current @State of the camera. /// /// - Parameters: @@ -385,8 +385,7 @@ MLNMapViewDelegate { } } } - - + // MARK: - MLNMapViewDelegate public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) {