From 9bccf81da6ba1216a459fa959013c72abf89b60d Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Sun, 10 Mar 2024 22:57:01 +0900 Subject: [PATCH 01/15] Fix unnecessary var binding --- .../MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift index 22d376e..fcea64c 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift @@ -64,7 +64,7 @@ extension MapView { sender: UIGestureRecognizing) -> MapGestureContext { // Build the context of the gesture's event. - var point: CGPoint = switch gesture.method { + let point: CGPoint = switch gesture.method { case let .tap(numberOfTaps: numberOfTaps): // Calculate the CGPoint of the last gesture tap sender.location(ofTouch: numberOfTaps - 1, in: mapView) From 875aff8e71209f8dc334063b4710ee01b6ebdfd0 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Sun, 10 Mar 2024 22:57:41 +0900 Subject: [PATCH 02/15] Fix runtime check warning about state change during update causing UB --- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 336412a..531b1fd 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -198,10 +198,13 @@ extension MapViewCoordinator: MLNMapViewDelegate { return } - // The user's desired camera is not a user tracking method, now we need to publish the MLNMapView's camera state - // to the MapView camera binding. - parent.camera = .center(mapView.centerCoordinate, - zoom: mapView.zoomLevel, - reason: CameraChangeReason(reason)) + DispatchQueue.main.async { + // The user's desired camera is not a user tracking method, now we need to publish the MLNMapView's camera + // state + // to the MapView camera binding. + self.parent.camera = .center(mapView.centerCoordinate, + zoom: mapView.zoomLevel, + reason: CameraChangeReason(reason)) + } } } From c22d05d148a6dec5932825fe8b45ee713d9d4484 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Tue, 12 Mar 2024 12:11:48 +0900 Subject: [PATCH 03/15] Minor refactoring; introduce more snapshot tests to make things cleaner --- Sources/MapLibreSwiftUI/Examples/Camera.swift | 14 ++++--- .../MapLibreSwiftUI/MapViewCoordinator.swift | 39 ++++++++++-------- .../Models/MapCamera/CameraState.swift | 4 +- .../Models/MapCamera/CameraStateTests.swift | 24 +++++------ .../Models/MapCamera/MapViewCameraTests.swift | 40 +++++++------------ .../testCenterCameraState.1.txt | 1 + .../CameraStateTests/testRect.1.txt | 1 + .../testTrackingUserLocation.1.txt | 1 + .../testTrackingUserLocationWithCourse.1.txt | 1 + .../testTrackingUserLocationWithHeading.1.txt | 1 + .../MapViewCameraTests/testCenterCamera.1.txt | 13 ++++++ .../testTrackUserLocationWithCourse.1.txt | 10 +++++ .../testTrackUserLocationWithHeading.1.txt | 7 ++++ .../testTrackingUserLocation.1.txt | 10 +++++ 14 files changed, 103 insertions(+), 63 deletions(-) create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testRect.1.txt create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index 3d7d025..8cf49c0 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -8,6 +8,7 @@ struct CameraDirectManipulationPreview: View { let styleURL: URL var onStyleLoaded: (() -> Void)? = nil + var targetCameraAfterDelay: MapViewCamera? = nil var body: some View { MapView(styleURL: styleURL, camera: $camera) @@ -16,7 +17,7 @@ struct CameraDirectManipulationPreview: View { onStyleLoaded?() } .overlay(alignment: .bottom, content: { - Text("\(String(describing: camera.state)) z \(camera.zoom)") + Text("\(String(describing: camera.state))") .padding() .foregroundColor(.white) .background( @@ -27,16 +28,19 @@ struct CameraDirectManipulationPreview: View { .padding(.bottom, 42) }) .task { - try? await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) + if let targetCameraAfterDelay { + try? await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) - camera = MapViewCamera.center(switzerland, zoom: 6) + camera = targetCameraAfterDelay + } } } } -#Preview("Camera Preview") { +#Preview("Camera Zoom after delay") { CameraDirectManipulationPreview( - styleURL: URL(string: "https://demotiles.maplibre.org/style.json")! + styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, + targetCameraAfterDelay: .center(switzerland, zoom: 6) ) .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 531b1fd..91d33d5 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -54,7 +54,7 @@ public class MapViewCoordinator: NSObject { zoomLevel: camera.zoom, direction: camera.direction, animated: animated) - case .trackingUserLocation: + case.trackingUserLocation: mapView.userTrackingMode = .follow mapView.setZoomLevel(camera.zoom, animated: false) case .trackingUserLocationWithHeading: @@ -176,32 +176,39 @@ public class MapViewCoordinator: NSObject { // MARK: - MLNMapViewDelegate extension MapViewCoordinator: MLNMapViewDelegate { - public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { + public func mapView(_ 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) { - // Validate that the mapView.userTrackingMode still matches our desired camera state for each tracking type. - let isFollowing = parent.camera.state == .trackingUserLocation && mapView.userTrackingMode == .follow - let isFollowingHeading = parent.camera.state == .trackingUserLocationWithHeading && mapView - .userTrackingMode == .followWithHeading - let isFollowingCourse = parent.camera.state == .trackingUserLocationWithCourse && mapView - .userTrackingMode == .followWithCourse - // If any of these are a mismatch, we know the camera is no longer following a desired method, so we should - // detach and revert to a .centered camera. If any one of these is true, the desired camera state still matches - // the mapView's userTrackingMode - if isFollowing || isFollowingHeading || isFollowingCourse { - // User tracking is still active, we can ignore camera updates until we unset/fail this boolean check + // 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 isProgrammaticallyTracking: Bool = switch parent.camera.state { + case .centered(onCoordinate: _): + false + case .trackingUserLocation: + mapView.userTrackingMode == .follow + case .trackingUserLocationWithHeading: + mapView.userTrackingMode == .followWithHeading + case .trackingUserLocationWithCourse: + mapView.userTrackingMode == .followWithCourse + case .rect(northeast: _, southwest: _): + false + case .showcase(shapeCollection: _): + false + } + + if isProgrammaticallyTracking { + // Programmatic tracking is still active, we can ignore camera updates until we unset/fail this boolean + // check return } DispatchQueue.main.async { - // The user's desired camera is not a user tracking method, now we need to publish the MLNMapView's camera - // state - // to the MapView camera binding. + // Publish the MLNMapView's "raw" camera state to the MapView camera binding. self.parent.camera = .center(mapView.centerCoordinate, zoom: mapView.zoomLevel, reason: CameraChangeReason(reason)) diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index f6fdd8e..e07fdd6 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -34,8 +34,8 @@ public enum CameraState: Hashable { extension CameraState: CustomDebugStringConvertible { public var debugDescription: String { switch self { - case let .centered(onCoordinate: onCoordinate): - "CameraState.centered(onCoordinate: \(onCoordinate)" + case let .centered(onCoordinate: coordinate): + "CameraState.centered(onCoordinate: \(coordinate))" case .trackingUserLocation: "CameraState.trackingUserLocation" case .trackingUserLocationWithHeading: diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift index 5365a8d..ec3b090 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -1,34 +1,33 @@ import CoreLocation +import SnapshotTesting import XCTest @testable import MapLibreSwiftUI final class CameraStateTests: XCTestCase { + let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + func testCenterCameraState() { - let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) - let state: CameraState = .centered(onCoordinate: expectedCoordinate) - XCTAssertEqual(state, .centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) - XCTAssertEqual( - String(describing: state), - "CameraState.centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)" - ) + let state: CameraState = .centered(onCoordinate: coordinate) + XCTAssertEqual(state, .centered(onCoordinate: coordinate)) + assertSnapshot(of: state, as: .description) } func testTrackingUserLocation() { let state: CameraState = .trackingUserLocation XCTAssertEqual(state, .trackingUserLocation) - XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocation") + assertSnapshot(of: state, as: .description) } func testTrackingUserLocationWithHeading() { let state: CameraState = .trackingUserLocationWithHeading XCTAssertEqual(state, .trackingUserLocationWithHeading) - XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocationWithHeading") + assertSnapshot(of: state, as: .description) } func testTrackingUserLocationWithCourse() { let state: CameraState = .trackingUserLocationWithCourse XCTAssertEqual(state, .trackingUserLocationWithCourse) - XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocationWithCourse") + assertSnapshot(of: state, as: .description) } func testRect() { @@ -37,9 +36,6 @@ final class CameraStateTests: XCTestCase { let state: CameraState = .rect(northeast: northeast, southwest: southwest) XCTAssertEqual(state, .rect(northeast: northeast, southwest: southwest)) - XCTAssertEqual( - String(describing: state), - "CameraState.rect(northeast: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), southwest: CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6))" - ) + assertSnapshot(of: state, as: .description) } } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index 1031264..15b091d 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -1,49 +1,37 @@ import CoreLocation +import SnapshotTesting import XCTest @testable import MapLibreSwiftUI final class MapViewCameraTests: XCTestCase { func testCenterCamera() { - let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) - let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) - let direction: CLLocationDirection = 23 - - let camera = MapViewCamera.center(expectedCoordinate, zoom: 12, pitch: pitch, direction: direction) - - XCTAssertEqual(camera.state, .centered(onCoordinate: expectedCoordinate)) - XCTAssertEqual(camera.zoom, 12) - XCTAssertEqual(camera.pitch, pitch) - XCTAssertEqual(camera.direction, direction) + let camera = MapViewCamera.center( + CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), + zoom: 5, + pitch: .freeWithinRange(minimum: 12, maximum: 34), + direction: 23 + ) + + assertSnapshot(of: camera, as: .dump) } func testTrackingUserLocation() { let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) - let camera = MapViewCamera.trackUserLocation(pitch: pitch) + let camera = MapViewCamera.trackUserLocation(zoom: 10, pitch: pitch) - XCTAssertEqual(camera.state, .trackingUserLocation) - XCTAssertEqual(camera.zoom, 10) - XCTAssertEqual(camera.pitch, pitch) - XCTAssertEqual(camera.direction, 0) + assertSnapshot(of: camera, as: .dump) } func testTrackUserLocationWithCourse() { let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) let camera = MapViewCamera.trackUserLocationWithCourse(zoom: 18, pitch: pitch) - XCTAssertEqual(camera.state, .trackingUserLocationWithCourse) - XCTAssertEqual(camera.zoom, 18) - XCTAssertEqual(camera.pitch, pitch) - XCTAssertEqual(camera.direction, 0) + assertSnapshot(of: camera, as: .dump) } func testTrackUserLocationWithHeading() { - let camera = MapViewCamera.trackUserLocationWithHeading() + let camera = MapViewCamera.trackUserLocationWithHeading(zoom: 10, pitch: .free) - XCTAssertEqual(camera.state, .trackingUserLocationWithHeading) - XCTAssertEqual(camera.zoom, 10) - XCTAssertEqual(camera.pitch, .free) - XCTAssertEqual(camera.direction, 0) + assertSnapshot(of: camera, as: .dump) } - - // TODO: Add additional camera tests once behaviors are added (e.g. rect) } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt new file mode 100644 index 0000000..d154bc2 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt @@ -0,0 +1 @@ +CameraState.centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testRect.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testRect.1.txt new file mode 100644 index 0000000..c1c7645 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testRect.1.txt @@ -0,0 +1 @@ +CameraState.rect(northeast: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), southwest: CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6)) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt new file mode 100644 index 0000000..40324e8 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt @@ -0,0 +1 @@ +CameraState.trackingUserLocation \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt new file mode 100644 index 0000000..4523e80 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt @@ -0,0 +1 @@ +CameraState.trackingUserLocationWithCourse \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt new file mode 100644 index 0000000..b75f646 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt @@ -0,0 +1 @@ +CameraState.trackingUserLocationWithHeading \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt new file mode 100644 index 0000000..5e42855 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt @@ -0,0 +1,13 @@ +▿ MapViewCamera + - direction: 23.0 + - lastReasonForChange: Optional.none + ▿ pitch: CameraPitch + ▿ freeWithinRange: (2 elements) + - minimum: 12.0 + - maximum: 34.0 + ▿ state: CameraState + ▿ centered: (1 element) + ▿ onCoordinate: CLLocationCoordinate2D + - latitude: 12.3 + - longitude: 23.4 + - zoom: 5.0 diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt new file mode 100644 index 0000000..5e18633 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt @@ -0,0 +1,10 @@ +▿ MapViewCamera + - direction: 0.0 + ▿ lastReasonForChange: Optional + - some: CameraChangeReason.programmatic + ▿ pitch: CameraPitch + ▿ freeWithinRange: (2 elements) + - minimum: 12.0 + - maximum: 34.0 + - state: CameraState.CameraState.trackingUserLocationWithCourse + - zoom: 18.0 diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt new file mode 100644 index 0000000..1164911 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt @@ -0,0 +1,7 @@ +▿ MapViewCamera + - direction: 0.0 + ▿ lastReasonForChange: Optional + - some: CameraChangeReason.programmatic + - pitch: CameraPitch.free + - state: CameraState.CameraState.trackingUserLocationWithHeading + - zoom: 10.0 diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt new file mode 100644 index 0000000..156d26a --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt @@ -0,0 +1,10 @@ +▿ MapViewCamera + - direction: 0.0 + ▿ lastReasonForChange: Optional + - some: CameraChangeReason.programmatic + ▿ pitch: CameraPitch + ▿ freeWithinRange: (2 elements) + - minimum: 12.0 + - maximum: 34.0 + - state: CameraState.CameraState.trackingUserLocation + - zoom: 10.0 From ef10bbb141eb5c0cc164ec9eaa159320a34c69e1 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Tue, 12 Mar 2024 15:54:56 +0900 Subject: [PATCH 04/15] Support content insets and a few other idiomatic modifiers toward supporting navigation use cases --- Sources/MapLibreSwiftUI/Examples/Camera.swift | 4 +- Sources/MapLibreSwiftUI/Examples/Layers.swift | 2 - .../MapLibreSwiftUI/Examples/Polyline.swift | 4 +- .../Examples/Preview Helpers.swift | 68 +++++++++++++++++++ .../Examples/User Location.swift | 33 +++++++++ Sources/MapLibreSwiftUI/MapView.swift | 68 ++++++++++++++----- .../MapLibreSwiftUI/MapViewCoordinator.swift | 8 ++- 7 files changed, 159 insertions(+), 28 deletions(-) create mode 100644 Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift create mode 100644 Sources/MapLibreSwiftUI/Examples/User Location.swift diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index 8cf49c0..3bec74d 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -1,8 +1,6 @@ import CoreLocation import SwiftUI -private let switzerland = CLLocationCoordinate2D(latitude: 46.801111, longitude: 8.226667) - struct CameraDirectManipulationPreview: View { @State private var camera = MapViewCamera.center(switzerland, zoom: 4) @@ -39,7 +37,7 @@ struct CameraDirectManipulationPreview: View { #Preview("Camera Zoom after delay") { CameraDirectManipulationPreview( - styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, + styleURL: demoTilesURL, targetCameraAfterDelay: .center(switzerland, zoom: 6) ) .ignoresSafeArea(.all) diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index 3278ada..fb2281a 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -3,8 +3,6 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - // A collection of points with various // attributes let pointSource = ShapeSource(identifier: "points") { diff --git a/Sources/MapLibreSwiftUI/Examples/Polyline.swift b/Sources/MapLibreSwiftUI/Examples/Polyline.swift index 1e636a4..df5c3f9 100644 --- a/Sources/MapLibreSwiftUI/Examples/Polyline.swift +++ b/Sources/MapLibreSwiftUI/Examples/Polyline.swift @@ -8,7 +8,7 @@ struct PolylinePreview: View { var body: some View { MapView(styleURL: styleURL, - constantCamera: .center(samplePedestrianWaypoints.first!, zoom: 14)) + camera: .constant(.center(samplePedestrianWaypoints.first!, zoom: 14))) { // Note: This line does not add the source to the style as if it // were a statement in an imperative programming language. @@ -43,8 +43,6 @@ struct PolylinePreview: View { struct Polyline_Previews: PreviewProvider { static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - PolylinePreview(styleURL: demoTilesURL) .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift new file mode 100644 index 0000000..d4e73e1 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift @@ -0,0 +1,68 @@ +// This file contains helpers that are used in the SwiftUI preview examples +import CoreLocation +import MapLibre + +let switzerland = CLLocationCoordinate2D(latitude: 47.03041, longitude: 8.29470) +let demoTilesURL = + URL(string: "https://demotiles.maplibre.org/style.json")! + +/// A simple class that provides a user location to a MapLibre view. +/// +/// This makes it easier to write SwiftUI previews without having to worry about location permissions. +class PreviewLocationManager: NSObject { + var delegate: (any MLNLocationManagerDelegate)? = nil { + didSet { + // Necessary to trigger an initial update correctly if the camera is set on init + DispatchQueue.main.async { + self.delegate?.locationManagerDidChangeAuthorization(self) + self.delegate?.locationManager(self, didUpdate: [self.lastLocation]) + } + } + } + + var authorizationStatus: CLAuthorizationStatus { + CLAuthorizationStatus.authorizedAlways + } + + var headingOrientation: CLDeviceOrientation = .portrait + + var lastLocation: CLLocation { + didSet { + delegate?.locationManager(self, didUpdate: [lastLocation]) + } + } + + init(initialLocation: CLLocation) { + lastLocation = initialLocation + } +} + +extension PreviewLocationManager: MLNLocationManager { + func requestAlwaysAuthorization() { + // Do nothing + } + + func requestWhenInUseAuthorization() { + // Do nothing + } + + func startUpdatingLocation() { + // Do nothing + } + + func stopUpdatingLocation() { + // Do nothing + } + + func startUpdatingHeading() { + // Do nothing + } + + func stopUpdatingHeading() { + // Do nothing + } + + func dismissHeadingCalibrationDisplay() { + // Do nothing + } +} diff --git a/Sources/MapLibreSwiftUI/Examples/User Location.swift b/Sources/MapLibreSwiftUI/Examples/User Location.swift new file mode 100644 index 0000000..e1acb7c --- /dev/null +++ b/Sources/MapLibreSwiftUI/Examples/User Location.swift @@ -0,0 +1,33 @@ +import CoreLocation +import SwiftUI + +private let locationManager = PreviewLocationManager(initialLocation: CLLocation( + coordinate: switzerland, + altitude: 0, + horizontalAccuracy: 1, + verticalAccuracy: 1, + course: 8, + speed: 28, + timestamp: Date() +)) + +#Preview("Track user location") { + MapView( + styleURL: demoTilesURL, + camera: .constant(.trackUserLocation(zoom: 8, pitch: .fixed(45))), + locationManager: locationManager + ) + .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0)) + .ignoresSafeArea(.all) +} + +#Preview("Track user location with Course") { + MapView( + styleURL: demoTilesURL, + camera: .constant(.trackUserLocationWithCourse(zoom: 8, pitch: .fixed(45))), + locationManager: locationManager + ) + .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0)) + .hideCompassView() + .ignoresSafeArea(.all) +} diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 3212f9e..1e49495 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -12,28 +12,26 @@ public struct MapView: UIViewRepresentable { var gestures = [MapGesture]() var onStyleLoaded: ((MLNStyle) -> Void)? + public var mapViewContentInset: UIEdgeInsets = .zero + public var isLogoViewHidden = false + public var isCompassViewHidden = false + /// 'Escape hatch' to MLNMapView until we have more modifiers. /// See ``unsafeMapViewModifier(_:)`` var unsafeMapViewModifier: ((MLNMapView) -> Void)? + private var locationManager: MLNLocationManager? + public init( styleURL: URL, camera: Binding = .constant(.default()), + locationManager: MLNLocationManager? = nil, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { styleSource = .url(styleURL) _camera = camera userLayers = makeMapContent() - } - - public init( - styleURL: URL, - constantCamera: MapViewCamera, - @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } - ) { - self.init(styleURL: styleURL, - camera: .constant(constantCamera), - makeMapContent) + self.locationManager = locationManager } public func makeCoordinator() -> MapViewCoordinator { @@ -49,6 +47,10 @@ public struct MapView: UIViewRepresentable { mapView.delegate = context.coordinator context.coordinator.mapView = mapView + applyModifiers(mapView, runUnsafe: false) + + mapView.locationManager = locationManager + switch styleSource { case let .url(styleURL): mapView.styleURL = styleURL @@ -58,9 +60,6 @@ public struct MapView: UIViewRepresentable { camera: $camera.wrappedValue, animated: false) - // TODO: Make this settable via a modifier - mapView.logoView.isHidden = true - // Link the style loaded to the coordinator that emits the delegate event. context.coordinator.onStyleLoaded = onStyleLoaded @@ -75,11 +74,7 @@ public struct MapView: UIViewRepresentable { public func updateUIView(_ mapView: MLNMapView, context: Context) { context.coordinator.parent = self - // MARK: Modifiers - - unsafeMapViewModifier?(mapView) - - // MARK: End Modifiers + applyModifiers(mapView, runUnsafe: true) // FIXME: This should be a more selective update context.coordinator.updateStyleSource(styleSource, mapView: mapView) @@ -92,6 +87,43 @@ public struct MapView: UIViewRepresentable { camera: $camera.wrappedValue, animated: isStyleLoaded) } + + private func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) { + mapView.contentInset = mapViewContentInset + + mapView.logoView.isHidden = isLogoViewHidden + mapView.compassView.isHidden = isCompassViewHidden + + if runUnsafe { + unsafeMapViewModifier?(mapView) + } + } +} + +extension MapView { + func mapViewContentInset(_ inset: UIEdgeInsets) -> Self { + var result = self + + result.mapViewContentInset = inset + + return result + } + + func hideLogoView() -> Self { + var result = self + + result.isLogoViewHidden = true + + return result + } + + func hideCompassView() -> Self { + var result = self + + result.isCompassViewHidden = true + + return result + } } #Preview { diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 91d33d5..aca3895 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -54,7 +54,7 @@ public class MapViewCoordinator: NSObject { zoomLevel: camera.zoom, direction: camera.direction, animated: animated) - case.trackingUserLocation: + case .trackingUserLocation: mapView.userTrackingMode = .follow mapView.setZoomLevel(camera.zoom, animated: false) case .trackingUserLocationWithHeading: @@ -176,7 +176,7 @@ public class MapViewCoordinator: NSObject { // MARK: - MLNMapViewDelegate extension MapViewCoordinator: MLNMapViewDelegate { - public func mapView(_ mapView: MLNMapView, didFinishLoading mglStyle: MLNStyle) { + public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { addLayers(to: mglStyle) onStyleLoaded?(mglStyle) } @@ -214,4 +214,8 @@ extension MapViewCoordinator: MLNMapViewDelegate { reason: CameraChangeReason(reason)) } } + + public func mapView(_: MLNMapView, didUpdate userLocation: MLNUserLocation?) { + print("Updated user location! \(String(describing: userLocation))") + } } From bcd90cf9172f8ee1f47e6521a4facdb32404415e Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Tue, 12 Mar 2024 20:32:17 +0900 Subject: [PATCH 05/15] Expose modifiers and MLNLocationManager instance publicly --- Sources/MapLibreSwiftUI/Examples/Camera.swift | 1 - .../Examples/Preview Helpers.swift | 62 --------------- .../Examples/User Location.swift | 2 +- Sources/MapLibreSwiftUI/MapView.swift | 2 +- .../MapLibreSwiftUI/MapViewCoordinator.swift | 4 - .../StaticLocationManager.swift | 75 +++++++++++++++++++ 6 files changed, 77 insertions(+), 69 deletions(-) create mode 100644 Sources/MapLibreSwiftUI/StaticLocationManager.swift diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index 3bec74d..a7f0f11 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -11,7 +11,6 @@ struct CameraDirectManipulationPreview: View { var body: some View { MapView(styleURL: styleURL, camera: $camera) .onStyleLoaded { _ in - print("Style is loaded") onStyleLoaded?() } .overlay(alignment: .bottom, content: { diff --git a/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift index d4e73e1..eca5e64 100644 --- a/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift @@ -1,68 +1,6 @@ // This file contains helpers that are used in the SwiftUI preview examples import CoreLocation -import MapLibre let switzerland = CLLocationCoordinate2D(latitude: 47.03041, longitude: 8.29470) let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - -/// A simple class that provides a user location to a MapLibre view. -/// -/// This makes it easier to write SwiftUI previews without having to worry about location permissions. -class PreviewLocationManager: NSObject { - var delegate: (any MLNLocationManagerDelegate)? = nil { - didSet { - // Necessary to trigger an initial update correctly if the camera is set on init - DispatchQueue.main.async { - self.delegate?.locationManagerDidChangeAuthorization(self) - self.delegate?.locationManager(self, didUpdate: [self.lastLocation]) - } - } - } - - var authorizationStatus: CLAuthorizationStatus { - CLAuthorizationStatus.authorizedAlways - } - - var headingOrientation: CLDeviceOrientation = .portrait - - var lastLocation: CLLocation { - didSet { - delegate?.locationManager(self, didUpdate: [lastLocation]) - } - } - - init(initialLocation: CLLocation) { - lastLocation = initialLocation - } -} - -extension PreviewLocationManager: MLNLocationManager { - func requestAlwaysAuthorization() { - // Do nothing - } - - func requestWhenInUseAuthorization() { - // Do nothing - } - - func startUpdatingLocation() { - // Do nothing - } - - func stopUpdatingLocation() { - // Do nothing - } - - func startUpdatingHeading() { - // Do nothing - } - - func stopUpdatingHeading() { - // Do nothing - } - - func dismissHeadingCalibrationDisplay() { - // Do nothing - } -} diff --git a/Sources/MapLibreSwiftUI/Examples/User Location.swift b/Sources/MapLibreSwiftUI/Examples/User Location.swift index e1acb7c..68b28c8 100644 --- a/Sources/MapLibreSwiftUI/Examples/User Location.swift +++ b/Sources/MapLibreSwiftUI/Examples/User Location.swift @@ -1,7 +1,7 @@ import CoreLocation import SwiftUI -private let locationManager = PreviewLocationManager(initialLocation: CLLocation( +private let locationManager = StaticLocationManager(initialLocation: CLLocation( coordinate: switzerland, altitude: 0, horizontalAccuracy: 1, diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 1e49495..0813f19 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -100,7 +100,7 @@ public struct MapView: UIViewRepresentable { } } -extension MapView { +public extension MapView { func mapViewContentInset(_ inset: UIEdgeInsets) -> Self { var result = self diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index aca3895..e272e88 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -214,8 +214,4 @@ extension MapViewCoordinator: MLNMapViewDelegate { reason: CameraChangeReason(reason)) } } - - public func mapView(_: MLNMapView, didUpdate userLocation: MLNUserLocation?) { - print("Updated user location! \(String(describing: userLocation))") - } } diff --git a/Sources/MapLibreSwiftUI/StaticLocationManager.swift b/Sources/MapLibreSwiftUI/StaticLocationManager.swift new file mode 100644 index 0000000..1aed855 --- /dev/null +++ b/Sources/MapLibreSwiftUI/StaticLocationManager.swift @@ -0,0 +1,75 @@ +import CoreLocation +import MapLibre + +/// A simple class that provides static location updates to a MapLibre view. +/// +/// This is not actually driven by a location manager (such as CLLocationManager) internally, but rather by updates +/// provided one at a time. Beyond the obvious use case in testing and SwiftUI previews, this is also useful if you are +/// doing some processing of raw location data (ex: determining whether to snap locations to a road) and selectively +/// passing the updates on to the map view. +/// +/// You can provide a new location by setting the ``lastLocation`` property. +/// +/// This class does not ever perform any authorization checks. That is the responsiblity of the caller. +public class StaticLocationManager: NSObject { + public var delegate: (any MLNLocationManagerDelegate)? = nil { + didSet { + DispatchQueue.main.async { + self.delegate?.locationManagerDidChangeAuthorization(self) + self.delegate?.locationManager(self, didUpdate: [self.lastLocation]) + } + } + } + + public var authorizationStatus: CLAuthorizationStatus = .authorizedAlways { + didSet { + DispatchQueue.main.async { + self.delegate?.locationManagerDidChangeAuthorization(self) + } + } + } + + public var headingOrientation: CLDeviceOrientation = .portrait + + public var lastLocation: CLLocation { + didSet { + DispatchQueue.main.async { + self.delegate?.locationManager(self, didUpdate: [self.lastLocation]) + } + } + } + + public init(initialLocation: CLLocation) { + lastLocation = initialLocation + } +} + +extension StaticLocationManager: MLNLocationManager { + public func requestAlwaysAuthorization() { + // Do nothing + } + + public func requestWhenInUseAuthorization() { + // Do nothing + } + + public func startUpdatingLocation() { + // Do nothing + } + + public func stopUpdatingLocation() { + // Do nothing + } + + public func startUpdatingHeading() { + // Do nothing + } + + public func stopUpdatingHeading() { + // Do nothing + } + + public func dismissHeadingCalibrationDisplay() { + // Do nothing + } +} From e13f845e2eeedab2bc0bc1fbd6544f50ca3018e1 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Wed, 13 Mar 2024 16:18:52 +0900 Subject: [PATCH 06/15] Minor fixes --- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 6 +++--- .../Models/MapCamera/MapViewCameraTests.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 7cf60ca..1a13700 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -56,13 +56,13 @@ public class MapViewCoordinator: NSObject { animated: animated) case let .trackingUserLocation(zoom: zoom): mapView.userTrackingMode = .follow - mapView.setZoomLevel(zoom, animated: false) + mapView.setZoomLevel(zoom, animated: animated) case let .trackingUserLocationWithHeading(zoom: zoom): mapView.userTrackingMode = .followWithHeading - mapView.setZoomLevel(zoom, animated: false) + mapView.setZoomLevel(zoom, animated: animated) case let .trackingUserLocationWithCourse(zoom: zoom): mapView.userTrackingMode = .followWithCourse - mapView.setZoomLevel(zoom, animated: false) + mapView.setZoomLevel(zoom, animated: animated) case let .rect(boundingBox, padding): mapView.setVisibleCoordinateBounds(boundingBox, edgePadding: padding, diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index e6a2d6b..31b0cad 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -1,6 +1,6 @@ import CoreLocation -import SnapshotTesting import MapLibre +import SnapshotTesting import XCTest @testable import MapLibreSwiftUI From 96919e471f1831327dc07f97c21dc148d416cad0 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Wed, 13 Mar 2024 16:39:36 +0900 Subject: [PATCH 07/15] Complete refactor of state-specific props out to the state --- .../MapLibreSwiftUI/MapViewCoordinator.swift | 22 +++++++----- .../Models/MapCamera/CameraState.swift | 17 ++++++---- .../Models/MapCamera/MapViewCamera.swift | 34 +++++++------------ .../Models/MapCamera/CameraStateTests.swift | 16 ++++----- .../testCenterCameraState.1.txt | 2 +- .../testTrackingUserLocation.1.txt | 2 +- .../testTrackingUserLocationWithCourse.1.txt | 2 +- .../testTrackingUserLocationWithHeading.1.txt | 2 +- .../MapViewCameraTests/testBoundingBox.1.txt | 2 -- .../MapViewCameraTests/testCenterCamera.1.txt | 12 +++---- .../testTrackUserLocationWithCourse.1.txt | 11 +++--- .../testTrackUserLocationWithHeading.1.txt | 5 ++- .../testTrackingUserLocation.1.txt | 11 +++--- 13 files changed, 67 insertions(+), 71 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 1a13700..5259f0a 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -48,21 +48,29 @@ public class MapViewCoordinator: NSObject { } switch camera.state { - case let .centered(onCoordinate: coordinate, zoom: zoom): + case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): mapView.userTrackingMode = .none mapView.setCenter(coordinate, zoomLevel: zoom, - direction: camera.direction, + direction: direction, animated: animated) - case let .trackingUserLocation(zoom: zoom): + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound + case let .trackingUserLocation(zoom: zoom, pitch: pitch): mapView.userTrackingMode = .follow mapView.setZoomLevel(zoom, animated: animated) - case let .trackingUserLocationWithHeading(zoom: zoom): + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound + case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch): mapView.userTrackingMode = .followWithHeading mapView.setZoomLevel(zoom, animated: animated) - case let .trackingUserLocationWithCourse(zoom: zoom): + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound + case let .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch): mapView.userTrackingMode = .followWithCourse mapView.setZoomLevel(zoom, animated: animated) + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound case let .rect(boundingBox, padding): mapView.setVisibleCoordinateBounds(boundingBox, edgePadding: padding, @@ -73,10 +81,6 @@ public class MapViewCoordinator: NSObject { break } - // Set the correct pitch range. - mapView.minimumPitch = camera.pitch.rangeValue.lowerBound - mapView.maximumPitch = camera.pitch.rangeValue.upperBound - snapshotCamera = camera } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index e2f7546..ecb36f2 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -4,25 +4,30 @@ import MapLibre /// The CameraState is used to understand the current context of the MapView's camera. public enum CameraState: Hashable { /// Centered on a coordinate - case centered(onCoordinate: CLLocationCoordinate2D, zoom: Double) + case centered( + onCoordinate: CLLocationCoordinate2D, + zoom: Double, + pitch: CameraPitch, + direction: CLLocationDirection + ) /// Follow the user's location using the MapView's internal camera. /// /// This feature uses the MLNMapView's userTrackingMode to .follow which automatically /// follows the user from within the MLNMapView. - case trackingUserLocation(zoom: Double) + case trackingUserLocation(zoom: Double, pitch: CameraPitch) /// Follow the user's location using the MapView's internal camera with the user's heading. /// /// This feature uses the MLNMapView's userTrackingMode to .followWithHeading which automatically /// follows the user from within the MLNMapView. - case trackingUserLocationWithHeading(zoom: Double) + case trackingUserLocationWithHeading(zoom: Double, pitch: CameraPitch) /// Follow the user's location using the MapView's internal camera with the users' course /// /// This feature uses the MLNMapView's userTrackingMode to .followWithCourse which automatically /// follows the user from within the MLNMapView. - case trackingUserLocationWithCourse(zoom: Double) + case trackingUserLocationWithCourse(zoom: Double, pitch: CameraPitch) /// Centered on a bounding box/rectangle. case rect( @@ -37,8 +42,8 @@ public enum CameraState: Hashable { extension CameraState: CustomDebugStringConvertible { public var debugDescription: String { switch self { - case let .centered(onCoordinate: coordinate, zoom: zoom): - "CameraState.centered(onCoordinate: \(coordinate), zoom: \(zoom))" + case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): + "CameraState.centered(onCoordinate: \(coordinate), zoom: \(zoom), pitch: \(pitch), direction: \(direction))" case let .trackingUserLocation(zoom: zoom): "CameraState.trackingUserLocation(zoom: \(zoom))" case let .trackingUserLocationWithHeading(zoom: zoom): diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift index 7caca65..cb1345e 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -14,8 +14,6 @@ public struct MapViewCamera: Hashable { } public var state: CameraState - public var pitch: CameraPitch - public var direction: CLLocationDirection /// The reason the camera was changed. /// @@ -29,10 +27,15 @@ public struct MapViewCamera: Hashable { /// /// - Returns: The constructed MapViewCamera. public static func `default`() -> MapViewCamera { - MapViewCamera(state: .centered(onCoordinate: Defaults.coordinate, zoom: Defaults.zoom), - pitch: Defaults.pitch, - direction: Defaults.direction, - lastReasonForChange: .programmatic) + MapViewCamera( + state: .centered( + onCoordinate: Defaults.coordinate, + zoom: Defaults.zoom, + pitch: Defaults.pitch, + direction: Defaults.direction + ), + lastReasonForChange: .programmatic + ) } /// Center the map on a specific location. @@ -49,9 +52,7 @@ public struct MapViewCamera: Hashable { direction: CLLocationDirection = Defaults.direction, reason: CameraChangeReason? = nil) -> MapViewCamera { - MapViewCamera(state: .centered(onCoordinate: coordinate, zoom: zoom), - pitch: pitch, - direction: direction, + MapViewCamera(state: .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction), lastReasonForChange: reason) } @@ -68,9 +69,7 @@ public struct MapViewCamera: Hashable { pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. - MapViewCamera(state: .trackingUserLocation(zoom: zoom), - pitch: pitch, - direction: Defaults.direction, + MapViewCamera(state: .trackingUserLocation(zoom: zoom, pitch: pitch), lastReasonForChange: .programmatic) } @@ -87,9 +86,7 @@ public struct MapViewCamera: Hashable { pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. - MapViewCamera(state: .trackingUserLocationWithHeading(zoom: zoom), - pitch: pitch, - direction: Defaults.direction, + MapViewCamera(state: .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch), lastReasonForChange: .programmatic) } @@ -106,9 +103,7 @@ public struct MapViewCamera: Hashable { pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. - MapViewCamera(state: .trackingUserLocationWithCourse(zoom: zoom), - pitch: pitch, - direction: Defaults.direction, + MapViewCamera(state: .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch), lastReasonForChange: .programmatic) } @@ -122,10 +117,7 @@ public struct MapViewCamera: Hashable { _ box: MLNCoordinateBounds, edgePadding: UIEdgeInsets = .init(top: 20, left: 20, bottom: 20, right: 20) ) -> MapViewCamera { - // TODO: pitch & direction are ignored. We should extract these into the state parameter. MapViewCamera(state: .rect(boundingBox: box, edgePadding: edgePadding), - pitch: Defaults.pitch, - direction: Defaults.direction, lastReasonForChange: .programmatic) } } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift index 8d2ff09..0bbbae1 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -7,26 +7,26 @@ final class CameraStateTests: XCTestCase { let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) func testCenterCameraState() { - let state: CameraState = .centered(onCoordinate: coordinate, zoom: 4) - XCTAssertEqual(state, .centered(onCoordinate: coordinate, zoom: 4)) + let state: CameraState = .centered(onCoordinate: coordinate, zoom: 4, pitch: .free, direction: 42) + XCTAssertEqual(state, .centered(onCoordinate: coordinate, zoom: 4, pitch: .free, direction: 42)) assertSnapshot(of: state, as: .description) } func testTrackingUserLocation() { - let state: CameraState = .trackingUserLocation(zoom: 4) - XCTAssertEqual(state, .trackingUserLocation(zoom: 4)) + let state: CameraState = .trackingUserLocation(zoom: 4, pitch: .free) + XCTAssertEqual(state, .trackingUserLocation(zoom: 4, pitch: .free)) assertSnapshot(of: state, as: .description) } func testTrackingUserLocationWithHeading() { - let state: CameraState = .trackingUserLocationWithHeading(zoom: 4) - XCTAssertEqual(state, .trackingUserLocationWithHeading(zoom: 4)) + let state: CameraState = .trackingUserLocationWithHeading(zoom: 4, pitch: .free) + XCTAssertEqual(state, .trackingUserLocationWithHeading(zoom: 4, pitch: .free)) assertSnapshot(of: state, as: .description) } func testTrackingUserLocationWithCourse() { - let state: CameraState = .trackingUserLocationWithCourse(zoom: 4) - XCTAssertEqual(state, .trackingUserLocationWithCourse(zoom: 4)) + let state: CameraState = .trackingUserLocationWithCourse(zoom: 4, pitch: .free) + XCTAssertEqual(state, .trackingUserLocationWithCourse(zoom: 4, pitch: .free)) assertSnapshot(of: state, as: .description) } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt index 2308330..6d2f3a0 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt @@ -1 +1 @@ -CameraState.centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), zoom: 4.0) \ No newline at end of file +CameraState.centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), zoom: 4.0, pitch: free, direction: 42.0) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt index f12ebc7..8c216f7 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocation.1.txt @@ -1 +1 @@ -CameraState.trackingUserLocation(zoom: 4.0) \ No newline at end of file +CameraState.trackingUserLocation(zoom: (4.0, MapLibreSwiftUI.CameraPitch.free)) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt index 43551e8..639e899 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithCourse.1.txt @@ -1 +1 @@ -CameraState.trackingUserLocationWithCourse(zoom: 4.0) \ No newline at end of file +CameraState.trackingUserLocationWithCourse(zoom: (4.0, MapLibreSwiftUI.CameraPitch.free)) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt index 5f4fe12..13adebe 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testTrackingUserLocationWithHeading.1.txt @@ -1 +1 @@ -CameraState.trackingUserLocationWithHeading(zoom: 4.0) \ No newline at end of file +CameraState.trackingUserLocationWithHeading(zoom: (4.0, MapLibreSwiftUI.CameraPitch.free)) \ No newline at end of file diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testBoundingBox.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testBoundingBox.1.txt index cca3c0e..092ae5f 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testBoundingBox.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testBoundingBox.1.txt @@ -1,8 +1,6 @@ ▿ MapViewCamera - - direction: 0.0 ▿ lastReasonForChange: Optional - some: CameraChangeReason.programmatic - - pitch: CameraPitch.free ▿ state: CameraState ▿ rect: (2 elements) ▿ boundingBox: MLNCoordinateBounds diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt index 359bbb5..d3c277d 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt @@ -1,13 +1,13 @@ ▿ MapViewCamera - - direction: 23.0 - lastReasonForChange: Optional.none - ▿ pitch: CameraPitch - ▿ freeWithinRange: (2 elements) - - minimum: 12.0 - - maximum: 34.0 ▿ state: CameraState - ▿ centered: (2 elements) + ▿ centered: (4 elements) ▿ onCoordinate: CLLocationCoordinate2D - latitude: 12.3 - longitude: 23.4 - zoom: 5.0 + ▿ pitch: CameraPitch + ▿ freeWithinRange: (2 elements) + - minimum: 12.0 + - maximum: 34.0 + - direction: 23.0 diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt index b556f5d..4c5c2d5 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt @@ -1,11 +1,10 @@ ▿ MapViewCamera - - direction: 0.0 ▿ lastReasonForChange: Optional - some: CameraChangeReason.programmatic - ▿ pitch: CameraPitch - ▿ freeWithinRange: (2 elements) - - minimum: 12.0 - - maximum: 34.0 ▿ state: CameraState - ▿ trackingUserLocationWithCourse: (1 element) + ▿ trackingUserLocationWithCourse: (2 elements) - zoom: 18.0 + ▿ pitch: CameraPitch + ▿ freeWithinRange: (2 elements) + - minimum: 12.0 + - maximum: 34.0 diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt index c4aa6b8..47942b2 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt @@ -1,8 +1,7 @@ ▿ MapViewCamera - - direction: 0.0 ▿ lastReasonForChange: Optional - some: CameraChangeReason.programmatic - - pitch: CameraPitch.free ▿ state: CameraState - ▿ trackingUserLocationWithHeading: (1 element) + ▿ trackingUserLocationWithHeading: (2 elements) - zoom: 10.0 + - pitch: CameraPitch.free diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt index 406bffe..922b6bb 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt @@ -1,11 +1,10 @@ ▿ MapViewCamera - - direction: 0.0 ▿ lastReasonForChange: Optional - some: CameraChangeReason.programmatic - ▿ pitch: CameraPitch - ▿ freeWithinRange: (2 elements) - - minimum: 12.0 - - maximum: 34.0 ▿ state: CameraState - ▿ trackingUserLocation: (1 element) + ▿ trackingUserLocation: (2 elements) - zoom: 10.0 + ▿ pitch: CameraPitch + ▿ freeWithinRange: (2 elements) + - minimum: 12.0 + - maximum: 34.0 From cb3af3f0986cabacc4145fb6bd19e22cfc1ad318 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Wed, 13 Mar 2024 16:57:55 +0900 Subject: [PATCH 08/15] Revert animated property to false --- Sources/MapLibreSwiftUI/Examples/User Location.swift | 4 ++-- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/MapLibreSwiftUI/Examples/User Location.swift b/Sources/MapLibreSwiftUI/Examples/User Location.swift index 68b28c8..aeaa838 100644 --- a/Sources/MapLibreSwiftUI/Examples/User Location.swift +++ b/Sources/MapLibreSwiftUI/Examples/User Location.swift @@ -14,7 +14,7 @@ private let locationManager = StaticLocationManager(initialLocation: CLLocation( #Preview("Track user location") { MapView( styleURL: demoTilesURL, - camera: .constant(.trackUserLocation(zoom: 8, pitch: .fixed(45))), + camera: .constant(.trackUserLocation(zoom: 4, pitch: .fixed(45))), locationManager: locationManager ) .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0)) @@ -24,7 +24,7 @@ private let locationManager = StaticLocationManager(initialLocation: CLLocation( #Preview("Track user location with Course") { MapView( styleURL: demoTilesURL, - camera: .constant(.trackUserLocationWithCourse(zoom: 8, pitch: .fixed(45))), + camera: .constant(.trackUserLocationWithCourse(zoom: 4, pitch: .fixed(45))), locationManager: locationManager ) .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0)) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 5259f0a..3ced014 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -58,17 +58,20 @@ public class MapViewCoordinator: NSObject { mapView.maximumPitch = pitch.rangeValue.upperBound case let .trackingUserLocation(zoom: zoom, pitch: pitch): mapView.userTrackingMode = .follow - mapView.setZoomLevel(zoom, animated: animated) + // Needs to be non-animated or else it messes up following + mapView.setZoomLevel(zoom, animated: false) mapView.minimumPitch = pitch.rangeValue.lowerBound mapView.maximumPitch = pitch.rangeValue.upperBound case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch): mapView.userTrackingMode = .followWithHeading - mapView.setZoomLevel(zoom, animated: animated) + // Needs to be non-animated or else it messes up following + mapView.setZoomLevel(zoom, animated: false) mapView.minimumPitch = pitch.rangeValue.lowerBound mapView.maximumPitch = pitch.rangeValue.upperBound case let .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch): mapView.userTrackingMode = .followWithCourse - mapView.setZoomLevel(zoom, animated: animated) + // Needs to be non-animated or else it messes up following + mapView.setZoomLevel(zoom, animated: false) mapView.minimumPitch = pitch.rangeValue.lowerBound mapView.maximumPitch = pitch.rangeValue.upperBound case let .rect(boundingBox, padding): From 3c084b3bfd97808c081da54e87cd0f23a37caa77 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Mon, 18 Mar 2024 00:40:03 +0900 Subject: [PATCH 09/15] First round of PR review fixes re: concurrency --- Package.swift | 9 ++- Sources/MapLibreSwiftUI/Examples/Layers.swift | 1 + .../Examples/User Location.swift | 1 + .../MapLibre/MLNMapViewCameraUpdating.swift | 18 ++--- .../Extensions/MapView/MapViewGestures.swift | 8 +- .../UIKit/UIGestureRecognizing.swift | 6 +- Sources/MapLibreSwiftUI/MapView.swift | 3 +- .../MapLibreSwiftUI/MapViewCoordinator.swift | 73 ++++++++++++------- .../Models/MapCamera/CameraPitch.swift | 2 +- .../StaticLocationManager.swift | 27 +++---- .../MapView/MapViewGestureTests.swift | 4 +- .../MapViewCoordinatorCameraTests.swift | 10 +-- 12 files changed, 94 insertions(+), 68 deletions(-) diff --git a/Package.swift b/Package.swift index 99c1f1b..b0ba5b1 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( ], swiftSettings: [ .define("MOCKING", .when(configuration: .debug)), + .enableExperimentalFeature("StrictConcurrency"), ] ), .target( @@ -46,10 +47,16 @@ let package = Package( .target(name: "InternalUtils"), .product(name: "MapLibre", package: "maplibre-gl-native-distribution"), .product(name: "MapLibreSwiftMacros", package: "maplibre-swift-macros"), + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), ] ), .target( - name: "InternalUtils" + name: "InternalUtils", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] ), // MARK: Tests diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index fb2281a..4c464ef 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -5,6 +5,7 @@ import SwiftUI // A collection of points with various // attributes +@MainActor let pointSource = ShapeSource(identifier: "points") { // Uses the DSL to quickly construct point features inline MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139)) diff --git a/Sources/MapLibreSwiftUI/Examples/User Location.swift b/Sources/MapLibreSwiftUI/Examples/User Location.swift index aeaa838..d76da44 100644 --- a/Sources/MapLibreSwiftUI/Examples/User Location.swift +++ b/Sources/MapLibreSwiftUI/Examples/User Location.swift @@ -1,6 +1,7 @@ import CoreLocation import SwiftUI +@MainActor private let locationManager = StaticLocationManager(initialLocation: CLLocation( coordinate: switzerland, altitude: 0, diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift index d46e238..aade5fd 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift @@ -5,15 +5,15 @@ import Mockable @Mockable protocol MLNMapViewCameraUpdating: AnyObject { - var userTrackingMode: MLNUserTrackingMode { get set } - var minimumPitch: CGFloat { get set } - var maximumPitch: CGFloat { get set } - func setCenter(_ coordinate: CLLocationCoordinate2D, - zoomLevel: Double, - direction: CLLocationDirection, - animated: Bool) - func setZoomLevel(_ zoomLevel: Double, animated: Bool) - func setVisibleCoordinateBounds( + @MainActor var userTrackingMode: MLNUserTrackingMode { get set } + @MainActor var minimumPitch: CGFloat { get set } + @MainActor var maximumPitch: CGFloat { get set } + @MainActor func setCenter(_ coordinate: CLLocationCoordinate2D, + zoomLevel: Double, + direction: CLLocationDirection, + animated: Bool) + @MainActor func setZoomLevel(_ zoomLevel: Double, animated: Bool) + @MainActor func setVisibleCoordinateBounds( _ bounds: MLNCoordinateBounds, edgePadding: UIEdgeInsets, animated: Bool, diff --git a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift index fcea64c..34f46e3 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift @@ -9,7 +9,7 @@ extension MapView { /// - mapView: The MLNMapView that will host the gesture itself. /// - context: The UIViewRepresentable context that will orchestrate the response sender /// - gesture: The gesture definition. - func registerGesture(_ mapView: MLNMapView, _ context: Context, gesture: MapGesture) { + @MainActor func registerGesture(_ mapView: MLNMapView, _ context: Context, gesture: MapGesture) { switch gesture.method { case let .tap(numberOfTaps: numberOfTaps): let gestureRecognizer = UITapGestureRecognizer(target: context.coordinator, @@ -41,7 +41,7 @@ extension MapView { /// - mapView: The MapView emitting the gesture. This is used to calculate the point and coordinate of the /// gesture. /// - sender: The UIGestureRecognizer - func processGesture(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) { + @MainActor func processGesture(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) { guard let gesture = gestures.first(where: { $0.gestureRecognizer == sender }) else { assertionFailure("\(sender) is not a registered UIGestureRecongizer on the MapView") return @@ -60,8 +60,8 @@ extension MapView { /// - gesture: The gesture definition for this event. /// - sender: The UIKit gesture emitting from the map view. /// - Returns: The calculated context from the sending UIKit gesture - func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, - sender: UIGestureRecognizing) -> MapGestureContext + @MainActor func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, + sender: UIGestureRecognizing) -> MapGestureContext { // Build the context of the gesture's event. let point: CGPoint = switch gesture.method { diff --git a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift index 09b68d8..c662593 100644 --- a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift +++ b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift @@ -3,9 +3,9 @@ import UIKit @Mockable protocol UIGestureRecognizing: AnyObject { - var state: UIGestureRecognizer.State { get } - func location(in view: UIView?) -> CGPoint - func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint + @MainActor var state: UIGestureRecognizer.State { get } + @MainActor func location(in view: UIView?) -> CGPoint + @MainActor func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint } extension UIGestureRecognizer: UIGestureRecognizing { diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 0813f19..22ee397 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -59,6 +59,7 @@ public struct MapView: UIViewRepresentable { context.coordinator.updateCamera(mapView: mapView, camera: $camera.wrappedValue, animated: false) + mapView.locationManager = mapView.locationManager // Link the style loaded to the coordinator that emits the delegate event. context.coordinator.onStyleLoaded = onStyleLoaded @@ -88,7 +89,7 @@ public struct MapView: UIViewRepresentable { animated: isStyleLoaded) } - private func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) { + @MainActor private func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) { mapView.contentInset = mapViewContentInset mapView.logoView.isHidden = isLogoViewHidden diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 3ced014..5eb27b5 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -11,6 +11,12 @@ public class MapViewCoordinator: NSObject { // every update cycle so we can avoid unnecessary updates private var snapshotUserLayers: [StyleLayerDefinition] = [] private var snapshotCamera: MapViewCamera? + + // Indicates whether we are currently in a push-down camera update cycle. + // This is necessary in order to ensure we don't keep trying to reset a state value which we were already processing + // an update for. + private var isUpdatingCamera = false + var onStyleLoaded: ((MLNStyle) -> Void)? var onGesture: (MLNMapView, UIGestureRecognizer) -> Void @@ -41,12 +47,13 @@ public class MapViewCoordinator: NSObject { /// camera related MLNMapView functionality. /// - camera: The new camera from the binding. /// - animated: Whether to animate. - func updateCamera(mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool) { + @MainActor func updateCamera(mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool) { guard camera != snapshotCamera else { // No action - camera has not changed. return } + isUpdatingCamera = true switch camera.state { case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): mapView.userTrackingMode = .none @@ -85,11 +92,12 @@ public class MapViewCoordinator: NSObject { } snapshotCamera = camera + isUpdatingCamera = false } // MARK: - Coordinator API - Styles + Layers - func updateStyleSource(_ source: MapStyleSource, mapView: MLNMapView) { + @MainActor func updateStyleSource(_ source: MapStyleSource, mapView: MLNMapView) { switch (source, parent.styleSource) { case let (.url(newURL), .url(oldURL)): if newURL != oldURL { @@ -98,7 +106,7 @@ public class MapViewCoordinator: NSObject { } } - func updateLayers(mapView: MLNMapView) { + @MainActor func updateLayers(mapView: MLNMapView) { // TODO: Figure out how to selectively update layers when only specific props changed. New function in addition to makeMLNStyleLayer? // TODO: Extract this out into a separate function or three... @@ -195,35 +203,48 @@ extension MapViewCoordinator: MLNMapViewDelegate { /// The MapView's region has changed with a specific reason. public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { + guard !isUpdatingCamera else { + return + } + // 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 isProgrammaticallyTracking: Bool = switch parent.camera.state { - case .centered(onCoordinate: _): - false - case .trackingUserLocation: - mapView.userTrackingMode == .follow - case .trackingUserLocationWithHeading: - mapView.userTrackingMode == .followWithHeading - case .trackingUserLocationWithCourse: - mapView.userTrackingMode == .followWithCourse - case .rect(boundingBox: _, edgePadding: _): - false - case .showcase(shapeCollection: _): - false - } + // NOTE: The use of assumeIsolated is just to make Swift strict concurrency checks happy. + // This invariant is upheld by the MLNMapView. + MainActor.assumeIsolated { + 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 + } - if isProgrammaticallyTracking { - // Programmatic tracking is still active, we can ignore camera updates until we unset/fail this boolean - // check - return - } + guard !isProgrammaticallyTracking else { + // Programmatic tracking is still active, we can ignore camera updates until we unset/fail this boolean + // check + return + } - DispatchQueue.main.async { // Publish the MLNMapView's "raw" camera state to the MapView camera binding. - self.parent.camera = .center(mapView.centerCoordinate, - zoom: mapView.zoomLevel, - reason: CameraChangeReason(reason)) + // This path only executes when the map view diverges from the parent state, so this is a "matter of fact" + // state propagation. + let newCamera: MapViewCamera = .center(mapView.centerCoordinate, + zoom: mapView.zoomLevel, + // TODO: Pitch doesn't really describe current state + pitch: .freeWithinRange( + minimum: mapView.minimumPitch, + maximum: mapView.maximumPitch + ), + direction: mapView.direction, + reason: CameraChangeReason(reason)) + snapshotCamera = newCamera + self.parent.camera = newCamera } } } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift index d6a9252..45d3cbd 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift @@ -2,7 +2,7 @@ import Foundation import MapLibre /// The current pitch state for the MapViewCamera -public enum CameraPitch: Hashable { +public enum CameraPitch: Hashable, Sendable { /// The user is free to control pitch from it's default min to max. case free diff --git a/Sources/MapLibreSwiftUI/StaticLocationManager.swift b/Sources/MapLibreSwiftUI/StaticLocationManager.swift index 1aed855..d6d109e 100644 --- a/Sources/MapLibreSwiftUI/StaticLocationManager.swift +++ b/Sources/MapLibreSwiftUI/StaticLocationManager.swift @@ -11,21 +11,12 @@ import MapLibre /// You can provide a new location by setting the ``lastLocation`` property. /// /// This class does not ever perform any authorization checks. That is the responsiblity of the caller. -public class StaticLocationManager: NSObject { - public var delegate: (any MLNLocationManagerDelegate)? = nil { - didSet { - DispatchQueue.main.async { - self.delegate?.locationManagerDidChangeAuthorization(self) - self.delegate?.locationManager(self, didUpdate: [self.lastLocation]) - } - } - } +public final class StaticLocationManager: NSObject, @unchecked Sendable { + public var delegate: (any MLNLocationManagerDelegate)? public var authorizationStatus: CLAuthorizationStatus = .authorizedAlways { didSet { - DispatchQueue.main.async { - self.delegate?.locationManagerDidChangeAuthorization(self) - } + delegate?.locationManagerDidChangeAuthorization(self) } } @@ -33,9 +24,7 @@ public class StaticLocationManager: NSObject { public var lastLocation: CLLocation { didSet { - DispatchQueue.main.async { - self.delegate?.locationManager(self, didUpdate: [self.lastLocation]) - } + delegate?.locationManager(self, didUpdate: [lastLocation]) } } @@ -54,7 +43,13 @@ extension StaticLocationManager: MLNLocationManager { } public func startUpdatingLocation() { - // Do nothing + // This has to be async dispatched or else the map view will not update immediately if the camera is set to + // follow the user's location. This leads to some REALLY (unbearably) bad artifacts. We should find a better + // solution for this at some point. This is the reason for the @unchecked Sendable conformance by the way (so + // that we don't get a warning about using non-sendable self; it should be safe though). + DispatchQueue.main.async { + self.delegate?.locationManager(self, didUpdate: [self.lastLocation]) + } } public func stopUpdatingLocation() { diff --git a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift index f2f0593..4baacde 100644 --- a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -27,7 +27,7 @@ final class MapViewGestureTests: XCTestCase { // MARK: Gesture Processing - func testTapGesture() { + @MainActor func testTapGesture() { let gesture = MapGesture(method: .tap(numberOfTaps: 2)) { _ in // Do nothing } @@ -52,7 +52,7 @@ final class MapViewGestureTests: XCTestCase { XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1) } - func testLongPressGesture() { + @MainActor func testLongPressGesture() { let gesture = MapGesture(method: .longPress(minimumDuration: 1)) { _ in // Do nothing } diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index 411a7bc..e181a80 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -16,7 +16,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { } } - func testUnchangedCamera() { + @MainActor func testUnchangedCamera() { let camera: MapViewCamera = .default() coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) @@ -50,7 +50,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(count: 0) } - func testCenterCameraUpdate() { + @MainActor func testCenterCameraUpdate() { let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) let newCamera: MapViewCamera = .center(coordinate, zoom: 13) @@ -80,7 +80,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(count: 0) } - func testUserTrackingCameraUpdate() { + @MainActor func testUserTrackingCameraUpdate() { let newCamera: MapViewCamera = .trackUserLocation() coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) @@ -109,7 +109,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(count: 1) } - func testUserTrackingWithCourseCameraUpdate() { + @MainActor func testUserTrackingWithCourseCameraUpdate() { let newCamera: MapViewCamera = .trackUserLocationWithCourse() coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) @@ -138,7 +138,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { .called(count: 1) } - func testUserTrackingWithHeadingUpdate() { + @MainActor func testUserTrackingWithHeadingUpdate() { let newCamera: MapViewCamera = .trackUserLocationWithHeading() coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) From 05c7af36e7ecc5e44e3fc22731134a34d3692d17 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Mon, 18 Mar 2024 02:17:31 +0900 Subject: [PATCH 10/15] Break out map controls --- Sources/MapLibreSwiftDSL/MapControls.swift | 137 ++++++++++++++++++ .../Examples/User Location.swift | 5 +- Sources/MapLibreSwiftUI/MapView.swift | 41 ++---- .../MapLibreSwiftUI/MapViewModifiers.swift | 17 +++ .../Examples/MapControlsTests.swift | 8 + 5 files changed, 179 insertions(+), 29 deletions(-) create mode 100644 Sources/MapLibreSwiftDSL/MapControls.swift create mode 100644 Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift diff --git a/Sources/MapLibreSwiftDSL/MapControls.swift b/Sources/MapLibreSwiftDSL/MapControls.swift new file mode 100644 index 0000000..6593b6b --- /dev/null +++ b/Sources/MapLibreSwiftDSL/MapControls.swift @@ -0,0 +1,137 @@ +import Foundation +import MapLibre + +public protocol MapControl { + /// Overrides the position of the control. Default values are control-specfiic. + var position: MLNOrnamentPosition? { get set } + /// Overrides the offset of the control. + var margins: CGPoint? { get set } + /// Overrides whether the control is hidden. + var isHidden: Bool { get set } + + @MainActor func configureMapView(_ mapView: MLNMapView) +} + +public extension MapControl { + /// Sets position of the control. + func position(_ position: MLNOrnamentPosition) -> Self { + var result = self + + result.position = position + + return result + } + + /// Sets the position offset of the control. + func margins(_ margins: CGPoint) -> Self { + var result = self + + result.margins = margins + + return result + } + + /// Hides the control. + func hidden(_: Bool) -> Self { + var result = self + + result.isHidden = true + + return result + } +} + +public struct CompassView: MapControl { + public var position: MLNOrnamentPosition? + public var margins: CGPoint? + public var isHidden: Bool = false + + public func configureMapView(_ mapView: MLNMapView) { + if let position { + mapView.compassViewPosition = position + } + + if let margins { + mapView.compassViewMargins = margins + } + + mapView.compassView.isHidden = isHidden + } + + public init() {} +} + +public struct LogoView: MapControl { + public var position: MLNOrnamentPosition? + public var margins: CGPoint? + public var isHidden: Bool = false + public var image: UIImage? + + public init() {} + + public func configureMapView(_ mapView: MLNMapView) { + if let position { + mapView.logoViewPosition = position + } + + if let margins { + mapView.logoViewMargins = margins + } + + mapView.logoView.isHidden = isHidden + + if let image { + mapView.logoView.image = image + } + } +} + +public extension LogoView { + /// Sets the logo image (defaults to the MapLibre logo). + func image(_ image: UIImage?) -> Self { + var result = self + + result.image = image + + return result + } +} + +@resultBuilder +public enum MapControlsBuilder: DefaultResultBuilder { + public static func buildExpression(_ expression: MapControl) -> [MapControl] { + [expression] + } + + public static func buildExpression(_ expression: [MapControl]) -> [MapControl] { + expression + } + + public static func buildExpression(_: Void) -> [MapControl] { + [] + } + + public static func buildBlock(_ components: [MapControl]...) -> [MapControl] { + components.flatMap { $0 } + } + + public static func buildArray(_ components: [MapControl]) -> [MapControl] { + components + } + + public static func buildArray(_ components: [[MapControl]]) -> [MapControl] { + components.flatMap { $0 } + } + + public static func buildEither(first components: [MapControl]) -> [MapControl] { + components + } + + public static func buildEither(second components: [MapControl]) -> [MapControl] { + components + } + + public static func buildOptional(_ components: [MapControl]?) -> [MapControl] { + components ?? [] + } +} diff --git a/Sources/MapLibreSwiftUI/Examples/User Location.swift b/Sources/MapLibreSwiftUI/Examples/User Location.swift index d76da44..3bafb2f 100644 --- a/Sources/MapLibreSwiftUI/Examples/User Location.swift +++ b/Sources/MapLibreSwiftUI/Examples/User Location.swift @@ -1,4 +1,5 @@ import CoreLocation +import MapLibreSwiftDSL import SwiftUI @MainActor @@ -29,6 +30,8 @@ private let locationManager = StaticLocationManager(initialLocation: CLLocation( locationManager: locationManager ) .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0)) - .hideCompassView() + .mapControls { + LogoView() + } .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 22ee397..44f92ae 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -20,6 +20,11 @@ public struct MapView: UIViewRepresentable { /// See ``unsafeMapViewModifier(_:)`` var unsafeMapViewModifier: ((MLNMapView) -> Void)? + var controls: [MapControl] = [ + CompassView(), + LogoView(), + ] + private var locationManager: MLNLocationManager? public init( @@ -92,8 +97,14 @@ public struct MapView: UIViewRepresentable { @MainActor private func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) { mapView.contentInset = mapViewContentInset - mapView.logoView.isHidden = isLogoViewHidden - mapView.compassView.isHidden = isCompassViewHidden + // Assume all controls are hidden by default (so that an empty list returns a map with no controls) + mapView.logoView.isHidden = true + mapView.compassView.isHidden = true + + // Apply each control configuration + for control in controls { + control.configureMapView(mapView) + } if runUnsafe { unsafeMapViewModifier?(mapView) @@ -101,32 +112,6 @@ public struct MapView: UIViewRepresentable { } } -public extension MapView { - func mapViewContentInset(_ inset: UIEdgeInsets) -> Self { - var result = self - - result.mapViewContentInset = inset - - return result - } - - func hideLogoView() -> Self { - var result = self - - result.isLogoViewHidden = true - - return result - } - - func hideCompassView() -> Self { - var result = self - - result.isCompassViewHidden = true - - return result - } -} - #Preview { MapView(styleURL: demoTilesURL) .ignoresSafeArea(.all) diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index bc8aa96..7f4641a 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -1,5 +1,6 @@ import Foundation import MapLibre +import MapLibreSwiftDSL import SwiftUI public extension MapView { @@ -82,4 +83,20 @@ public extension MapView { return newMapView } + + func mapViewContentInset(_ inset: UIEdgeInsets) -> Self { + var result = self + + result.mapViewContentInset = inset + + return result + } + + func mapControls(@MapControlsBuilder _ buildControls: () -> [MapControl]) -> Self { + var result = self + + result.controls = buildControls() + + return result + } } diff --git a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift new file mode 100644 index 0000000..c127a87 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Ian Wagner on 2024-03-18. +// + +import Foundation From b7f55bb12c5fe17b5f39dad65a92f6ac7ed17c3c Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Mon, 18 Mar 2024 02:17:51 +0900 Subject: [PATCH 11/15] Add token snapshot test (though it doesn't work at the moment, like the rest) --- .../Examples/MapControlsTests.swift | 51 +++++++++++++++--- .../testCompassChangePosition.1.png | Bin 0 -> 70816 bytes .../MapControlsTests/testCompassOnly.1.png | Bin 0 -> 70816 bytes .../MapControlsTests/testEmptyControls.1.png | Bin 0 -> 70816 bytes .../MapControlsTests/testLogoOnly.1.png | Bin 0 -> 79642 bytes 5 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassChangePosition.1.png create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassOnly.1.png create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testEmptyControls.1.png create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoOnly.1.png diff --git a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift index c127a87..06bd75d 100644 --- a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift +++ b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift @@ -1,8 +1,43 @@ -// -// File.swift -// -// -// Created by Ian Wagner on 2024-03-18. -// - -import Foundation +import MapLibreSwiftDSL +import SnapshotTesting +import XCTest +@testable import MapLibreSwiftUI + +final class MapControlsTests: XCTestCase { + func testEmptyControls() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + // No controls + } + } + } + + func testLogoOnly() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + LogoView() + } + } + } + + func testCompassOnly() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + CompassView() + } + } + } + + func testCompassChangePosition() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + CompassView() + .position(.topLeft) + } + } + } +} diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassChangePosition.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassChangePosition.1.png new file mode 100644 index 0000000000000000000000000000000000000000..22296cee048a86405a0c4dc8a0e0963142303412 GIT binary patch literal 70816 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 V-nAYhw=wLW?S{KZ>6>hR{Wq%Q_NV{= literal 0 HcmV?d00001 diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassOnly.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testCompassOnly.1.png new file mode 100644 index 0000000000000000000000000000000000000000..22296cee048a86405a0c4dc8a0e0963142303412 GIT binary patch literal 70816 zcmeHQdpML^`yPWrMYGLLIc3-WG9o$&iBXMCcA<12q-@!dkem<0jvPAf5`_@aE^R|a z%5iIULQy-%9L6D)Lnf!jVVG}ftQq!Gju-t~r? z?Akg{LRJEU!OYvfjkFts5oMPpdcQ&|?^p$?sUOU%&p;v47dM zCtrmVg?KF{V$runB@%^ES)%7_waABme0>#n*=FvB!Tg~8^;gI+YLyN9P5EOaV>9+E z`+#46L_OI55jd}3%fU*zI?AOOi~(jlX``8!P*3BZxFQ2F>dH}P*~ZyB#AORTa$byz z%~vuA#0X(UrWCw1R?DUggW z`)6;y)!2M<)|3u%yX<26yP)PpzWY3QVAj-ALNyjjX!4yd`?J8aGj-^8$!b)0YLAOd z1RvNE?;xoEK_VOUqW&+IXh|{=7S#VATRJo63bMpX!3P$S36T}leN;6fa; zfD57V11^LtF+)6tD1;&jL?Mo=z(j^{4oqZRO8^$)U>?9ilRyyQG-4%z0H--192gm* zbHK=uC4i9uBb!FivB1cHk)f%R11}*jgpy*AT11wB)FNk$TndEjG-n2d>@-U9gTTSz zpdfG{WCa=-vIJ;k9OeZY8A|Jez=13Q0tW~jC@;gAK|$aEfdiq+N#+Ft2M8R~gG3+* z9Ft=N1PAU z*uZLrow+gsYX$EY)iT3w6l#KVB}G{488*V2ff9j%kW>i<`i2d_gU~bpLu_&ifEdJ? z6Cp?==^AFS$?OtL^PEH@;Gjv^Tm(RIa{dG)&0!%xH8>$+K2Qyy8VFkh)tHiA!HNLY z0IGqo^|v4aR0Eju*5kNJ7Y5>)kHf&$sxd87iPn#TssXA7@&yi@2!sVxplVEu6+ktBY5>)k zoF}H^2%s83HGpbN9}}FEB`lyqunJTSP&Gi+n3kavsA?1k)dpiQ!d2Tz8_iIbTMSm4 zq3H#983%*HjV)+!{MYcZ`4|j_#iHo!+J*M|=xO{D$G+2#x^mQ6wsH0jaoIwToEM{L z#+*tvyfjwQMKS1j%qE! z8Dv&qWYcF*U}Okm!t+BWnHN0i8ZE?~;XW47$Pf{}KW1P%~5rUwIn(+ETYoJN)aI1O+b;B@BP=>U?Gsse!n;Pmtv6qcO9k~4%V zLEr#^qhvM0QB%DF1dji7fg>{G%MP>`z;rKW$2YjYo)hGMZ0Q7C^ewdtH%B9Q40pD2 zr~%yBiY7crGa^gCI%-%)jn=quW>82oB6tjGMo2UMN2VETn{PZT+neBtQO^;#n-jj< zL?v`+1lY4~49Ob|Z9M{O1#F=5vu2(BO`9kP0A1IW|{5(o4a2=k7|YZ5QGLSXo& zMQF^TdF?CqQxd(Wcvj%{Wf6ia6p?Puo!S68%}dyQ7ym+kOzX0t;0iWLCbJOw=s@M!25SWlQnV|x>5$}S48-W|~jVg#nyZ{H$2)PGLYA95Qh{2@B z!v=tjP_zJS!~+%pu~FO%1c;3&6)-gvD&{KzQ{$08U}}i`0^#QY3n2U`ZVF)`Z-k-+ zi_Hfy z1YvIwLwJ4xVhAs10b&TsaDo`Z0~R2Lptw1M7*c3~)u4pu43&nt^@b0Jb}x~%Ug%n6 zh)d8>Zo6W?rN{FG^@9va{-hz5$RZs1Qy5nsAAVq^#Pqr~0|d2p6gm;YC|@ zT170^h-!6otGwGD>fP$(@dX5ltz+YEL%i6DD$*tLzSN{v%EzrMt!`6bpRxCD>Yz6+ zA!czV;}|Zt@;o8!SyM`j7mdDFwJlP2X^qpumybxM+A;Llv}GuH%mpnAmwu+RvhFm> zQJ$+7j=k!`k$vk^ueLU@l-ovXOXP2LleOO2%cXni94YM&(=l2)v3jxB{c%_6=+Huj zGtN`5$vp!%yw6k{>wMYhN50lSt}uxw`PFH*M|;}+O?TYCwUJ38sj#~I{;-lU-BEg4 z{rvEd>%Q1>Gh6SE+nWZeAJ>!?up2X9MK(sZ<+i!rM48!J-<2@pEben+V)NgnTuz@)R-dSO2eKQLmSH8_Wt=-oHfF| z20nSDKe?wt9-5s`NwK8qkJN{%J}`^xIuucSXF~F^J}+EPwi6^7)DRz0r&Am5do_6M zhFo9W90hXoTP@FfVZ$HqIai${be5{J)(o@ zM2|PZTh>o_J*U0M&-b~}@#OXg+5WrLn+tMQ#95o>h%O}ZU-N70v6K(DnoEq+qMYqS zN1~K(SGZruzbq<6ioCtpQzn+&mw#tu?azj-b)JP%5m}j>&l3*y z*-pE8k`OLzT6tBO4pj~?E<{9sW?-m_H6ciOy5 z%55@c!ElEhZt;c!i-!tsd)C-5H{RBprD$1}N6ss_?iQIm7!$^%cH*2V&b{*P7jLy6 zxt@r@NcnFkZ8Rg?Q~fAlm=y^kVQNZsyK}r6^V8#(O*K*awtc}3 zF}houma0f}OZe#hGo}O<8Hn_f(=FPZ$d=TI=vdvRN6}`LnU4u-Qag_IccY{zkXrjNUUy8 zOxMUnCozU-Ox$XQ|C0P(lixJL-+K*@bPYCHD{sxqO0R!&qVa3EtnN?$X;g1`;ArLE z3;XolTtlzl_|UMbknHL{^2wzTAM$j_(}TY&ir7$%F10shk+kdOKFq_yM2pyt_H(gI z^aCuYCzQ7^s~JlbM`^Pp^FlVudv6|UF1zwu=y(c2{)=u4lfP#Q*p!?GI}iKo+1+tr zn)Fl5`sV=y1GP(<$;Us8?@Z8QHkjfiS$Zy_{8ep5(*1HCkW}9U+etV(j;{AQJfujs z(rMH+!%NoE>c#nj?Gq8n{QDM3<3D2(Wa`}C?klt!+%}|GE`2R@gx$Z?0ylm)KP46! zdNxwC{iU&@I%~Y{jsp4qUcZ4aqhajOk;xdj?smr4F`~G2R9Bb(>MsoHf7qVcVLrm5 z9MXL+m7T;IZ~N;8W4+TBYMNuH=V^AmdfhdA^$enmL>cZ;_9=wC<-`XWos`*bS4a92 zwy632K1zRUIVa%Q&Nue=6zT8${&w|L@fnb=E_CcxjwsH)bt4o-7jCq$*%XxAfAe2W z9re;_%lztZO|-nQt9Cn;Fz{$Xv+b|yE9-l1v4*3R(`r`{DNYC20q=0Bt_FXX3|ow% zPes|&b(b9(KU0=aJ^E+B2aovV+#eg6{?|{)zpE|VpH}NiZQB^{%CuFLC}H#+ZxeEw_#Dv9~8aZ9cAkxiv6D~BM`lh9`rn@cQGl$p-q|J-!^}DZC&yM0y8({ zlhyN;5yi#{P2Pozb+huvKIvac$~Z`62&g0d(VFEnux9LxDZbZti@oWAg2gq_NiGNa zD~z}o2^CCmb^a&pU9-t&6Y90jC4KfRXz(uV2!HW(bT?kosM(wUOqOGP6Kc%kvUCo! zzn?L-W>KMwYsjsv#RQ3i$sSAiZ5S>hne+PtLgV41)Mw|V^Y31#J+i_Vd_MXif8y+;ZdH8G_LXa+6+4lOplhM@ zYER20)%9nK?+5t(W$Zg~%$9gOjS%PSJJdX3x83&9Yeo=0#Q%-=4}4>}C{e`6{|v3b zo)j4xIJYb9e3HwME-v>*ojHD2EUwRNPQstszr`lGEcu~OnR_%;SQt@c&mO~7WoE4* zjM~D;+lRu3dvD-Mnj1$bW5>$L6ZsXi#nI%#&o_NkUzIeP<=>`EBu?mQx{4ScmX5fv zk^eMuiY|9l@BZ{unUwc1m9^(;ZIo@~-R<&L_vwZ(vqOWU1+%mtOkckVmgK7AX zooCl8@|}}`Eq1p0?+bhmJ-*9&%CI;VfBdkvfAhP_LyFYeTJ?3>@N z6h`zNVmCx9apoV7XVg}9*Ep1BkGtYLi4C-nKP(hnJB^oAJn=ors;DR|>g+s*V#tiaFfCfV&kHuR zWyI$Q2oZ|dgE1AVS3($rkIyu=uRYKFrOy^;r}recPOWxy`0V+(q`uPx2{~nV1OD)` zQ9QD8EOuS8MbXvWy-BlYxz{iP8t2I^eXsV(|GaL+xg5_9U*f(7@*TqQgN-BHGvyW> z#4VXN^d;#((XUoc_}V%B@su&%c3;uIo_e&pHgIjM(RbGFyg0C@OtlDaw5MU`hIjIrW+4Y(;8qL-$hkJYrk&?Jw$LTx(z43doT=b%OH~}9cDzjanZi7m+sfs_#C1| zYxvjI)%~FtxA+Z=hS7+&Ot;A5&!K`nf54ZUYD4<|9#k3i3&Z^#v5IF-el89ZR*x92Vy+ZTf6440p(`Oxt#u1Hm;n@D2UO|P0BcCTCx-cGj15Tb|1_3kjC-jRHhwnt6KA* zd2q*_=WG&P=X#X-(643tb-bGK+IW49Hlt{gy!&RV=!nbZ)jBz@?UDIeZ)H*m_jZ27 z@SlyymO}=n+N1QFy4)l23MK~)Ob^7q65?K8Ug2CpV3NxqZL=p|N0kf`vjdYd*4-55 z&LX_RLVqE{){ljuy?jB|Ake@`$ndb!rrEsXbK71yij63KO_pjBaFc(~TlQyfgC)6N z<`rxPMe!lYEX5~+HW|obf0iY#AfO{fALp=h8Z5n2vjy18f&H1I^m@MQ%ib5^wTub= z!i4WfNuHUwXI{0y1Yfi46oxMr2w{^o5()C-j8_WWFY^k&uSkRo_Ya5o|Gsd;#IED& V-nAYhw=wLW?S{KZ>6>hR{Wq%Q_NV{= literal 0 HcmV?d00001 diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testEmptyControls.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testEmptyControls.1.png new file mode 100644 index 0000000000000000000000000000000000000000..22296cee048a86405a0c4dc8a0e0963142303412 GIT binary patch literal 70816 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 V-nAYhw=wLW?S{KZ>6>hR{Wq%Q_NV{= literal 0 HcmV?d00001 diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoOnly.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoOnly.1.png new file mode 100644 index 0000000000000000000000000000000000000000..321b769674b2b4344fa12f12f5cda73de73a2d71 GIT binary patch literal 79642 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 literal 0 HcmV?d00001 From 742622d500486e9c8ca49f86710e47f0b53953c2 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Mon, 18 Mar 2024 02:30:59 +0900 Subject: [PATCH 12/15] Minor refactoring; attempt to fix CI --- .github/workflows/test.yml | 6 +- .../MapLibreSwiftUI/MapViewCoordinator.swift | 78 ++++++++++--------- 2 files changed, 44 insertions(+), 40 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9275cb5..98040f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: run: brew install swiftformat - name: Checkout maplibre-swiftui-dsl-playground - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Check format run: swiftformat . --lint @@ -38,10 +38,10 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '15.0' + xcode-version: '15.2' - name: Checkout maplibre-swiftui-dsl-playground - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Test ${{ matrix.scheme }} on ${{ matrix.destination }} run: xcodebuild -scheme ${{ matrix.scheme }} test -skipMacroValidation -destination '${{ matrix.destination }}' | xcbeautify && exit ${PIPESTATUS[0]} diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 5eb27b5..20e3489 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -201,50 +201,54 @@ extension MapViewCoordinator: MLNMapViewDelegate { onStyleLoaded?(mglStyle) } - /// The MapView's region has changed with a specific reason. - public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { - guard !isUpdatingCamera else { - return - } - + @MainActor private func updateParentCamera(mapView: MLNMapView, reason: MLNCameraChangeReason) { // If any of these are a mismatch, we know the camera is no longer following a desired method, so we should // detach and revert to a .centered camera. If any one of these is true, the desired camera state still // matches the mapView's userTrackingMode // NOTE: The use of assumeIsolated is just to make Swift strict concurrency checks happy. // This invariant is upheld by the MLNMapView. - MainActor.assumeIsolated { - 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 - } + 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 - } + guard !isProgrammaticallyTracking else { + // Programmatic tracking is still active, we can ignore camera updates until we unset/fail this boolean + // check + return + } + + // Publish the MLNMapView's "raw" camera state to the MapView camera binding. + // This path only executes when the map view diverges from the parent state, so this is a "matter of fact" + // state propagation. + let newCamera: MapViewCamera = .center(mapView.centerCoordinate, + zoom: mapView.zoomLevel, + // TODO: Pitch doesn't really describe current state + pitch: .freeWithinRange( + minimum: mapView.minimumPitch, + maximum: mapView.maximumPitch + ), + direction: mapView.direction, + reason: CameraChangeReason(reason)) + snapshotCamera = newCamera + self.parent.camera = newCamera + } + + /// The MapView's region has changed with a specific reason. + public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { + guard !isUpdatingCamera else { + return + } - // Publish the MLNMapView's "raw" camera state to the MapView camera binding. - // This path only executes when the map view diverges from the parent state, so this is a "matter of fact" - // state propagation. - let newCamera: MapViewCamera = .center(mapView.centerCoordinate, - zoom: mapView.zoomLevel, - // TODO: Pitch doesn't really describe current state - pitch: .freeWithinRange( - minimum: mapView.minimumPitch, - maximum: mapView.maximumPitch - ), - direction: mapView.direction, - reason: CameraChangeReason(reason)) - snapshotCamera = newCamera - self.parent.camera = newCamera + MainActor.assumeIsolated { + updateParentCamera(mapView: mapView, reason: reason) } } } From 8635f1deba73b48839480b28bc6ceb30029478a3 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Mon, 18 Mar 2024 03:10:06 +0900 Subject: [PATCH 13/15] swiftfmt and bug fix --- Sources/MapLibreSwiftUI/MapView.swift | 4 ++++ Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 10 +++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 44f92ae..5b10d9e 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -52,7 +52,11 @@ public struct MapView: UIViewRepresentable { mapView.delegate = context.coordinator context.coordinator.mapView = mapView + // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as + // content insets can trigger a change) + context.coordinator.suppressCameraUpdatePropagation = true applyModifiers(mapView, runUnsafe: false) + context.coordinator.suppressCameraUpdatePropagation = false mapView.locationManager = locationManager diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 20e3489..7c92933 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -15,7 +15,7 @@ public class MapViewCoordinator: NSObject { // 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. - private var isUpdatingCamera = false + var suppressCameraUpdatePropagation = false var onStyleLoaded: ((MLNStyle) -> Void)? var onGesture: (MLNMapView, UIGestureRecognizer) -> Void @@ -53,7 +53,7 @@ public class MapViewCoordinator: NSObject { return } - isUpdatingCamera = true + suppressCameraUpdatePropagation = true switch camera.state { case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): mapView.userTrackingMode = .none @@ -92,7 +92,7 @@ public class MapViewCoordinator: NSObject { } snapshotCamera = camera - isUpdatingCamera = false + suppressCameraUpdatePropagation = false } // MARK: - Coordinator API - Styles + Layers @@ -238,12 +238,12 @@ extension MapViewCoordinator: MLNMapViewDelegate { direction: mapView.direction, reason: CameraChangeReason(reason)) snapshotCamera = newCamera - self.parent.camera = newCamera + parent.camera = newCamera } /// The MapView's region has changed with a specific reason. public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { - guard !isUpdatingCamera else { + guard !suppressCameraUpdatePropagation else { return } From 1c321e6df38562f62fc5e3b93f7f6a00ca92bfed Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Mon, 18 Mar 2024 03:15:41 +0900 Subject: [PATCH 14/15] Even worse hack to fix CI --- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 7c92933..58dca04 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -247,7 +247,8 @@ extension MapViewCoordinator: MLNMapViewDelegate { return } - MainActor.assumeIsolated { + // FIXME: CI complains about this being unavailable before iOS 17, despite building on iOS 17.2... This is an epic hack to fix it for now. I can only assume this is an issue with Xcode pre-15.3 + Task { @MainActor in updateParentCamera(mapView: mapView, reason: reason) } } From 879bd06a931c2cada17e5d9fe354527bc04133cf Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Tue, 19 Mar 2024 11:31:33 +0900 Subject: [PATCH 15/15] PR fixes --- .../MapLibre/MLNMapViewCameraUpdating.swift | 1 + .../MapLibreSwiftUI/MapViewCoordinator.swift | 7 +++++-- .../MapLibreSwiftUI/StaticLocationManager.swift | 1 + .../Examples/MapControlsTests.swift | 10 ++++++++++ .../testLogoChangePosition.1.png | Bin 0 -> 82480 bytes 5 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoChangePosition.1.png diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift index aade5fd..7568bc2 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift @@ -3,6 +3,7 @@ import Foundation import MapLibre import Mockable +// NOTE: We should eventually mark the entire protocol @MainActor, but Mockable generates some unsafe code at the moment @Mockable protocol MLNMapViewCameraUpdating: AnyObject { @MainActor var userTrackingMode: MLNUserTrackingMode { get set } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 58dca04..d242491 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -54,6 +54,10 @@ public class MapViewCoordinator: NSObject { } suppressCameraUpdatePropagation = true + defer { + suppressCameraUpdatePropagation = false + } + switch camera.state { case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): mapView.userTrackingMode = .none @@ -92,7 +96,6 @@ public class MapViewCoordinator: NSObject { } snapshotCamera = camera - suppressCameraUpdatePropagation = false } // MARK: - Coordinator API - Styles + Layers @@ -247,7 +250,7 @@ extension MapViewCoordinator: MLNMapViewDelegate { return } - // FIXME: CI complains about this being unavailable before iOS 17, despite building on iOS 17.2... This is an epic hack to fix it for now. I can only assume this is an issue with Xcode pre-15.3 + // FIXME: CI complains about MainActor.assumeIsolated being unavailable before iOS 17, despite building on iOS 17.2... This is an epic hack to fix it for now. I can only assume this is an issue with Xcode pre-15.3 Task { @MainActor in updateParentCamera(mapView: mapView, reason: reason) } diff --git a/Sources/MapLibreSwiftUI/StaticLocationManager.swift b/Sources/MapLibreSwiftUI/StaticLocationManager.swift index d6d109e..ecee763 100644 --- a/Sources/MapLibreSwiftUI/StaticLocationManager.swift +++ b/Sources/MapLibreSwiftUI/StaticLocationManager.swift @@ -20,6 +20,7 @@ public final class StaticLocationManager: NSObject, @unchecked Sendable { } } + // TODO: Investigate what this does and document it public var headingOrientation: CLDeviceOrientation = .portrait public var lastLocation: CLLocation { diff --git a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift index 06bd75d..0d087bd 100644 --- a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift +++ b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift @@ -22,6 +22,16 @@ final class MapControlsTests: XCTestCase { } } + func testLogoChangePosition() { + assertView { + MapView(styleURL: demoTilesURL) + .mapControls { + LogoView() + .position(.topLeft) + } + } + } + func testCompassOnly() { assertView { MapView(styleURL: demoTilesURL) diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoChangePosition.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/MapControlsTests/testLogoChangePosition.1.png new file mode 100644 index 0000000000000000000000000000000000000000..7bbb8f1fa7a6ac126c89cba7b5c26fdf496a3bc6 GIT binary patch literal 82480 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
    x{4zFS~Q(K?qZ7?-i1WK4JoPD$bE<|MpR-m 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 bx1ustk=gO>Zh!$5@N+@qihBMz^WgslW|<{T literal 0 HcmV?d00001