From 113f368b79aaf5ed55c8624d47284d3e33cc1ec8 Mon Sep 17 00:00:00 2001 From: P W Date: Wed, 15 May 2024 15:56:21 +0200 Subject: [PATCH 01/23] Pitch improvements (#1) * updating tests * adding pitch setup for initial load * renaming CameraPitch * pitch workaround for initial load * format improvements * fixing tests * linting --- .../Examples/User Location.swift | 4 +- .../MapLibre/MLNMapViewCameraUpdating.swift | 3 + .../MapViewCameraOperations.swift | 52 ++++--- .../MapLibreSwiftUI/MapViewCoordinator.swift | 143 +++++++++++++++--- ...meraPitch.swift => CameraPitchRange.swift} | 2 +- .../Models/MapCamera/CameraState.swift | 19 ++- .../Models/MapCamera/MapViewCamera.swift | 48 ++++-- .../MapViewCoordinatorCameraTests.swift | 31 ++++ .../Models/MapCamera/CameraPitchTests.swift | 6 +- .../Models/MapCamera/CameraStateTests.swift | 22 ++- .../Models/MapCamera/MapViewCameraTests.swift | 12 +- .../testCenterCameraState.1.txt | 2 +- .../testTrackingUserLocation.1.txt | 2 +- .../testTrackingUserLocationWithCourse.1.txt | 2 +- .../testTrackingUserLocationWithHeading.1.txt | 2 +- .../MapViewCameraTests/testCenterCamera.1.txt | 8 +- .../testTrackUserLocationWithCourse.1.txt | 5 +- .../testTrackUserLocationWithHeading.1.txt | 5 +- .../testTrackingUserLocation.1.txt | 5 +- 19 files changed, 270 insertions(+), 103 deletions(-) rename Sources/MapLibreSwiftUI/Models/MapCamera/{CameraPitch.swift => CameraPitchRange.swift} (94%) diff --git a/Sources/MapLibreSwiftUI/Examples/User Location.swift b/Sources/MapLibreSwiftUI/Examples/User Location.swift index 3bafb2f..b30b575 100644 --- a/Sources/MapLibreSwiftUI/Examples/User Location.swift +++ b/Sources/MapLibreSwiftUI/Examples/User Location.swift @@ -16,7 +16,7 @@ private let locationManager = StaticLocationManager(initialLocation: CLLocation( #Preview("Track user location") { MapView( styleURL: demoTilesURL, - camera: .constant(.trackUserLocation(zoom: 4, pitch: .fixed(45))), + camera: .constant(.trackUserLocation(zoom: 4, pitch: 45)), locationManager: locationManager ) .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0)) @@ -26,7 +26,7 @@ private let locationManager = StaticLocationManager(initialLocation: CLLocation( #Preview("Track user location with Course") { MapView( styleURL: demoTilesURL, - camera: .constant(.trackUserLocationWithCourse(zoom: 4, pitch: .fixed(45))), + camera: .constant(.trackUserLocationWithCourse(zoom: 4, pitch: 45)), locationManager: locationManager ) .mapViewContentInset(.init(top: 450, left: 0, bottom: 0, right: 0)) diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift index d7f01a4..60bc362 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift @@ -10,6 +10,9 @@ protocol MLNMapViewCameraUpdating: AnyObject { @MainActor var minimumPitch: CGFloat { get set } @MainActor var maximumPitch: CGFloat { get set } @MainActor var direction: CLLocationDirection { get set } + @MainActor var camera: MLNMapCamera { get set } + @MainActor var frame: CGRect { get set } + @MainActor func setCamera(_ camera: MLNMapCamera, animated: Bool) @MainActor func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double, direction: CLLocationDirection, diff --git a/Sources/MapLibreSwiftUI/Extensions/MapViewCamera/MapViewCameraOperations.swift b/Sources/MapLibreSwiftUI/Extensions/MapViewCamera/MapViewCameraOperations.swift index a58f330..ef6db95 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapViewCamera/MapViewCameraOperations.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapViewCamera/MapViewCameraOperations.swift @@ -8,17 +8,18 @@ public extension MapViewCamera { /// - Parameter newZoom: The new zoom value. mutating func setZoom(_ newZoom: Double) { switch state { - case let .centered(onCoordinate, _, pitch, direction): + case let .centered(onCoordinate, _, pitch, pitchRange, direction): state = .centered(onCoordinate: onCoordinate, zoom: newZoom, pitch: pitch, + pitchRange: pitchRange, direction: direction) - case let .trackingUserLocation(_, pitch, direction): - state = .trackingUserLocation(zoom: newZoom, pitch: pitch, direction: direction) - case let .trackingUserLocationWithHeading(_, pitch): - state = .trackingUserLocationWithHeading(zoom: newZoom, pitch: pitch) - case let .trackingUserLocationWithCourse(_, pitch): - state = .trackingUserLocationWithCourse(zoom: newZoom, pitch: pitch) + case let .trackingUserLocation(_, pitch, pitchRange, direction): + state = .trackingUserLocation(zoom: newZoom, pitch: pitch, pitchRange: pitchRange, direction: direction) + case let .trackingUserLocationWithHeading(_, pitch, pitchRange): + state = .trackingUserLocationWithHeading(zoom: newZoom, pitch: pitch, pitchRange: pitchRange) + case let .trackingUserLocationWithCourse(_, pitch, pitchRange): + state = .trackingUserLocationWithCourse(zoom: newZoom, pitch: pitch, pitchRange: pitchRange) case .rect: return case .showcase: @@ -33,17 +34,23 @@ public extension MapViewCamera { /// - Parameter newZoom: The value to increment the zoom by. Negative decrements the value. mutating func incrementZoom(by increment: Double) { switch state { - case let .centered(onCoordinate, zoom, pitch, direction): + case let .centered(onCoordinate, zoom, pitch, pitchRange, direction): state = .centered(onCoordinate: onCoordinate, zoom: zoom + increment, pitch: pitch, + pitchRange: pitchRange, direction: direction) - case let .trackingUserLocation(zoom, pitch, direction): - state = .trackingUserLocation(zoom: zoom + increment, pitch: pitch, direction: direction) - case let .trackingUserLocationWithHeading(zoom, pitch): - state = .trackingUserLocationWithHeading(zoom: zoom + increment, pitch: pitch) - case let .trackingUserLocationWithCourse(zoom, pitch): - state = .trackingUserLocationWithCourse(zoom: zoom + increment, pitch: pitch) + case let .trackingUserLocation(zoom, pitch, pitchRange, direction): + state = .trackingUserLocation( + zoom: zoom + increment, + pitch: pitch, + pitchRange: pitchRange, + direction: direction + ) + case let .trackingUserLocationWithHeading(zoom, pitch, pitchRange): + state = .trackingUserLocationWithHeading(zoom: zoom + increment, pitch: pitch, pitchRange: pitchRange) + case let .trackingUserLocationWithCourse(zoom, pitch, pitchRange): + state = .trackingUserLocationWithCourse(zoom: zoom + increment, pitch: pitch, pitchRange: pitchRange) case .rect: return case .showcase: @@ -58,19 +65,20 @@ public extension MapViewCamera { /// Set a new pitch for the current camera state. /// /// - Parameter newPitch: The new pitch value. - mutating func setPitch(_ newPitch: CameraPitch) { + mutating func setPitch(_ newPitch: Double) { switch state { - case let .centered(onCoordinate, zoom, _, direction): + case let .centered(onCoordinate, zoom, _, pitchRange, direction): state = .centered(onCoordinate: onCoordinate, zoom: zoom, pitch: newPitch, + pitchRange: pitchRange, direction: direction) - case let .trackingUserLocation(zoom, _, direction): - state = .trackingUserLocation(zoom: zoom, pitch: newPitch, direction: direction) - case let .trackingUserLocationWithHeading(zoom, _): - state = .trackingUserLocationWithHeading(zoom: zoom, pitch: newPitch) - case let .trackingUserLocationWithCourse(zoom, _): - state = .trackingUserLocationWithCourse(zoom: zoom, pitch: newPitch) + case let .trackingUserLocation(zoom, _, pitchRange, direction): + state = .trackingUserLocation(zoom: zoom, pitch: newPitch, pitchRange: pitchRange, direction: direction) + case let .trackingUserLocationWithHeading(zoom, _, pitchRange): + state = .trackingUserLocationWithHeading(zoom: zoom, pitch: newPitch, pitchRange: pitchRange) + case let .trackingUserLocationWithCourse(zoom, _, pitchRange): + state = .trackingUserLocationWithCourse(zoom: zoom, pitch: newPitch, pitchRange: pitchRange) case .rect: return case .showcase: diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index a2f5446..d0886ed 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -63,33 +63,126 @@ public class MapViewCoordinator: NSObject { } switch camera.state { - case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): + case let .centered( + onCoordinate: coordinate, + zoom: zoom, + pitch: pitch, + pitchRange: pitchRange, + direction: direction + ): mapView.userTrackingMode = .none - mapView.setCenter(coordinate, - zoomLevel: zoom, - direction: direction, - animated: animated) - mapView.minimumPitch = pitch.rangeValue.lowerBound - mapView.maximumPitch = pitch.rangeValue.upperBound - case let .trackingUserLocation(zoom: zoom, pitch: pitch, direction: direction): + + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + mapView.setCenter(coordinate, + zoomLevel: zoom, + direction: direction, + animated: animated) + + // this is a workaround for no camera - minimum and maximum will be reset below, but this adjusts it. + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + + } else { + let camera = mapView.camera + camera.centerCoordinate = coordinate + camera.heading = direction + camera.pitch = pitch + + let altitude = MLNAltitudeForZoomLevel(zoom, pitch, coordinate.latitude, mapView.frame.size) + camera.altitude = altitude + mapView.setCamera(camera, animated: animated) + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + case let .trackingUserLocation(zoom: zoom, pitch: pitch, pitchRange: pitchRange, direction: direction): mapView.userTrackingMode = .follow - // Needs to be non-animated or else it messes up following - mapView.setZoomLevel(zoom, animated: false) - mapView.direction = direction - mapView.minimumPitch = pitch.rangeValue.lowerBound - mapView.maximumPitch = pitch.rangeValue.upperBound - case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch): + + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + // Needs to be non-animated or else it messes up following + + mapView.setZoomLevel(zoom, animated: false) + mapView.direction = direction + + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + + } else { + let camera = mapView.camera + camera.heading = direction + camera.pitch = pitch + + let altitude = MLNAltitudeForZoomLevel( + zoom, + pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + camera.altitude = altitude + mapView.setCamera(camera, animated: animated) + } + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch, pitchRange: pitchRange): mapView.userTrackingMode = .followWithHeading - // 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): + + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + // Needs to be non-animated or else it messes up following + + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + + } else { + let camera = mapView.camera + + let altitude = MLNAltitudeForZoomLevel( + zoom, + pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + camera.altitude = altitude + camera.pitch = pitch + mapView.setCamera(camera, animated: animated) + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + case let .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch, pitchRange: pitchRange): mapView.userTrackingMode = .followWithCourse - // 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 + + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + // Needs to be non-animated or else it messes up following + + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + + } else { + let camera = mapView.camera + + let altitude = MLNAltitudeForZoomLevel( + zoom, + pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + camera.altitude = altitude + camera.pitch = pitch + mapView.setCamera(camera, animated: animated) + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound case let .rect(boundingBox, padding): mapView.setVisibleCoordinateBounds(boundingBox, edgePadding: padding, @@ -244,8 +337,8 @@ extension MapViewCoordinator: MLNMapViewDelegate { // state propagation. let newCamera: MapViewCamera = .center(mapView.centerCoordinate, zoom: mapView.zoomLevel, - // TODO: Pitch doesn't really describe current state - pitch: .freeWithinRange( + pitch: mapView.camera.pitch, + pitchRange: .freeWithinRange( minimum: mapView.minimumPitch, maximum: mapView.maximumPitch ), diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift similarity index 94% rename from Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift rename to Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift index 45d3cbd..9379339 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift @@ -2,7 +2,7 @@ import Foundation import MapLibre /// The current pitch state for the MapViewCamera -public enum CameraPitch: Hashable, Sendable { +public enum CameraPitchRange: Hashable, Sendable { /// The user is free to control pitch from it's default min to max. case free diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index a62883c..6a09a9a 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -7,7 +7,8 @@ public enum CameraState: Hashable { case centered( onCoordinate: CLLocationCoordinate2D, zoom: Double, - pitch: CameraPitch, + pitch: Double, + pitchRange: CameraPitchRange, direction: CLLocationDirection ) @@ -15,19 +16,19 @@ public enum CameraState: Hashable { /// /// This feature uses the MLNMapView's userTrackingMode to .follow which automatically /// follows the user from within the MLNMapView. - case trackingUserLocation(zoom: Double, pitch: CameraPitch, direction: CLLocationDirection) + case trackingUserLocation(zoom: Double, pitch: Double, pitchRange: CameraPitchRange, direction: CLLocationDirection) /// 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, pitch: CameraPitch) + case trackingUserLocationWithHeading(zoom: Double, pitch: Double, pitchRange: CameraPitchRange) /// 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, pitch: CameraPitch) + case trackingUserLocationWithCourse(zoom: Double, pitch: Double, pitchRange: CameraPitchRange) /// Centered on a bounding box/rectangle. case rect( @@ -42,8 +43,14 @@ public enum CameraState: Hashable { extension CameraState: CustomDebugStringConvertible { public var debugDescription: String { switch self { - case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): - "CameraState.centered(onCoordinate: \(coordinate), zoom: \(zoom), pitch: \(pitch), direction: \(direction))" + case let .centered( + onCoordinate: coordinate, + zoom: zoom, + pitch: pitch, + pitchRange: pitchRange, + direction: direction + ): + "CameraState.centered(onCoordinate: \(coordinate), zoom: \(zoom), pitch: \(pitch), pitchRange: \(pitchRange), 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 f9d0f68..f6181af 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -9,7 +9,8 @@ public struct MapViewCamera: Hashable { public enum Defaults { public static let coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0) public static let zoom: Double = 10 - public static let pitch: CameraPitch = .free + public static let pitch: Double = 0 + public static let pitchRange: CameraPitchRange = .free public static let direction: CLLocationDirection = 0 } @@ -32,6 +33,7 @@ public struct MapViewCamera: Hashable { onCoordinate: Defaults.coordinate, zoom: Defaults.zoom, pitch: Defaults.pitch, + pitchRange: Defaults.pitchRange, direction: Defaults.direction ), lastReasonForChange: .programmatic @@ -48,12 +50,21 @@ public struct MapViewCamera: Hashable { /// - Returns: The constructed MapViewCamera. public static func center(_ coordinate: CLLocationCoordinate2D, zoom: Double, - pitch: CameraPitch = Defaults.pitch, + pitch: Double = Defaults.pitch, + pitchRange: CameraPitchRange = Defaults.pitchRange, direction: CLLocationDirection = Defaults.direction, reason: CameraChangeReason? = nil) -> MapViewCamera { - MapViewCamera(state: .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction), - lastReasonForChange: reason) + MapViewCamera( + state: .centered( + onCoordinate: coordinate, + zoom: zoom, + pitch: pitch, + pitchRange: pitchRange, + direction: direction + ), + lastReasonForChange: reason + ) } /// Enables user location tracking within the MapView. @@ -66,12 +77,15 @@ public struct MapViewCamera: Hashable { /// - pitch: Set the camera pitch method. /// - Returns: The MapViewCamera representing the scenario public static func trackUserLocation(zoom: Double = Defaults.zoom, - pitch: CameraPitch = Defaults.pitch, + pitch: Double = Defaults.pitch, + pitchRange: CameraPitchRange = Defaults.pitchRange, direction: CLLocationDirection = Defaults.direction) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. - MapViewCamera(state: .trackingUserLocation(zoom: zoom, pitch: pitch, direction: direction), - lastReasonForChange: .programmatic) + MapViewCamera( + state: .trackingUserLocation(zoom: zoom, pitch: pitch, pitchRange: pitchRange, direction: direction), + lastReasonForChange: .programmatic + ) } /// Enables user location tracking within the MapView. @@ -83,11 +97,13 @@ public struct MapViewCamera: Hashable { /// pitch. /// - pitch: Set the camera pitch method. /// - Returns: The MapViewCamera representing the scenario - public static func trackUserLocationWithHeading(zoom: Double = Defaults.zoom, - pitch: CameraPitch = Defaults.pitch) -> MapViewCamera - { + public static func trackUserLocationWithHeading( + zoom: Double = Defaults.zoom, + pitch: Double = Defaults.pitch, + pitchRange: CameraPitchRange = Defaults.pitchRange + ) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. - MapViewCamera(state: .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch), + MapViewCamera(state: .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch, pitchRange: pitchRange), lastReasonForChange: .programmatic) } @@ -100,11 +116,13 @@ public struct MapViewCamera: Hashable { /// pitch. /// - pitch: Set the camera pitch method. /// - Returns: The MapViewCamera representing the scenario - public static func trackUserLocationWithCourse(zoom: Double = Defaults.zoom, - pitch: CameraPitch = Defaults.pitch) -> MapViewCamera - { + public static func trackUserLocationWithCourse( + zoom: Double = Defaults.zoom, + pitch: Double = Defaults.pitch, + pitchRange: CameraPitchRange = Defaults.pitchRange + ) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. - MapViewCamera(state: .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch), + MapViewCamera(state: .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch, pitchRange: pitchRange), lastReasonForChange: .programmatic) } diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index adbab17..aad4f21 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -10,6 +10,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { override func setUp() async throws { maplibreMapView = MockMLNMapViewCameraUpdating() + given(maplibreMapView).frame.willReturn(.zero) mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) coordinator = MapView.Coordinator(parent: mapView) { _, _ in // No action @@ -39,8 +40,14 @@ final class MapViewCoordinatorCameraTests: XCTestCase { animated: .value(false)) .called(count: 1) + // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the + // pitch, and then once to set the actual range. verify(maplibreMapView) .minimumPitch(newValue: .value(0)) + .setterCalled(count: 2) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(0)) .setterCalled(count: 1) verify(maplibreMapView) @@ -69,8 +76,14 @@ final class MapViewCoordinatorCameraTests: XCTestCase { animated: .value(false)) .called(count: 1) + // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the + // pitch, and then once to set the actual range. verify(maplibreMapView) .minimumPitch(newValue: .value(0)) + .setterCalled(count: 2) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(0)) .setterCalled(count: 1) verify(maplibreMapView) @@ -98,8 +111,14 @@ final class MapViewCoordinatorCameraTests: XCTestCase { animated: .any) .called(count: 0) + // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the + // pitch, and then once to set the actual range. verify(maplibreMapView) .minimumPitch(newValue: .value(0)) + .setterCalled(count: 2) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(0)) .setterCalled(count: 1) verify(maplibreMapView) @@ -127,8 +146,14 @@ final class MapViewCoordinatorCameraTests: XCTestCase { animated: .any) .called(count: 0) + // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the + // pitch, and then once to set the actual range. verify(maplibreMapView) .minimumPitch(newValue: .value(0)) + .setterCalled(count: 2) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(0)) .setterCalled(count: 1) verify(maplibreMapView) @@ -156,8 +181,14 @@ final class MapViewCoordinatorCameraTests: XCTestCase { animated: .any) .called(count: 0) + // Due to the .frame == .zero workaround, min/max pitch setting is called twice, once to set the + // pitch, and then once to set the actual range. verify(maplibreMapView) .minimumPitch(newValue: .value(0)) + .setterCalled(count: 2) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(0)) .setterCalled(count: 1) verify(maplibreMapView) diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift index 1cb717f..f3e0c34 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift @@ -3,19 +3,19 @@ import XCTest final class CameraPitchTests: XCTestCase { func testFreePitch() { - let pitch: CameraPitch = .free + let pitch: CameraPitchRange = .free XCTAssertEqual(pitch.rangeValue.lowerBound, 0) XCTAssertEqual(pitch.rangeValue.upperBound, 60) } func testRangePitch() { - let pitch = CameraPitch.freeWithinRange(minimum: 9, maximum: 29) + let pitch = CameraPitchRange.freeWithinRange(minimum: 9, maximum: 29) XCTAssertEqual(pitch.rangeValue.lowerBound, 9) XCTAssertEqual(pitch.rangeValue.upperBound, 29) } func testFixedPitch() { - let pitch = CameraPitch.fixed(41) + let pitch = CameraPitchRange.fixed(41) XCTAssertEqual(pitch.rangeValue.lowerBound, 41) XCTAssertEqual(pitch.rangeValue.upperBound, 41) } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift index 514cc70..0fb061e 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -7,26 +7,32 @@ final class CameraStateTests: XCTestCase { let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) func testCenterCameraState() { - let state: CameraState = .centered(onCoordinate: coordinate, zoom: 4, pitch: .free, direction: 42) - XCTAssertEqual(state, .centered(onCoordinate: coordinate, zoom: 4, pitch: .free, direction: 42)) + let state: CameraState = .centered( + onCoordinate: coordinate, + zoom: 4, + pitch: 0, + pitchRange: .free, + direction: 42 + ) + XCTAssertEqual(state, .centered(onCoordinate: coordinate, zoom: 4, pitch: 0, pitchRange: .free, direction: 42)) assertSnapshot(of: state, as: .description) } func testTrackingUserLocation() { - let state: CameraState = .trackingUserLocation(zoom: 4, pitch: .free, direction: 12) - XCTAssertEqual(state, .trackingUserLocation(zoom: 4, pitch: .free, direction: 12)) + let state: CameraState = .trackingUserLocation(zoom: 4, pitch: 0, pitchRange: .free, direction: 12) + XCTAssertEqual(state, .trackingUserLocation(zoom: 4, pitch: 0, pitchRange: .free, direction: 12)) assertSnapshot(of: state, as: .description) } func testTrackingUserLocationWithHeading() { - let state: CameraState = .trackingUserLocationWithHeading(zoom: 4, pitch: .free) - XCTAssertEqual(state, .trackingUserLocationWithHeading(zoom: 4, pitch: .free)) + let state: CameraState = .trackingUserLocationWithHeading(zoom: 4, pitch: 0, pitchRange: .free) + XCTAssertEqual(state, .trackingUserLocationWithHeading(zoom: 4, pitch: 0, pitchRange: .free)) assertSnapshot(of: state, as: .description) } func testTrackingUserLocationWithCourse() { - let state: CameraState = .trackingUserLocationWithCourse(zoom: 4, pitch: .free) - XCTAssertEqual(state, .trackingUserLocationWithCourse(zoom: 4, pitch: .free)) + let state: CameraState = .trackingUserLocationWithCourse(zoom: 4, pitch: 0, pitchRange: .free) + XCTAssertEqual(state, .trackingUserLocationWithCourse(zoom: 4, pitch: 0, pitchRange: .free)) assertSnapshot(of: state, as: .description) } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index 31b0cad..4f01c41 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -9,7 +9,7 @@ final class MapViewCameraTests: XCTestCase { let camera = MapViewCamera.center( CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), zoom: 5, - pitch: .freeWithinRange(minimum: 12, maximum: 34), + pitch: 12, direction: 23 ) @@ -17,21 +17,21 @@ final class MapViewCameraTests: XCTestCase { } func testTrackingUserLocation() { - let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) - let camera = MapViewCamera.trackUserLocation(zoom: 10, pitch: pitch) + let pitch: CameraPitchRange = .freeWithinRange(minimum: 12, maximum: 34) + let camera = MapViewCamera.trackUserLocation(zoom: 10, pitchRange: pitch) assertSnapshot(of: camera, as: .dump) } func testTrackUserLocationWithCourse() { - let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) - let camera = MapViewCamera.trackUserLocationWithCourse(zoom: 18, pitch: pitch) + let pitchRange: CameraPitchRange = .freeWithinRange(minimum: 12, maximum: 34) + let camera = MapViewCamera.trackUserLocationWithCourse(zoom: 18, pitchRange: pitchRange) assertSnapshot(of: camera, as: .dump) } func testTrackUserLocationWithHeading() { - let camera = MapViewCamera.trackUserLocationWithHeading(zoom: 10, pitch: .free) + let camera = MapViewCamera.trackUserLocationWithHeading(zoom: 10, pitch: 0) assertSnapshot(of: camera, as: .dump) } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/CameraStateTests/testCenterCameraState.1.txt index 6d2f3a0..1f4c7be 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, pitch: free, direction: 42.0) \ No newline at end of file +CameraState.centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), zoom: 4.0, pitch: 0.0, pitchRange: 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 8cf7a75..0955934 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, MapLibreSwiftUI.CameraPitch.free, 12.0)) \ No newline at end of file +CameraState.trackingUserLocation(zoom: (4.0, 0.0, MapLibreSwiftUI.CameraPitchRange.free, 12.0)) \ 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 639e899..13eace7 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, MapLibreSwiftUI.CameraPitch.free)) \ No newline at end of file +CameraState.trackingUserLocationWithCourse(zoom: (4.0, 0.0, MapLibreSwiftUI.CameraPitchRange.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 13adebe..a071639 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, MapLibreSwiftUI.CameraPitch.free)) \ No newline at end of file +CameraState.trackingUserLocationWithHeading(zoom: (4.0, 0.0, MapLibreSwiftUI.CameraPitchRange.free)) \ 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 index d3c277d..318c577 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testCenterCamera.1.txt @@ -1,13 +1,11 @@ ▿ MapViewCamera - lastReasonForChange: Optional.none ▿ state: CameraState - ▿ centered: (4 elements) + ▿ centered: (5 elements) ▿ onCoordinate: CLLocationCoordinate2D - latitude: 12.3 - longitude: 23.4 - zoom: 5.0 - ▿ pitch: CameraPitch - ▿ freeWithinRange: (2 elements) - - minimum: 12.0 - - maximum: 34.0 + - pitch: 12.0 + - pitchRange: CameraPitchRange.free - 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 4c5c2d5..f405f82 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithCourse.1.txt @@ -2,9 +2,10 @@ ▿ lastReasonForChange: Optional - some: CameraChangeReason.programmatic ▿ state: CameraState - ▿ trackingUserLocationWithCourse: (2 elements) + ▿ trackingUserLocationWithCourse: (3 elements) - zoom: 18.0 - ▿ pitch: CameraPitch + - pitch: 0.0 + ▿ pitchRange: CameraPitchRange ▿ 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 47942b2..cb5da4b 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackUserLocationWithHeading.1.txt @@ -2,6 +2,7 @@ ▿ lastReasonForChange: Optional - some: CameraChangeReason.programmatic ▿ state: CameraState - ▿ trackingUserLocationWithHeading: (2 elements) + ▿ trackingUserLocationWithHeading: (3 elements) - zoom: 10.0 - - pitch: CameraPitch.free + - pitch: 0.0 + - pitchRange: CameraPitchRange.free diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt index 9b728f1..4252dc9 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/__Snapshots__/MapViewCameraTests/testTrackingUserLocation.1.txt @@ -2,9 +2,10 @@ ▿ lastReasonForChange: Optional - some: CameraChangeReason.programmatic ▿ state: CameraState - ▿ trackingUserLocation: (3 elements) + ▿ trackingUserLocation: (4 elements) - zoom: 10.0 - ▿ pitch: CameraPitch + - pitch: 0.0 + ▿ pitchRange: CameraPitchRange ▿ freeWithinRange: (2 elements) - minimum: 12.0 - maximum: 34.0 From 347c224ff81b4ed8f477f9e967367d8c6eb6d39f Mon Sep 17 00:00:00 2001 From: PW Date: Wed, 15 May 2024 16:53:18 +0200 Subject: [PATCH 02/23] Squashed commit of the following: commit 857c1ce1327f233d30f37c6d99eb158e7c5d938c Author: PW Date: Wed May 15 14:02:58 2024 +0200 adding example commit ea40ab1cf5a2f38c8be917291dc88912ae0a33cf Author: PW Date: Wed May 15 13:49:24 2024 +0200 adding expand clusters modifier --- Sources/MapLibreSwiftUI/Examples/Layers.swift | 7 ++++ .../Extensions/MapView/MapViewGestures.swift | 34 +++++++++++++++++++ Sources/MapLibreSwiftUI/MapView.swift | 2 ++ .../MapLibreSwiftUI/MapViewModifiers.swift | 12 +++++++ 4 files changed, 55 insertions(+) diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index 05b7013..27e09bd 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -123,6 +123,13 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c .iconColor(.white) .predicate(NSPredicate(format: "cluster != YES")) } + .onTapMapGesture(on: ["simple-circles-non-clusters"], onTapChanged: { _, features in + print("Tapped on \(features.first)") + }) + .expandClustersOnTapping(clusteredLayers: [ClusterLayer( + layerIdentifier: "simple-circles-clusters", + sourceIdentifier: "points" + )]) .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift index 3c46bf0..18b321c 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift @@ -58,6 +58,29 @@ extension MapView { return } + if let clusteredLayers { + if let gestureRecognizer = sender as? UITapGestureRecognizer, gestureRecognizer.numberOfTouches == 1 { + let point = gestureRecognizer.location(in: sender.view) + for clusteredLayer in clusteredLayers { + let features = mapView.visibleFeatures( + at: point, + styleLayerIdentifiers: [clusteredLayer.layerIdentifier] + ) + if let cluster = features.first as? MLNPointFeatureCluster, + let source = mapView.style? + .source(withIdentifier: clusteredLayer.sourceIdentifier) as? MLNShapeSource + { + let zoomLevel = source.zoomLevel(forExpanding: cluster) + + if zoomLevel > 0 { + mapView.setCenter(cluster.coordinate, zoomLevel: zoomLevel, animated: true) + break // since we can only zoom on one thing, we can abort the for loop here + } + } + } + } + } + // Process the gesture into a context response. let context = processContextFromGesture(mapView, gesture: gesture, sender: sender) // Run the context through the gesture held on the MapView (emitting to the MapView modifier). @@ -97,3 +120,14 @@ extension MapView { coordinate: mapView.convert(point, toCoordinateFrom: mapView)) } } + +/// Provides the layer identifier and it's source identifier. +public struct ClusterLayer { + public let layerIdentifier: String + public let sourceIdentifier: String + + public init(layerIdentifier: String, sourceIdentifier: String) { + self.layerIdentifier = layerIdentifier + self.sourceIdentifier = sourceIdentifier + } +} diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index b98e164..7dc92b4 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -28,6 +28,8 @@ public struct MapView: UIViewRepresentable { private var locationManager: MLNLocationManager? + var clusteredLayers: [ClusterLayer]? + public init( styleURL: URL, camera: Binding = .constant(.default()), diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index 63c093f..8b82d14 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -108,6 +108,18 @@ public extension MapView { return newMapView } + /// Add a default implementation for tapping clustered features. When tapped, the map zooms so that the cluster is + /// expanded. + /// - Parameter clusteredLayers: An array of layers to monitor that can contain clustered features. + /// - Returns: The modified MapView + func expandClustersOnTapping(clusteredLayers: [ClusterLayer]) -> MapView { + var newMapView = self + + newMapView.clusteredLayers = clusteredLayers + + return newMapView + } + func mapViewContentInset(_ inset: UIEdgeInsets) -> Self { var result = self From f91d4b2c143b199774735643e8d40dd4a88c0eca Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Fri, 24 May 2024 15:56:40 +0200 Subject: [PATCH 03/23] add NavigationMapView --- File.swift | 8 + Package.resolved | 36 +++ Package.swift | 2 + .../MapLibre/MLNMapViewCameraUpdating.swift | 2 +- .../UIKit/UIGestureRecognizing.swift | 2 +- .../Models/Gesture/MapGesture.swift | 8 +- .../Models/Gesture/MapGestureContext.swift | 7 + .../Models/MapCamera/CameraChangeReason.swift | 2 +- .../Models/MapCamera/CameraPitch.swift | 2 +- .../MapLibreSwiftUI/NavigationMapView.swift | 242 ++++++++++++++ .../NavigationMapViewCoordinator.swift | 297 ++++++++++++++++++ 11 files changed, 600 insertions(+), 8 deletions(-) create mode 100644 File.swift create mode 100644 Sources/MapLibreSwiftUI/NavigationMapView.swift create mode 100644 Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift diff --git a/File.swift b/File.swift new file mode 100644 index 0000000..33a70c8 --- /dev/null +++ b/File.swift @@ -0,0 +1,8 @@ +// +// File.swift +// MapLibreSwiftUI +// +// Created by Patrick Kladek on 23.05.24. +// + +import Foundation diff --git a/Package.resolved b/Package.resolved index 8b80b5a..eb36a6d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "mapbox-directions-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/flitsmeister/mapbox-directions-swift", + "state" : { + "revision" : "6c19ecc4e1324887ae3250802b8d13d8d8b3ff2d", + "version" : "0.23.3" + } + }, { "identity" : "maplibre-gl-native-distribution", "kind" : "remoteSourceControl", @@ -27,6 +36,24 @@ "version" : "0.0.3" } }, + { + "identity" : "polyline", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raphaelmor/Polyline", + "state" : { + "revision" : "353f80378dcd8f17eefe8550090c6b1ae3c9da23", + "version" : "5.1.0" + } + }, + { + "identity" : "solar", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ceeK/Solar.git", + "state" : { + "revision" : "c2b96f2d5fb7f835b91cefac5e83101f54643901", + "version" : "3.0.1" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", @@ -44,6 +71,15 @@ "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", "version" : "509.1.1" } + }, + { + "identity" : "turf-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/flitsmeister/turf-swift", + "state" : { + "revision" : "b05b4658d1b48eac4127a0d9ebbb5a6f965a8251", + "version" : "0.2.2" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 06a554e..7a96e59 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.4.0"), .package(url: "https://github.com/stadiamaps/maplibre-swift-macros.git", from: "0.0.3"), + .package(path: "~/Developer/maplibre-navigation-ios"), // Testing .package(url: "https://github.com/Kolos65/Mockable.git", exact: "0.0.3"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.15.3"), @@ -34,6 +35,7 @@ let package = Package( .target(name: "InternalUtils"), .target(name: "MapLibreSwiftDSL"), .product(name: "MapLibre", package: "maplibre-gl-native-distribution"), + .product(name: "MapboxNavigation", package: "maplibre-navigation-ios"), .product(name: "Mockable", package: "Mockable"), ], swiftSettings: [ diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift index d7f01a4..3786cbd 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift @@ -5,7 +5,7 @@ import Mockable // NOTE: We should eventually mark the entire protocol @MainActor, but Mockable generates some unsafe code at the moment @Mockable -protocol MLNMapViewCameraUpdating: AnyObject { +public protocol MLNMapViewCameraUpdating: AnyObject { @MainActor var userTrackingMode: MLNUserTrackingMode { get set } @MainActor var minimumPitch: CGFloat { get set } @MainActor var maximumPitch: CGFloat { get set } diff --git a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift index c662593..1800fea 100644 --- a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift +++ b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift @@ -2,7 +2,7 @@ import Mockable import UIKit @Mockable -protocol UIGestureRecognizing: AnyObject { +public protocol UIGestureRecognizing: AnyObject { @MainActor var state: UIGestureRecognizer.State { get } @MainActor func location(in view: UIView?) -> CGPoint @MainActor func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift index a54821d..5a2e783 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift @@ -17,20 +17,20 @@ public class MapGesture: NSObject { } /// The Gesture's method, this is used to register it for the correct user interaction on the MapView. - let method: Method + public let method: Method /// The onChange action that runs when the gesture changes on the map view. - let onChange: GestureAction + public let onChange: GestureAction /// The underlying gesture recognizer - weak var gestureRecognizer: UIGestureRecognizer? + public weak var gestureRecognizer: UIGestureRecognizer? /// Create a new gesture recognizer definition for the MapView /// /// - Parameters: /// - method: The gesture recognizer method /// - onChange: The action to perform when the gesture is changed - init(method: Method, onChange: GestureAction) { + public init(method: Method, onChange: GestureAction) { self.method = method self.onChange = onChange } diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift index bb70afe..3e3a531 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift @@ -14,4 +14,11 @@ public struct MapGestureContext { /// The underlying geographic coordinate at the point of the gesture. public let coordinate: CLLocationCoordinate2D + + public init(gestureMethod: MapGesture.Method, state: UIGestureRecognizer.State, point: CGPoint, coordinate: CLLocationCoordinate2D) { + self.gestureMethod = gestureMethod + self.state = state + self.point = point + self.coordinate = coordinate + } } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift index 368ae84..2268ea8 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -19,7 +19,7 @@ public enum CameraChangeReason: Hashable { /// If you need a full history of the full bit range, use MLNCameraChangeReason directly /// /// - Parameter mlnCameraChangeReason: The camera change reason options list from the MapLibre MapViewDelegate - init?(_ mlnCameraChangeReason: MLNCameraChangeReason) { + public init?(_ mlnCameraChangeReason: MLNCameraChangeReason) { switch mlnCameraChangeReason.largestBitwiseReason { case .programmatic: self = .programmatic diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift index 45d3cbd..871800b 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift @@ -15,7 +15,7 @@ public enum CameraPitch: Hashable, Sendable { /// The range of acceptable pitch values. /// /// This is applied to the map view on camera updates. - var rangeValue: ClosedRange { + public var rangeValue: ClosedRange { switch self { case .free: 0 ... 60 // TODO: set this to a maplibre constant (this is available on Android, but maybe not iOS)? diff --git a/Sources/MapLibreSwiftUI/NavigationMapView.swift b/Sources/MapLibreSwiftUI/NavigationMapView.swift new file mode 100644 index 0000000..75e75dc --- /dev/null +++ b/Sources/MapLibreSwiftUI/NavigationMapView.swift @@ -0,0 +1,242 @@ +// +// NavigationMapView.swift +// +// +// Created by Patrick Kladek on 23.05.24. +// + +import InternalUtils +import MapboxCoreNavigation +import MapboxDirections +import MapboxNavigation +import MapLibre +import MapLibreSwiftDSL +import SwiftUI + +public struct NavigationMapView: UIViewControllerRepresentable { + public typealias UIViewControllerType = NavigationViewController + + @Binding var camera: MapViewCamera + + let styleSource: MapStyleSource + let userLayers: [StyleLayerDefinition] + + var gestures = [MapGesture]() + + var onStyleLoaded: ((MLNStyle) -> Void)? + var onViewPortChanged: ((MapViewPort) -> Void)? + var route: Route? + + public var mapViewContentInset: UIEdgeInsets = .zero + + /// 'Escape hatch' to MLNMapView until we have more modifiers. + /// See ``unsafeMapViewModifier(_:)`` + var unsafeMapViewModifier: ((MLNMapView) -> Void)? + + var controls: [MapControl] = [ + CompassView(), + LogoView(), + AttributionButton(), + ] + + private var locationManager: MLNLocationManager? + + public init( + styleSource: MapStyleSource, + camera: Binding = .constant(.default()), + locationManager: MLNLocationManager? = nil, + @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } + ) { + self.styleSource = styleSource + _camera = camera + userLayers = makeMapContent() + self.locationManager = locationManager + } + + public func makeCoordinator() -> NavigationMapViewCoordinator { + NavigationMapViewCoordinator( + parent: self, + onGesture: { processGesture($0, $1) }, + onViewPortChanged: { onViewPortChanged?($0) } + ) + } + + public func makeUIViewController(context: Context) -> NavigationViewController { + let viewController = NavigationViewController(directions: Directions(accessToken: "empty")) + + // TODO: its not allowed to change the mapView delegate. find another way to link mapview with coordinator + // viewController.mapView?.delegate = context.coordinator + context.coordinator.mapView = viewController.mapView + + // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as + // content insets can trigger a change) + if let mapView = viewController.mapView { + context.coordinator.updateStyleSource(styleSource, mapView: mapView) + + context.coordinator.suppressCameraUpdatePropagation = true + applyModifiers(mapView, runUnsafe: false) + context.coordinator.suppressCameraUpdatePropagation = false + + mapView.locationManager = locationManager + + 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 + + // Add all gesture recognizers + for gesture in gestures { + self.registerGesture(mapView, context, gesture: gesture) + } + } + + + return viewController + } + + public func updateUIViewController(_ uiViewController: NavigationViewController, context: Context) { + context.coordinator.parent = self + let mapView = uiViewController.mapView! + + applyModifiers(mapView, runUnsafe: true) + + // FIXME: This should be a more selective update + context.coordinator.updateStyleSource(styleSource, mapView: mapView) + context.coordinator.updateLayers(mapView: mapView) + + // FIXME: This isn't exactly telling us if the *map* is loaded, and the docs for setCenter say it needs to be. + let isStyleLoaded = mapView.style != nil + context.coordinator.updateCamera(mapView: mapView, + camera: $camera.wrappedValue, + animated: isStyleLoaded) + + if let route { + let locationManager = SimulatedLocationManager(route: route) + locationManager.speedMultiplier = 2 + uiViewController.start(with: route, locationManager: locationManager) + } else { + uiViewController.endRoute() + } + } + + public func unsafeMapViewModifier(_ modifier: @escaping (MLNMapView) -> Void) -> NavigationMapView { + var newMapView = self + newMapView.unsafeMapViewModifier = modifier + return newMapView + } + + public func assign(route: Route?) -> NavigationMapView { + var newMapView = self + newMapView.route = route + return newMapView + } +} + +#Preview { + MapView(styleURL: demoTilesURL) + .ignoresSafeArea(.all) + .previewDisplayName("Vanilla Map") + + // For a larger selection of previews, + // check out the Examples directory, which + // has a wide variety of previews, + // organized into (hopefully) useful groups +} + + +extension NavigationMapView { + + @MainActor func registerGesture(_ mapView: MLNMapView, _ context: Context, gesture: MapGesture) { + switch gesture.method { + case let .tap(numberOfTaps: numberOfTaps): + let gestureRecognizer = UITapGestureRecognizer(target: context.coordinator, + action: #selector(context.coordinator.captureGesture(_:))) + gestureRecognizer.numberOfTapsRequired = numberOfTaps + if numberOfTaps == 1 { + // If a user double taps to zoom via the built in gesture, a normal + // tap should not be triggered. + if let doubleTapRecognizer = mapView.gestureRecognizers? + .first(where: { + $0 is UITapGestureRecognizer && ($0 as! UITapGestureRecognizer).numberOfTapsRequired == 2 + }) + { + gestureRecognizer.require(toFail: doubleTapRecognizer) + } + } + mapView.addGestureRecognizer(gestureRecognizer) + gesture.gestureRecognizer = gestureRecognizer + + case let .longPress(minimumDuration: minimumDuration): + let gestureRecognizer = UILongPressGestureRecognizer(target: context.coordinator, + action: #selector(context.coordinator + .captureGesture(_:))) + gestureRecognizer.minimumPressDuration = minimumDuration + + mapView.addGestureRecognizer(gestureRecognizer) + gesture.gestureRecognizer = gestureRecognizer + } + } + + @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 + } + + // Process the gesture into a context response. + let context = processContextFromGesture(mapView, gesture: gesture, sender: sender) + // Run the context through the gesture held on the MapView (emitting to the MapView modifier). + switch gesture.onChange { + case let .context(action): + action(context) + case let .feature(action, layers): + let point = sender.location(in: sender.view) + let features = mapView.visibleFeatures(at: point, styleLayerIdentifiers: layers) + action(context, features) + } + } + + @MainActor func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, + sender: UIGestureRecognizing) -> MapGestureContext { + // Build the context of the gesture's event. + 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) + case .longPress: + // Calculate the CGPoint of the long process gesture. + sender.location(in: mapView) + } + + return MapGestureContext(gestureMethod: gesture.method, + state: sender.state, + point: point, + coordinate: mapView.convert(point, toCoordinateFrom: mapView)) + } +} + +// MARK: - Private + +private extension NavigationMapView { + + @MainActor func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) { + mapView.contentInset = mapViewContentInset + + // Assume all controls are hidden by default (so that an empty list returns a map with no controls) + mapView.logoView.isHidden = true + mapView.compassView.isHidden = true + mapView.attributionButton.isHidden = true + + // Apply each control configuration + for control in controls { + control.configureMapView(mapView) + } + + if runUnsafe { + unsafeMapViewModifier?(mapView) + } + } +} diff --git a/Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift b/Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift new file mode 100644 index 0000000..8278227 --- /dev/null +++ b/Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift @@ -0,0 +1,297 @@ +// +// NavigationMapViewCoordinator.swift +// +// +// Created by Patrick Kladek on 23.05.24. +// + +import Foundation +import MapLibre +import MapLibreSwiftDSL + +public class NavigationMapViewCoordinator: NSObject { + // This must be weak, the UIViewRepresentable owns the MLNMapView. + weak var mapView: MLNMapView? + var parent: NavigationMapView + + // Storage of variables as they were previously; these are snapshot + // every update cycle so we can avoid unnecessary updates + private var snapshotUserLayers: [StyleLayerDefinition] = [] + private var snapshotCamera: MapViewCamera? + private var snapshotStyleSource: MapStyleSource? + + // Indicates whether we are currently in a push-down camera update cycle. + // This is necessary in order to ensure we don't keep trying to reset a state value which we were already processing + // an update for. + var suppressCameraUpdatePropagation = false + + var onStyleLoaded: ((MLNStyle) -> Void)? + var onGesture: (MLNMapView, UIGestureRecognizer) -> Void + var onViewPortChanged: (MapViewPort) -> Void + + init(parent: NavigationMapView, + onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void, + onViewPortChanged: @escaping (MapViewPort) -> Void) + { + self.parent = parent + self.onGesture = onGesture + self.onViewPortChanged = onViewPortChanged + } + + // MARK: Core UIView Functionality + + @objc func captureGesture(_ sender: UIGestureRecognizer) { + guard let mapView else { + return + } + + onGesture(mapView, sender) + } + + // MARK: - Coordinator API - Camera + Manipulation + + /// Update the camera based on the MapViewCamera binding change. + /// + /// - Parameters: + /// - mapView: This is the camera updating protocol representation of the MLNMapView. This allows mockable testing + /// for + /// camera related MLNMapView functionality. + /// - camera: The new camera from the binding. + /// - animated: Whether to animate. + @MainActor func updateCamera(mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool) { +// guard camera != snapshotCamera else { +// // No action - camera has not changed. +// return +// } + + suppressCameraUpdatePropagation = true + defer { + suppressCameraUpdatePropagation = false + } + + switch camera.state { + case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): + mapView.userTrackingMode = .none + mapView.setCenter(coordinate, + zoomLevel: zoom, + direction: direction, + animated: animated) + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound + case let .trackingUserLocation(zoom: zoom, pitch: pitch, direction: direction): + mapView.userTrackingMode = .follow + // Needs to be non-animated or else it messes up following + mapView.setZoomLevel(zoom, animated: false) + mapView.direction = direction + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound + case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch): + mapView.userTrackingMode = .followWithHeading + // 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 + // Needs to be non-animated or else it messes up following + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch.rangeValue.lowerBound + mapView.maximumPitch = pitch.rangeValue.upperBound + case let .rect(boundingBox, padding): + mapView.setVisibleCoordinateBounds(boundingBox, + edgePadding: padding, + animated: animated, + completionHandler: nil) + case .showcase: + // TODO: Need a method these/or to finalize a goal here. + break + } + + snapshotCamera = camera + } + + // MARK: - Coordinator API - Styles + Layers + + @MainActor func updateStyleSource(_ source: MapStyleSource, mapView: MLNMapView) { + switch (source, snapshotStyleSource) { + case let (.url(newURL), .url(oldURL)): + if newURL != oldURL { + mapView.styleURL = newURL + } + case let (.url(newURL), .none): + mapView.styleURL = newURL + } + + snapshotStyleSource = source + } + + @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... + // Try to reuse DSL-defined sources if possible (they are the same type)! + if let style = mapView.style { + var sourcesToRemove = Set() + for layer in snapshotUserLayers { + if let oldLayer = style.layer(withIdentifier: layer.identifier) { + style.removeLayer(oldLayer) + } + + if let specWithSource = layer as? SourceBoundStyleLayerDefinition { + switch specWithSource.source { + case .mglSource: + // Do Nothing + // DISCUSS: The idea is to exclude "unmanaged" sources and only manage the ones specified via the DSL and attached to a layer. + // This is a really hackish design and I don't particularly like it. + continue + case .source: + // Mark sources for removal after all user layers have been removed. + // Sources specified in this way should be used by a layer already in the style. + sourcesToRemove.insert(specWithSource.source.identifier) + } + } + } + + // Remove sources that were added by layers specified in the DSL + for sourceID in sourcesToRemove { + if let source = style.source(withIdentifier: sourceID) { + style.removeSource(source) + } else { + print("That's funny... couldn't find identifier \(sourceID)") + } + } + } + + // Snapshot the new user-defined layers + snapshotUserLayers = parent.userLayers + + // If the style is loaded, add the new layers to it. + // Otherwise, this will get invoked automatically by the style didFinishLoading callback + if let style = mapView.style { + addLayers(to: style) + } + } + + func addLayers(to mglStyle: MLNStyle) { + for layerSpec in parent.userLayers { + // DISCUSS: What preventions should we try to put in place against the user accidentally adding the same layer twice? + let newLayer = layerSpec.makeStyleLayer(style: mglStyle).makeMLNStyleLayer() + + // Unconditionally transfer the common properties + newLayer.isVisible = layerSpec.isVisible + + if let minZoom = layerSpec.minimumZoomLevel { + newLayer.minimumZoomLevel = minZoom + } + + if let maxZoom = layerSpec.maximumZoomLevel { + newLayer.maximumZoomLevel = maxZoom + } + + switch layerSpec.insertionPosition { + case let .above(layerID: id): + if let layer = mglStyle.layer(withIdentifier: id) { + mglStyle.insertLayer(newLayer, above: layer) + } else { + NSLog("Failed to find layer with ID \(id). Adding layer on top.") + mglStyle.addLayer(newLayer) + } + case let .below(layerID: id): + if let layer = mglStyle.layer(withIdentifier: id) { + mglStyle.insertLayer(newLayer, below: layer) + } else { + NSLog("Failed to find layer with ID \(id). Adding layer on top.") + mglStyle.addLayer(newLayer) + } + case .aboveOthers: + mglStyle.addLayer(newLayer) + case .belowOthers: + mglStyle.insertLayer(newLayer, at: 0) + } + } + } +} + +// MARK: - MLNMapViewDelegate + +extension NavigationMapViewCoordinator: MLNMapViewDelegate { + public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { + addLayers(to: mglStyle) + onStyleLoaded?(mglStyle) + } + + // MARK: MapViewCamera + + @MainActor private func updateParentCamera(mapView: MLNMapView, reason: MLNCameraChangeReason) { + // If any of these are a mismatch, we know the camera is no longer following a desired method, so we should + // detach and revert to a .centered camera. If any one of these is true, the desired camera state still + // matches the mapView's userTrackingMode + // NOTE: The use of assumeIsolated is just to make Swift strict concurrency checks happy. + // This invariant is upheld by the MLNMapView. + let userTrackingMode = mapView.userTrackingMode + let isProgrammaticallyTracking: Bool = switch parent.camera.state { + case .trackingUserLocation: + userTrackingMode == .follow + case .trackingUserLocationWithHeading: + userTrackingMode == .followWithHeading + case .trackingUserLocationWithCourse: + userTrackingMode == .followWithCourse + case .centered, .rect, .showcase: + false + } + + guard !isProgrammaticallyTracking else { + // Programmatic tracking is still active, we can ignore camera updates until we unset/fail this boolean + // check + return + } + + // Publish the MLNMapView's "raw" camera state to the MapView camera binding. + // This path only executes when the map view diverges from the parent state, so this is a "matter of fact" + // state propagation. + let newCamera: MapViewCamera = .center(mapView.centerCoordinate, + zoom: mapView.zoomLevel, + // TODO: Pitch doesn't really describe current state + pitch: .freeWithinRange( + minimum: mapView.minimumPitch, + maximum: mapView.maximumPitch + ), + direction: mapView.direction, + reason: CameraChangeReason(reason)) + snapshotCamera = newCamera + parent.camera = newCamera + } + + /// The MapView's region has changed with a specific reason. + public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { + // 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 + // TODO: We could put this in regionIsChangingWith if we calculate significant change/debounce. + Task { @MainActor in + updateViewPort(mapView: mapView, reason: reason) + } + + guard !suppressCameraUpdatePropagation else { + return + } + + // 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) + } + } + + // MARK: MapViewPort + + @MainActor private func updateViewPort(mapView: MLNMapView, reason: MLNCameraChangeReason) { + // Calculate the Raw "ViewPort" + let calculatedViewPort = MapViewPort( + center: mapView.centerCoordinate, + zoom: mapView.zoomLevel, + direction: mapView.direction, + lastReasonForChange: CameraChangeReason(reason) + ) + + onViewPortChanged(calculatedViewPort) + } +} + From be6fa071571858a59cd477c6e2e81b99e5d5d951 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Fri, 24 May 2024 19:50:28 +0200 Subject: [PATCH 04/23] add missing modifiers --- .../MapLibreSwiftUI/NavigationMapView.swift | 149 ++++++++++++++++- .../NavigationMapViewCoordinator.swift | 151 ++++++++++++++---- 2 files changed, 265 insertions(+), 35 deletions(-) diff --git a/Sources/MapLibreSwiftUI/NavigationMapView.swift b/Sources/MapLibreSwiftUI/NavigationMapView.swift index 75e75dc..00171c8 100644 --- a/Sources/MapLibreSwiftUI/NavigationMapView.swift +++ b/Sources/MapLibreSwiftUI/NavigationMapView.swift @@ -40,6 +40,8 @@ public struct NavigationMapView: UIViewControllerRepresentable { ] private var locationManager: MLNLocationManager? + + var clusteredLayers: [ClusterLayer]? public init( styleSource: MapStyleSource, @@ -122,12 +124,6 @@ public struct NavigationMapView: UIViewControllerRepresentable { } } - public func unsafeMapViewModifier(_ modifier: @escaping (MLNMapView) -> Void) -> NavigationMapView { - var newMapView = self - newMapView.unsafeMapViewModifier = modifier - return newMapView - } - public func assign(route: Route?) -> NavigationMapView { var newMapView = self newMapView.route = route @@ -218,6 +214,147 @@ extension NavigationMapView { } } +public extension NavigationMapView { + /// Perform an action when the map view has loaded its style and all locally added style definitions. + /// + /// - Parameter perform: The action to perform with the loaded style. + /// - Returns: The modified map view. + func onStyleLoaded(_ perform: @escaping (MLNStyle) -> Void) -> NavigationMapView { + var newMapView = self + newMapView.onStyleLoaded = perform + return newMapView + } + + /// Allows you to set properties of the underlying MLNMapView directly + /// in cases where these have not been ported to DSL yet. + /// Use this function to modify various properties of the MLNMapView instance. + /// For example, you can enable the display of the user's location on the map by setting `showUserLocation` to true. + /// + /// This is an 'escape hatch' back to the non-DSL world + /// of MapLibre for features that have not been ported to DSL yet. + /// Be careful not to use this to modify properties that are + /// already ported to the DSL, like the camera for example, as your + /// modifications here may break updates that occur with modifiers. + /// In particular, this modifier is potentially dangerous as it runs on + /// EVERY call to `updateUIView`. + /// + /// - Parameter modifier: A closure that provides you with an MLNMapView so you can set properties. + /// - Returns: A MapView with the modifications applied. + /// + /// Example: + /// ```swift + /// MapView() + /// .mapViewModifier { mapView in + /// mapView.showUserLocation = true + /// } + /// ``` + /// + func unsafeMapViewModifier(_ modifier: @escaping (MLNMapView) -> Void) -> NavigationMapView { + var newMapView = self + newMapView.unsafeMapViewModifier = modifier + return newMapView + } + + // MARK: Default Gestures + + /// Add an tap gesture handler to the MapView + /// + /// - Parameters: + /// - count: The number of taps required to run the gesture. + /// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc), that also contains + /// information like the latitude and longitude of the tap. + /// - Returns: The modified map view. + func onTapMapGesture(count: Int = 1, + onTapChanged: @escaping (MapGestureContext) -> Void) -> NavigationMapView + { + var newMapView = self + + // Build the gesture and link it to the map view. + let gesture = MapGesture(method: .tap(numberOfTaps: count), + onChange: .context(onTapChanged)) + newMapView.gestures.append(gesture) + + return newMapView + } + + /// Add an tap gesture handler to the MapView that returns any visible map features that were tapped. + /// + /// - Parameters: + /// - count: The number of taps required to run the gesture. + /// - on layers: The set of layer ids that you would like to check for visible features that were tapped. If no + /// set is provided, all map layers are checked. + /// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc), that also contains + /// information like the latitude and longitude of the tap. Also emits an array of map features that were tapped. + /// Returns an empty array when nothing was tapped on the "on" layer ids that were provided. + /// - Returns: The modified map view. + func onTapMapGesture(count: Int = 1, on layers: Set?, + onTapChanged: @escaping (MapGestureContext, [any MLNFeature]) -> Void) -> NavigationMapView + { + var newMapView = self + + // Build the gesture and link it to the map view. + let gesture = MapGesture(method: .tap(numberOfTaps: count), + onChange: .feature(onTapChanged, layers: layers)) + newMapView.gestures.append(gesture) + + return newMapView + } + + /// Add a long press gesture handler to the MapView + /// + /// - Parameters: + /// - minimumDuration: The minimum duration in seconds the user must press the screen to run the gesture. + /// - onPressChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc). + /// - Returns: The modified map view. + func onLongPressMapGesture(minimumDuration: Double = 0.5, + onPressChanged: @escaping (MapGestureContext) -> Void) -> NavigationMapView + { + var newMapView = self + + // Build the gesture and link it to the map view. + let gesture = MapGesture(method: .longPress(minimumDuration: minimumDuration), + onChange: .context(onPressChanged)) + newMapView.gestures.append(gesture) + + return newMapView + } + + /// Add a default implementation for tapping clustered features. When tapped, the map zooms so that the cluster is + /// expanded. + /// - Parameter clusteredLayers: An array of layers to monitor that can contain clustered features. + /// - Returns: The modified MapView + func expandClustersOnTapping(clusteredLayers: [ClusterLayer]) -> NavigationMapView { + var newMapView = self + + newMapView.clusteredLayers = clusteredLayers + + return newMapView + } + + func mapViewContentInset(_ inset: UIEdgeInsets) -> Self { + var result = self + + result.mapViewContentInset = inset + + return result + } + + func mapControls(@MapControlsBuilder _ buildControls: () -> [MapControl]) -> Self { + var result = self + + result.controls = buildControls() + + return result + } + + func onMapViewPortUpdate(_ onViewPortChanged: @escaping (MapViewPort) -> Void) -> Self { + var result = self + result.onViewPortChanged = onViewPortChanged + return result + } +} + + // MARK: - Private private extension NavigationMapView { diff --git a/Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift b/Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift index 8278227..7817987 100644 --- a/Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift @@ -59,10 +59,10 @@ public class NavigationMapViewCoordinator: NSObject { /// - camera: The new camera from the binding. /// - animated: Whether to animate. @MainActor func updateCamera(mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool) { -// guard camera != snapshotCamera else { -// // No action - camera has not changed. -// return -// } + guard camera != snapshotCamera else { + // No action - camera has not changed. + return + } suppressCameraUpdatePropagation = true defer { @@ -70,33 +70,126 @@ public class NavigationMapViewCoordinator: NSObject { } switch camera.state { - case let .centered(onCoordinate: coordinate, zoom: zoom, pitch: pitch, direction: direction): + case let .centered( + onCoordinate: coordinate, + zoom: zoom, + pitch: pitch, + pitchRange: pitchRange, + direction: direction + ): mapView.userTrackingMode = .none - mapView.setCenter(coordinate, - zoomLevel: zoom, - direction: direction, - animated: animated) - mapView.minimumPitch = pitch.rangeValue.lowerBound - mapView.maximumPitch = pitch.rangeValue.upperBound - case let .trackingUserLocation(zoom: zoom, pitch: pitch, direction: direction): + + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + mapView.setCenter(coordinate, + zoomLevel: zoom, + direction: direction, + animated: animated) + + // this is a workaround for no camera - minimum and maximum will be reset below, but this adjusts it. + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + + } else { + let camera = mapView.camera + camera.centerCoordinate = coordinate + camera.heading = direction + camera.pitch = pitch + + let altitude = MLNAltitudeForZoomLevel(zoom, pitch, coordinate.latitude, mapView.frame.size) + camera.altitude = altitude + mapView.setCamera(camera, animated: animated) + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + case let .trackingUserLocation(zoom: zoom, pitch: pitch, pitchRange: pitchRange, direction: direction): mapView.userTrackingMode = .follow - // Needs to be non-animated or else it messes up following - mapView.setZoomLevel(zoom, animated: false) - mapView.direction = direction - mapView.minimumPitch = pitch.rangeValue.lowerBound - mapView.maximumPitch = pitch.rangeValue.upperBound - case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch): + + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + // Needs to be non-animated or else it messes up following + + mapView.setZoomLevel(zoom, animated: false) + mapView.direction = direction + + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + + } else { + let camera = mapView.camera + camera.heading = direction + camera.pitch = pitch + + let altitude = MLNAltitudeForZoomLevel( + zoom, + pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + camera.altitude = altitude + mapView.setCamera(camera, animated: animated) + } + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch, pitchRange: pitchRange): mapView.userTrackingMode = .followWithHeading - // 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): + + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + // Needs to be non-animated or else it messes up following + + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + + } else { + let camera = mapView.camera + + let altitude = MLNAltitudeForZoomLevel( + zoom, + pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + camera.altitude = altitude + camera.pitch = pitch + mapView.setCamera(camera, animated: animated) + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound + case let .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch, pitchRange: pitchRange): mapView.userTrackingMode = .followWithCourse - // 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 + + if mapView.frame.size == .zero { + // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, + // so let's do something else instead. + // Needs to be non-animated or else it messes up following + + mapView.setZoomLevel(zoom, animated: false) + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + + } else { + let camera = mapView.camera + + let altitude = MLNAltitudeForZoomLevel( + zoom, + pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + camera.altitude = altitude + camera.pitch = pitch + mapView.setCamera(camera, animated: animated) + } + + mapView.minimumPitch = pitchRange.rangeValue.lowerBound + mapView.maximumPitch = pitchRange.rangeValue.upperBound case let .rect(boundingBox, padding): mapView.setVisibleCoordinateBounds(boundingBox, edgePadding: padding, @@ -251,8 +344,8 @@ extension NavigationMapViewCoordinator: MLNMapViewDelegate { // state propagation. let newCamera: MapViewCamera = .center(mapView.centerCoordinate, zoom: mapView.zoomLevel, - // TODO: Pitch doesn't really describe current state - pitch: .freeWithinRange( + pitch: mapView.camera.pitch, + pitchRange: .freeWithinRange( minimum: mapView.minimumPitch, maximum: mapView.maximumPitch ), From 788303ee5af18091e1b2d03f9cbbcbfdb6f3ba22 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Mon, 27 May 2024 12:55:05 +0200 Subject: [PATCH 05/23] Add Navigation Target --- File.swift | 8 -------- Package.swift | 18 ++++++++++++++++++ .../NavigationMapView.swift | 1 + .../NavigationMapViewCoordinator.swift | 1 + .../MapLibreSwiftUI/Examples/Gestures.swift | 4 ++-- Sources/MapLibreSwiftUI/Examples/Layers.swift | 2 +- .../Examples/Preview Helpers.swift | 3 +-- 7 files changed, 24 insertions(+), 13 deletions(-) delete mode 100644 File.swift rename Sources/{MapLibreSwiftUI => MapLibreNavigationSwiftUI}/NavigationMapView.swift (99%) rename Sources/{MapLibreSwiftUI => MapLibreNavigationSwiftUI}/NavigationMapViewCoordinator.swift (99%) diff --git a/File.swift b/File.swift deleted file mode 100644 index 33a70c8..0000000 --- a/File.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File.swift -// MapLibreSwiftUI -// -// Created by Patrick Kladek on 23.05.24. -// - -import Foundation diff --git a/Package.swift b/Package.swift index 7a96e59..fb6c4a6 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,9 @@ let package = Package( name: "MapLibreSwiftUI", targets: ["MapLibreSwiftUI"] ), + .library(name: "MapLibreNavigationSwiftUI", + targets: ["MapLibreNavigationSwiftUI"] + ), .library( name: "MapLibreSwiftDSL", targets: ["MapLibreSwiftDSL"] @@ -43,6 +46,21 @@ let package = Package( .enableExperimentalFeature("StrictConcurrency"), ] ), + .target( + name: "MapLibreNavigationSwiftUI", + dependencies: [ + .target(name: "InternalUtils"), + .target(name: "MapLibreSwiftDSL"), + .target(name: "MapLibreSwiftUI"), + .product(name: "MapLibre", package: "maplibre-gl-native-distribution"), + .product(name: "MapboxNavigation", package: "maplibre-navigation-ios"), + .product(name: "Mockable", package: "Mockable"), + ], + swiftSettings: [ + .define("MOCKING", .when(configuration: .debug)), + .enableExperimentalFeature("StrictConcurrency"), + ] + ), .target( name: "MapLibreSwiftDSL", dependencies: [ diff --git a/Sources/MapLibreSwiftUI/NavigationMapView.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift similarity index 99% rename from Sources/MapLibreSwiftUI/NavigationMapView.swift rename to Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift index 00171c8..67412bf 100644 --- a/Sources/MapLibreSwiftUI/NavigationMapView.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift @@ -11,6 +11,7 @@ import MapboxDirections import MapboxNavigation import MapLibre import MapLibreSwiftDSL +import MapLibreSwiftUI import SwiftUI public struct NavigationMapView: UIViewControllerRepresentable { diff --git a/Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift similarity index 99% rename from Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift rename to Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift index 7817987..40f1b89 100644 --- a/Sources/MapLibreSwiftUI/NavigationMapViewCoordinator.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift @@ -8,6 +8,7 @@ import Foundation import MapLibre import MapLibreSwiftDSL +import MapLibreSwiftUI public class NavigationMapViewCoordinator: NSObject { // This must be weak, the UIViewRepresentable owns the MLNMapView. diff --git a/Sources/MapLibreSwiftUI/Examples/Gestures.swift b/Sources/MapLibreSwiftUI/Examples/Gestures.swift index e7f9fc3..4d5a3f5 100644 --- a/Sources/MapLibreSwiftUI/Examples/Gestures.swift +++ b/Sources/MapLibreSwiftUI/Examples/Gestures.swift @@ -18,7 +18,7 @@ import SwiftUI .iconColor(.white) } .onTapMapGesture(on: [tappableID], onTapChanged: { _, features in - print("Tapped on \(features.first)") + print("Tapped on \(features.first?.description ?? "")") }) .ignoresSafeArea(.all) } @@ -26,7 +26,7 @@ import SwiftUI #Preview("Tappable Countries") { MapView(styleURL: demoTilesURL) .onTapMapGesture(on: ["countries-fill"], onTapChanged: { _, features in - print("Tapped on \(features.first)") + print("Tapped on \(features.first?.description ?? "")") }) .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index 27e09bd..bc898d7 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -124,7 +124,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c .predicate(NSPredicate(format: "cluster != YES")) } .onTapMapGesture(on: ["simple-circles-non-clusters"], onTapChanged: { _, features in - print("Tapped on \(features.first)") + print("Tapped on \(features.first?.debugDescription ?? "")") }) .expandClustersOnTapping(clusteredLayers: [ClusterLayer( layerIdentifier: "simple-circles-clusters", diff --git a/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift index eca5e64..918fb47 100644 --- a/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift @@ -2,5 +2,4 @@ import CoreLocation let switzerland = CLLocationCoordinate2D(latitude: 47.03041, longitude: 8.29470) -let demoTilesURL = - URL(string: "https://demotiles.maplibre.org/style.json")! +public let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! From f1afff56b93b1674709aca964174acc08dd516fc Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Wed, 29 May 2024 18:42:16 +0200 Subject: [PATCH 06/23] correctly handle callbacks when ending route --- .../NavigationMapView.swift | 18 ++++----- .../NavigationMapViewCoordinator.swift | 37 +++++++++++++++---- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift index 67412bf..17aa9d4 100644 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift @@ -18,6 +18,7 @@ public struct NavigationMapView: UIViewControllerRepresentable { public typealias UIViewControllerType = NavigationViewController @Binding var camera: MapViewCamera + @Binding var route: Route? let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] @@ -26,7 +27,6 @@ public struct NavigationMapView: UIViewControllerRepresentable { var onStyleLoaded: ((MLNStyle) -> Void)? var onViewPortChanged: ((MapViewPort) -> Void)? - var route: Route? public var mapViewContentInset: UIEdgeInsets = .zero @@ -47,11 +47,13 @@ public struct NavigationMapView: UIViewControllerRepresentable { public init( styleSource: MapStyleSource, camera: Binding = .constant(.default()), + route: Binding, locationManager: MLNLocationManager? = nil, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { self.styleSource = styleSource _camera = camera + _route = route userLayers = makeMapContent() self.locationManager = locationManager } @@ -70,6 +72,7 @@ public struct NavigationMapView: UIViewControllerRepresentable { // TODO: its not allowed to change the mapView delegate. find another way to link mapview with coordinator // viewController.mapView?.delegate = context.coordinator context.coordinator.mapView = viewController.mapView + viewController.delegate = context.coordinator // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as // content insets can trigger a change) @@ -95,7 +98,6 @@ public struct NavigationMapView: UIViewControllerRepresentable { self.registerGesture(mapView, context, gesture: gesture) } } - return viewController } @@ -116,20 +118,16 @@ public struct NavigationMapView: UIViewControllerRepresentable { camera: $camera.wrappedValue, animated: isStyleLoaded) - if let route { + if let route, context.coordinator.state != .running { let locationManager = SimulatedLocationManager(route: route) locationManager.speedMultiplier = 2 uiViewController.start(with: route, locationManager: locationManager) - } else { + context.coordinator.state = .running + } else if route == nil && context.coordinator.state != .ended { uiViewController.endRoute() + context.coordinator.state = .ended } } - - public func assign(route: Route?) -> NavigationMapView { - var newMapView = self - newMapView.route = route - return newMapView - } } #Preview { diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift index 40f1b89..fb70cd8 100644 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift @@ -9,8 +9,16 @@ import Foundation import MapLibre import MapLibreSwiftDSL import MapLibreSwiftUI +import MapboxNavigation public class NavigationMapViewCoordinator: NSObject { + + enum State { + case running + case ended + } + var state: State = .ended + // This must be weak, the UIViewRepresentable owns the MLNMapView. weak var mapView: MLNMapView? var parent: NavigationMapView @@ -309,12 +317,25 @@ public class NavigationMapViewCoordinator: NSObject { // MARK: - MLNMapViewDelegate extension NavigationMapViewCoordinator: MLNMapViewDelegate { + public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { addLayers(to: mglStyle) onStyleLoaded?(mglStyle) } +} + +// MARK: - NavigationViewControllerDelegate + +extension NavigationMapViewCoordinator: NavigationViewControllerDelegate { + + public func navigationViewControllerDidFinish(_ navigationViewController: NavigationViewController) { + self.parent.route = nil + } +} - // MARK: MapViewCamera +// MARK: MapViewCamera + +public extension NavigationMapViewCoordinator { @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 @@ -357,26 +378,29 @@ extension NavigationMapViewCoordinator: MLNMapViewDelegate { } /// The MapView's region has changed with a specific reason. - public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { + func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { // 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 // TODO: We could put this in regionIsChangingWith if we calculate significant change/debounce. Task { @MainActor in updateViewPort(mapView: mapView, reason: reason) } - + guard !suppressCameraUpdatePropagation else { return } - + // 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) } } +} + +// MARK: - Private - // MARK: MapViewPort +private extension NavigationMapViewCoordinator { - @MainActor private func updateViewPort(mapView: MLNMapView, reason: MLNCameraChangeReason) { + @MainActor func updateViewPort(mapView: MLNMapView, reason: MLNCameraChangeReason) { // Calculate the Raw "ViewPort" let calculatedViewPort = MapViewPort( center: mapView.centerCoordinate, @@ -388,4 +412,3 @@ extension NavigationMapViewCoordinator: MLNMapViewDelegate { onViewPortChanged(calculatedViewPort) } } - From 32a03f83ae1919993b1d0984d37aa342f230c841 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Fri, 31 May 2024 23:44:50 +0200 Subject: [PATCH 07/23] fix pr comments --- Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift | 2 +- .../NavigationMapViewCoordinator.swift | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift index 17aa9d4..cf7eab2 100644 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift @@ -125,7 +125,7 @@ public struct NavigationMapView: UIViewControllerRepresentable { context.coordinator.state = .running } else if route == nil && context.coordinator.state != .ended { uiViewController.endRoute() - context.coordinator.state = .ended + context.coordinator.state = .stopped } } } diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift index fb70cd8..f483206 100644 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift @@ -15,9 +15,9 @@ public class NavigationMapViewCoordinator: NSObject { enum State { case running - case ended + case stopped } - var state: State = .ended + var state: State = .stopped // This must be weak, the UIViewRepresentable owns the MLNMapView. weak var mapView: MLNMapView? @@ -72,6 +72,10 @@ public class NavigationMapViewCoordinator: NSObject { // No action - camera has not changed. return } + guard self.state == .stopped else { + // Navigation is in control of camera, abort + return + } suppressCameraUpdatePropagation = true defer { From c1276ab787cbbfed8001c4487c386c557534bef3 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Mon, 3 Jun 2024 11:31:39 +0200 Subject: [PATCH 08/23] use Style instead of MapStyleSource --- .../NavigationMapView.swift | 40 +++++++++++++------ .../NavigationMapViewCoordinator.swift | 13 ------ 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift index cf7eab2..624cfe2 100644 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift @@ -20,7 +20,8 @@ public struct NavigationMapView: UIViewControllerRepresentable { @Binding var camera: MapViewCamera @Binding var route: Route? - let styleSource: MapStyleSource + let dayStyle: Style + let nightStyle: Style let userLayers: [StyleLayerDefinition] var gestures = [MapGesture]() @@ -45,13 +46,15 @@ public struct NavigationMapView: UIViewControllerRepresentable { var clusteredLayers: [ClusterLayer]? public init( - styleSource: MapStyleSource, + dayStyle: Style, + nightStyle: Style, camera: Binding = .constant(.default()), route: Binding, locationManager: MLNLocationManager? = nil, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { - self.styleSource = styleSource + self.dayStyle = dayStyle + self.nightStyle = nightStyle _camera = camera _route = route userLayers = makeMapContent() @@ -67,7 +70,7 @@ public struct NavigationMapView: UIViewControllerRepresentable { } public func makeUIViewController(context: Context) -> NavigationViewController { - let viewController = NavigationViewController(directions: Directions(accessToken: "empty")) + let viewController = NavigationViewController(dayStyle: self.dayStyle, nightStyle: self.nightStyle, directions: Directions(accessToken: "empty")) // TODO: its not allowed to change the mapView delegate. find another way to link mapview with coordinator // viewController.mapView?.delegate = context.coordinator @@ -77,8 +80,6 @@ public struct NavigationMapView: UIViewControllerRepresentable { // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as // content insets can trigger a change) if let mapView = viewController.mapView { - context.coordinator.updateStyleSource(styleSource, mapView: mapView) - context.coordinator.suppressCameraUpdatePropagation = true applyModifiers(mapView, runUnsafe: false) context.coordinator.suppressCameraUpdatePropagation = false @@ -109,7 +110,6 @@ public struct NavigationMapView: UIViewControllerRepresentable { applyModifiers(mapView, runUnsafe: true) // FIXME: This should be a more selective update - context.coordinator.updateStyleSource(styleSource, mapView: mapView) context.coordinator.updateLayers(mapView: mapView) // FIXME: This isn't exactly telling us if the *map* is loaded, and the docs for setCenter say it needs to be. @@ -119,12 +119,19 @@ public struct NavigationMapView: UIViewControllerRepresentable { animated: isStyleLoaded) if let route, context.coordinator.state != .running { - let locationManager = SimulatedLocationManager(route: route) - locationManager.speedMultiplier = 2 - uiViewController.start(with: route, locationManager: locationManager) + func locationManager() -> NavigationLocationManager? { + if UIDevice.isSimulator { + let locationManager = SimulatedLocationManager(route: route) + locationManager.speedMultiplier = 2 + return locationManager + } + return nil + } + + uiViewController.startNavigation(with: route, locationManager: locationManager()) context.coordinator.state = .running - } else if route == nil && context.coordinator.state != .ended { - uiViewController.endRoute() + } else if route == nil && context.coordinator.state != .stopped { + uiViewController.endNavigation() context.coordinator.state = .stopped } } @@ -141,6 +148,15 @@ public struct NavigationMapView: UIViewControllerRepresentable { // organized into (hopefully) useful groups } +extension UIDevice { + static var isSimulator: Bool = { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + }() +} extension NavigationMapView { diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift index f483206..2fcdd5c 100644 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift @@ -218,19 +218,6 @@ public class NavigationMapViewCoordinator: NSObject { // MARK: - Coordinator API - Styles + Layers - @MainActor func updateStyleSource(_ source: MapStyleSource, mapView: MLNMapView) { - switch (source, snapshotStyleSource) { - case let (.url(newURL), .url(oldURL)): - if newURL != oldURL { - mapView.styleURL = newURL - } - case let (.url(newURL), .none): - mapView.styleURL = newURL - } - - snapshotStyleSource = source - } - @MainActor func updateLayers(mapView: MLNMapView) { // TODO: Figure out how to selectively update layers when only specific props changed. New function in addition to makeMLNStyleLayer? From 87934140d8a8873072baf7d02a8c5148dc4a05be Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Wed, 5 Jun 2024 14:40:26 +0200 Subject: [PATCH 09/23] rename state to navigationState --- .../xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme | 2 +- .../xcschemes/MapLibreSwiftUI-Package.xcscheme | 2 +- .../xcshareddata/xcschemes/MapLibreSwiftUI.xcscheme | 2 +- .../NavigationMapView.swift | 11 +++++------ .../NavigationMapViewCoordinator.swift | 12 +++++++----- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme index a45b7e5..b7b77e7 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme @@ -1,6 +1,6 @@ NavigationLocationManager? { if UIDevice.isSimulator { let locationManager = SimulatedLocationManager(route: route) @@ -129,10 +128,10 @@ public struct NavigationMapView: UIViewControllerRepresentable { } uiViewController.startNavigation(with: route, locationManager: locationManager()) - context.coordinator.state = .running - } else if route == nil && context.coordinator.state != .stopped { + context.coordinator.navigationState = .running + } else if route == nil && context.coordinator.navigationState != .stopped { uiViewController.endNavigation() - context.coordinator.state = .stopped + context.coordinator.navigationState = .stopped } } } diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift index 2fcdd5c..84b20bf 100644 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift @@ -13,15 +13,16 @@ import MapboxNavigation public class NavigationMapViewCoordinator: NSObject { - enum State { + enum NavigationState { case running case stopped } - var state: State = .stopped + var navigationState: NavigationState = .stopped // This must be weak, the UIViewRepresentable owns the MLNMapView. weak var mapView: MLNMapView? var parent: NavigationMapView + var styleLoaded: Bool = false // Storage of variables as they were previously; these are snapshot // every update cycle so we can avoid unnecessary updates @@ -72,7 +73,7 @@ public class NavigationMapViewCoordinator: NSObject { // No action - camera has not changed. return } - guard self.state == .stopped else { + guard self.navigationState == .stopped else { // Navigation is in control of camera, abort return } @@ -310,8 +311,9 @@ public class NavigationMapViewCoordinator: NSObject { extension NavigationMapViewCoordinator: MLNMapViewDelegate { public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { - addLayers(to: mglStyle) - onStyleLoaded?(mglStyle) + self.styleLoaded = true + self.addLayers(to: mglStyle) + self.onStyleLoaded?(mglStyle) } } From 8b9390b14ec64edd95e1eb19cd93706c53b375b4 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Wed, 5 Jun 2024 17:48:57 +0200 Subject: [PATCH 10/23] wrap MapView in UIViewController --- .../NavigationMapView.swift | 2 +- Sources/MapLibreSwiftUI/MapView.swift | 38 ++++++++++--------- .../MapLibreSwiftUI/MapViewController.swift | 22 +++++++++++ 3 files changed, 43 insertions(+), 19 deletions(-) create mode 100644 Sources/MapLibreSwiftUI/MapViewController.swift diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift index c53f232..29ac89d 100644 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift +++ b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift @@ -24,7 +24,7 @@ public struct NavigationMapView: UIViewControllerRepresentable { let nightStyle: Style let userLayers: [StyleLayerDefinition] - var gestures = [MapGesture]() + var gestures = [MapGesture]() var onStyleLoaded: ((MLNStyle) -> Void)? var onViewPortChanged: ((MapViewPort) -> Void)? diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 7dc92b4..e6d9da5 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -3,7 +3,9 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -public struct MapView: UIViewRepresentable { +public struct MapView: UIViewControllerRepresentable { + public typealias UIViewControllerType = MapViewController + @Binding var camera: MapViewCamera let styleSource: MapStyleSource @@ -50,54 +52,54 @@ public struct MapView: UIViewRepresentable { ) } - public func makeUIView(context: Context) -> MLNMapView { + public func makeUIViewController(context: Context) -> MapViewController { // Create the map view - let mapView = MLNMapView(frame: .zero) - mapView.delegate = context.coordinator - context.coordinator.mapView = mapView + let controller = MapViewController() + controller.mapView.delegate = context.coordinator + context.coordinator.mapView = controller.mapView // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as // content insets can trigger a change) context.coordinator.suppressCameraUpdatePropagation = true - applyModifiers(mapView, runUnsafe: false) + self.applyModifiers(controller.mapView, runUnsafe: false) context.coordinator.suppressCameraUpdatePropagation = false - mapView.locationManager = locationManager + controller.mapView.locationManager = locationManager switch styleSource { case let .url(styleURL): - mapView.styleURL = styleURL + controller.mapView.styleURL = styleURL } - context.coordinator.updateCamera(mapView: mapView, + context.coordinator.updateCamera(mapView: controller.mapView, camera: $camera.wrappedValue, animated: false) - mapView.locationManager = mapView.locationManager + controller.mapView.locationManager = controller.mapView.locationManager // Link the style loaded to the coordinator that emits the delegate event. context.coordinator.onStyleLoaded = onStyleLoaded // Add all gesture recognizers for gesture in gestures { - registerGesture(mapView, context, gesture: gesture) + registerGesture(controller.mapView, context, gesture: gesture) } - return mapView + return controller } - public func updateUIView(_ mapView: MLNMapView, context: Context) { + public func updateUIViewController(_ uiViewController: MapViewController, context: Context) { context.coordinator.parent = self - applyModifiers(mapView, runUnsafe: true) + applyModifiers(uiViewController.mapView, runUnsafe: true) // FIXME: This should be a more selective update - context.coordinator.updateStyleSource(styleSource, mapView: mapView) - context.coordinator.updateLayers(mapView: mapView) + context.coordinator.updateStyleSource(styleSource, mapView: uiViewController.mapView) + context.coordinator.updateLayers(mapView: uiViewController.mapView) // FIXME: This isn't exactly telling us if the *map* is loaded, and the docs for setCenter say it needs to be. - let isStyleLoaded = mapView.style != nil + let isStyleLoaded = uiViewController.mapView.style != nil - context.coordinator.updateCamera(mapView: mapView, + context.coordinator.updateCamera(mapView: uiViewController.mapView, camera: $camera.wrappedValue, animated: isStyleLoaded) } diff --git a/Sources/MapLibreSwiftUI/MapViewController.swift b/Sources/MapLibreSwiftUI/MapViewController.swift new file mode 100644 index 0000000..1d85098 --- /dev/null +++ b/Sources/MapLibreSwiftUI/MapViewController.swift @@ -0,0 +1,22 @@ +// +// MapViewController.swift +// +// +// Created by Patrick Kladek on 05.06.24. +// + +import UIKit +import MapLibre +import MapboxNavigation +import MapboxCoreNavigation + +public final class MapViewController: UIViewController { + + var mapView: MLNMapView { + return self.view as! MLNMapView + } + + override public func loadView() { + self.view = MLNMapView(frame: .zero) + } +} From 00ac50086b04909e18b311c2b054f9f06e17e62e Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Wed, 5 Jun 2024 18:20:25 +0200 Subject: [PATCH 11/23] add generics to MapView --- Sources/MapLibreSwiftUI/Examples/Camera.swift | 2 +- Sources/MapLibreSwiftUI/Examples/Gestures.swift | 4 ++-- Sources/MapLibreSwiftUI/Examples/Layers.swift | 14 +++++++------- Sources/MapLibreSwiftUI/Examples/Other.swift | 2 +- Sources/MapLibreSwiftUI/Examples/Polyline.swift | 2 +- .../MapLibreSwiftUI/Examples/User Location.swift | 4 ++-- Sources/MapLibreSwiftUI/MapView.swift | 16 ++++++++-------- Sources/MapLibreSwiftUI/MapViewController.swift | 8 ++++++-- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 10 ++++------ 9 files changed, 32 insertions(+), 30 deletions(-) diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index a7f0f11..3853e03 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -9,7 +9,7 @@ struct CameraDirectManipulationPreview: View { var targetCameraAfterDelay: MapViewCamera? = nil var body: some View { - MapView(styleURL: styleURL, camera: $camera) + MapView(styleURL: styleURL, camera: $camera) .onStyleLoaded { _ in onStyleLoaded?() } diff --git a/Sources/MapLibreSwiftUI/Examples/Gestures.swift b/Sources/MapLibreSwiftUI/Examples/Gestures.swift index 4d5a3f5..2dbb2a2 100644 --- a/Sources/MapLibreSwiftUI/Examples/Gestures.swift +++ b/Sources/MapLibreSwiftUI/Examples/Gestures.swift @@ -5,7 +5,7 @@ import SwiftUI #Preview("Tappable Circles") { let tappableID = "simple-circles" - return MapView(styleURL: demoTilesURL) { + return MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon CircleStyleLayer(identifier: tappableID, source: pointSource) .radius(16) @@ -24,7 +24,7 @@ import SwiftUI } #Preview("Tappable Countries") { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .onTapMapGesture(on: ["countries-fill"], onTapChanged: { _, features in print("Tapped on \(features.first?.description ?? "")") }) diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index bc898d7..2a2384a 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -34,7 +34,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Rose Tint") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Silly example: a background layer on top of everything to create a tint effect BackgroundLayer(identifier: "rose-colored-glasses") .backgroundColor(.systemPink.withAlphaComponent(0.3)) @@ -44,7 +44,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Simple Symbol") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) .iconImage(UIImage(systemName: "mappin")!) @@ -53,7 +53,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Rotated Symbols (Const)") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) .iconImage(UIImage(systemName: "location.north.circle.fill")!) @@ -63,7 +63,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Rotated Symbols (Dynamic)") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) .iconImage(UIImage(systemName: "location.north.circle.fill")!) @@ -73,7 +73,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Circles with Symbols") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon CircleStyleLayer(identifier: "simple-circles", source: pointSource) .radius(16) @@ -94,7 +94,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c zoom: 5, direction: 0 ) - return MapView(styleURL: demoTilesURL, camera: $camera) { + return MapView(styleURL: demoTilesURL, camera: $camera) { // Clusters pins when they would touch // Cluster == YES shows only those pins that are clustered, using .text @@ -135,7 +135,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c // TODO: Fixme // #Preview("Multiple Symbol Icons") { -// MapView(styleURL: demoTilesURL) { +// MapView(styleURL: demoTilesURL) { // // Simple symbol layer demonstration with an icon // SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) // .iconImage(attribute: "icon", diff --git a/Sources/MapLibreSwiftUI/Examples/Other.swift b/Sources/MapLibreSwiftUI/Examples/Other.swift index 7e5b6a8..3a2dadf 100644 --- a/Sources/MapLibreSwiftUI/Examples/Other.swift +++ b/Sources/MapLibreSwiftUI/Examples/Other.swift @@ -4,7 +4,7 @@ import MapLibreSwiftDSL import SwiftUI #Preview("Unsafe MapView Modifier") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // 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 df5c3f9..216a0d2 100644 --- a/Sources/MapLibreSwiftUI/Examples/Polyline.swift +++ b/Sources/MapLibreSwiftUI/Examples/Polyline.swift @@ -7,7 +7,7 @@ struct PolylinePreview: View { let styleURL: URL var body: some View { - MapView(styleURL: styleURL, + MapView(styleURL: styleURL, camera: .constant(.center(samplePedestrianWaypoints.first!, zoom: 14))) { // Note: This line does not add the source to the style as if it diff --git a/Sources/MapLibreSwiftUI/Examples/User Location.swift b/Sources/MapLibreSwiftUI/Examples/User Location.swift index b30b575..3c1e580 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( + MapView( styleURL: demoTilesURL, camera: .constant(.trackUserLocation(zoom: 4, pitch: 45)), locationManager: locationManager @@ -24,7 +24,7 @@ private let locationManager = StaticLocationManager(initialLocation: CLLocation( } #Preview("Track user location with Course") { - MapView( + MapView( styleURL: demoTilesURL, camera: .constant(.trackUserLocationWithCourse(zoom: 4, pitch: 45)), locationManager: locationManager diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index e6d9da5..d0cfbfa 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -3,8 +3,8 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -public struct MapView: UIViewControllerRepresentable { - public typealias UIViewControllerType = MapViewController +public struct MapView: UIViewControllerRepresentable { + public typealias UIViewControllerType = T @Binding var camera: MapViewCamera @@ -44,17 +44,17 @@ public struct MapView: UIViewControllerRepresentable { self.locationManager = locationManager } - public func makeCoordinator() -> MapViewCoordinator { - MapViewCoordinator( + public func makeCoordinator() -> MapViewCoordinator { + MapViewCoordinator( parent: self, onGesture: { processGesture($0, $1) }, onViewPortChanged: { onViewPortChanged?($0) } ) } - public func makeUIViewController(context: Context) -> MapViewController { + public func makeUIViewController(context: Context) -> T { // Create the map view - let controller = MapViewController() + let controller = T() controller.mapView.delegate = context.coordinator context.coordinator.mapView = controller.mapView @@ -87,7 +87,7 @@ public struct MapView: UIViewControllerRepresentable { return controller } - public func updateUIViewController(_ uiViewController: MapViewController, context: Context) { + public func updateUIViewController(_ uiViewController: T, context: Context) { context.coordinator.parent = self applyModifiers(uiViewController.mapView, runUnsafe: true) @@ -124,7 +124,7 @@ public struct MapView: UIViewControllerRepresentable { } #Preview { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .ignoresSafeArea(.all) .previewDisplayName("Vanilla Map") diff --git a/Sources/MapLibreSwiftUI/MapViewController.swift b/Sources/MapLibreSwiftUI/MapViewController.swift index 1d85098..d3b8614 100644 --- a/Sources/MapLibreSwiftUI/MapViewController.swift +++ b/Sources/MapLibreSwiftUI/MapViewController.swift @@ -10,9 +10,13 @@ import MapLibre import MapboxNavigation import MapboxCoreNavigation -public final class MapViewController: UIViewController { +public protocol WrappedViewController: UIViewController { + var mapView: MLNMapView { get } +} + +public final class MapViewController: UIViewController, WrappedViewController { - var mapView: MLNMapView { + public var mapView: MLNMapView { return self.view as! MLNMapView } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index d0886ed..287d9aa 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -2,10 +2,10 @@ import Foundation import MapLibre import MapLibreSwiftDSL -public class MapViewCoordinator: NSObject { +public class MapViewCoordinator: NSObject, MLNMapViewDelegate { // This must be weak, the UIViewRepresentable owns the MLNMapView. weak var mapView: MLNMapView? - var parent: MapView + var parent: MapView // Storage of variables as they were previously; these are snapshot // every update cycle so we can avoid unnecessary updates @@ -22,7 +22,7 @@ public class MapViewCoordinator: NSObject { var onGesture: (MLNMapView, UIGestureRecognizer) -> Void var onViewPortChanged: (MapViewPort) -> Void - init(parent: MapView, + init(parent: MapView, onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void, onViewPortChanged: @escaping (MapViewPort) -> Void) { @@ -296,11 +296,9 @@ public class MapViewCoordinator: NSObject { } } } -} -// MARK: - MLNMapViewDelegate + // MARK: - MLNMapViewDelegate -extension MapViewCoordinator: MLNMapViewDelegate { public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { addLayers(to: mglStyle) onStyleLoaded?(mglStyle) From b6d2b9fb7149f2d9502a5159886357aea7693e85 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Fri, 7 Jun 2024 18:23:23 +0200 Subject: [PATCH 12/23] remove maplibre-navigation dependency --- Package.resolved | 36 -- Package.swift | 20 - .../NavigationMapView.swift | 393 ----------------- .../NavigationMapViewCoordinator.swift | 407 ------------------ Sources/MapLibreSwiftUI/Examples/Other.swift | 6 +- Sources/MapLibreSwiftUI/MapView.swift | 47 +- .../MapLibreSwiftUI/MapViewController.swift | 11 +- .../MapLibreSwiftUI/MapViewModifiers.swift | 26 +- 8 files changed, 53 insertions(+), 893 deletions(-) delete mode 100644 Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift delete mode 100644 Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift diff --git a/Package.resolved b/Package.resolved index eb36a6d..8b80b5a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "mapbox-directions-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/flitsmeister/mapbox-directions-swift", - "state" : { - "revision" : "6c19ecc4e1324887ae3250802b8d13d8d8b3ff2d", - "version" : "0.23.3" - } - }, { "identity" : "maplibre-gl-native-distribution", "kind" : "remoteSourceControl", @@ -36,24 +27,6 @@ "version" : "0.0.3" } }, - { - "identity" : "polyline", - "kind" : "remoteSourceControl", - "location" : "https://github.com/raphaelmor/Polyline", - "state" : { - "revision" : "353f80378dcd8f17eefe8550090c6b1ae3c9da23", - "version" : "5.1.0" - } - }, - { - "identity" : "solar", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ceeK/Solar.git", - "state" : { - "revision" : "c2b96f2d5fb7f835b91cefac5e83101f54643901", - "version" : "3.0.1" - } - }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", @@ -71,15 +44,6 @@ "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", "version" : "509.1.1" } - }, - { - "identity" : "turf-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/flitsmeister/turf-swift", - "state" : { - "revision" : "b05b4658d1b48eac4127a0d9ebbb5a6f965a8251", - "version" : "0.2.2" - } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index fb6c4a6..06a554e 100644 --- a/Package.swift +++ b/Package.swift @@ -15,9 +15,6 @@ let package = Package( name: "MapLibreSwiftUI", targets: ["MapLibreSwiftUI"] ), - .library(name: "MapLibreNavigationSwiftUI", - targets: ["MapLibreNavigationSwiftUI"] - ), .library( name: "MapLibreSwiftDSL", targets: ["MapLibreSwiftDSL"] @@ -26,7 +23,6 @@ let package = Package( dependencies: [ .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.4.0"), .package(url: "https://github.com/stadiamaps/maplibre-swift-macros.git", from: "0.0.3"), - .package(path: "~/Developer/maplibre-navigation-ios"), // Testing .package(url: "https://github.com/Kolos65/Mockable.git", exact: "0.0.3"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.15.3"), @@ -38,7 +34,6 @@ let package = Package( .target(name: "InternalUtils"), .target(name: "MapLibreSwiftDSL"), .product(name: "MapLibre", package: "maplibre-gl-native-distribution"), - .product(name: "MapboxNavigation", package: "maplibre-navigation-ios"), .product(name: "Mockable", package: "Mockable"), ], swiftSettings: [ @@ -46,21 +41,6 @@ let package = Package( .enableExperimentalFeature("StrictConcurrency"), ] ), - .target( - name: "MapLibreNavigationSwiftUI", - dependencies: [ - .target(name: "InternalUtils"), - .target(name: "MapLibreSwiftDSL"), - .target(name: "MapLibreSwiftUI"), - .product(name: "MapLibre", package: "maplibre-gl-native-distribution"), - .product(name: "MapboxNavigation", package: "maplibre-navigation-ios"), - .product(name: "Mockable", package: "Mockable"), - ], - swiftSettings: [ - .define("MOCKING", .when(configuration: .debug)), - .enableExperimentalFeature("StrictConcurrency"), - ] - ), .target( name: "MapLibreSwiftDSL", dependencies: [ diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift deleted file mode 100644 index 29ac89d..0000000 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapView.swift +++ /dev/null @@ -1,393 +0,0 @@ -// -// NavigationMapView.swift -// -// -// Created by Patrick Kladek on 23.05.24. -// - -import InternalUtils -import MapboxCoreNavigation -import MapboxDirections -import MapboxNavigation -import MapLibre -import MapLibreSwiftDSL -import MapLibreSwiftUI -import SwiftUI - -public struct NavigationMapView: UIViewControllerRepresentable { - public typealias UIViewControllerType = NavigationViewController - - @Binding var camera: MapViewCamera - @Binding var route: Route? - - let dayStyle: Style - let nightStyle: Style - let userLayers: [StyleLayerDefinition] - - var gestures = [MapGesture]() - - var onStyleLoaded: ((MLNStyle) -> Void)? - var onViewPortChanged: ((MapViewPort) -> Void)? - - public var mapViewContentInset: UIEdgeInsets = .zero - - /// 'Escape hatch' to MLNMapView until we have more modifiers. - /// See ``unsafeMapViewModifier(_:)`` - var unsafeMapViewModifier: ((MLNMapView) -> Void)? - - var controls: [MapControl] = [ - CompassView(), - LogoView(), - AttributionButton(), - ] - - private var locationManager: MLNLocationManager? - - var clusteredLayers: [ClusterLayer]? - - public init( - dayStyle: Style, - nightStyle: Style, - camera: Binding = .constant(.default()), - route: Binding, - locationManager: MLNLocationManager? = nil, - @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } - ) { - self.dayStyle = dayStyle - self.nightStyle = nightStyle - _camera = camera - _route = route - userLayers = makeMapContent() - self.locationManager = locationManager - } - - public func makeCoordinator() -> NavigationMapViewCoordinator { - NavigationMapViewCoordinator( - parent: self, - onGesture: { processGesture($0, $1) }, - onViewPortChanged: { onViewPortChanged?($0) } - ) - } - - public func makeUIViewController(context: Context) -> NavigationViewController { - let viewController = NavigationViewController(dayStyle: self.dayStyle, nightStyle: self.nightStyle, directions: Directions(accessToken: "empty")) - - // TODO: its not allowed to change the mapView delegate. find another way to link mapview with coordinator - // viewController.mapView?.delegate = context.coordinator - context.coordinator.mapView = viewController.mapView - viewController.delegate = context.coordinator - - // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as - // content insets can trigger a change) - if let mapView = viewController.mapView { - context.coordinator.suppressCameraUpdatePropagation = true - applyModifiers(mapView, runUnsafe: false) - context.coordinator.suppressCameraUpdatePropagation = false - - mapView.locationManager = locationManager - - 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 - - // Add all gesture recognizers - for gesture in gestures { - self.registerGesture(mapView, context, gesture: gesture) - } - } - - return viewController - } - - public func updateUIViewController(_ uiViewController: NavigationViewController, context: Context) { - context.coordinator.parent = self - let mapView = uiViewController.mapView! - - applyModifiers(mapView, runUnsafe: true) - - // FIXME: This should be a more selective update - context.coordinator.updateLayers(mapView: mapView) - - let isStyleLoaded = context.coordinator.styleLoaded - context.coordinator.updateCamera(mapView: mapView, - camera: $camera.wrappedValue, - animated: isStyleLoaded) - - if let route, context.coordinator.navigationState != .running { - func locationManager() -> NavigationLocationManager? { - if UIDevice.isSimulator { - let locationManager = SimulatedLocationManager(route: route) - locationManager.speedMultiplier = 2 - return locationManager - } - return nil - } - - uiViewController.startNavigation(with: route, locationManager: locationManager()) - context.coordinator.navigationState = .running - } else if route == nil && context.coordinator.navigationState != .stopped { - uiViewController.endNavigation() - context.coordinator.navigationState = .stopped - } - } -} - -#Preview { - MapView(styleURL: demoTilesURL) - .ignoresSafeArea(.all) - .previewDisplayName("Vanilla Map") - - // For a larger selection of previews, - // check out the Examples directory, which - // has a wide variety of previews, - // organized into (hopefully) useful groups -} - -extension UIDevice { - static var isSimulator: Bool = { - #if targetEnvironment(simulator) - return true - #else - return false - #endif - }() -} - -extension NavigationMapView { - - @MainActor func registerGesture(_ mapView: MLNMapView, _ context: Context, gesture: MapGesture) { - switch gesture.method { - case let .tap(numberOfTaps: numberOfTaps): - let gestureRecognizer = UITapGestureRecognizer(target: context.coordinator, - action: #selector(context.coordinator.captureGesture(_:))) - gestureRecognizer.numberOfTapsRequired = numberOfTaps - if numberOfTaps == 1 { - // If a user double taps to zoom via the built in gesture, a normal - // tap should not be triggered. - if let doubleTapRecognizer = mapView.gestureRecognizers? - .first(where: { - $0 is UITapGestureRecognizer && ($0 as! UITapGestureRecognizer).numberOfTapsRequired == 2 - }) - { - gestureRecognizer.require(toFail: doubleTapRecognizer) - } - } - mapView.addGestureRecognizer(gestureRecognizer) - gesture.gestureRecognizer = gestureRecognizer - - case let .longPress(minimumDuration: minimumDuration): - let gestureRecognizer = UILongPressGestureRecognizer(target: context.coordinator, - action: #selector(context.coordinator - .captureGesture(_:))) - gestureRecognizer.minimumPressDuration = minimumDuration - - mapView.addGestureRecognizer(gestureRecognizer) - gesture.gestureRecognizer = gestureRecognizer - } - } - - @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 - } - - // Process the gesture into a context response. - let context = processContextFromGesture(mapView, gesture: gesture, sender: sender) - // Run the context through the gesture held on the MapView (emitting to the MapView modifier). - switch gesture.onChange { - case let .context(action): - action(context) - case let .feature(action, layers): - let point = sender.location(in: sender.view) - let features = mapView.visibleFeatures(at: point, styleLayerIdentifiers: layers) - action(context, features) - } - } - - @MainActor func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, - sender: UIGestureRecognizing) -> MapGestureContext { - // Build the context of the gesture's event. - 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) - case .longPress: - // Calculate the CGPoint of the long process gesture. - sender.location(in: mapView) - } - - return MapGestureContext(gestureMethod: gesture.method, - state: sender.state, - point: point, - coordinate: mapView.convert(point, toCoordinateFrom: mapView)) - } -} - -public extension NavigationMapView { - /// Perform an action when the map view has loaded its style and all locally added style definitions. - /// - /// - Parameter perform: The action to perform with the loaded style. - /// - Returns: The modified map view. - func onStyleLoaded(_ perform: @escaping (MLNStyle) -> Void) -> NavigationMapView { - var newMapView = self - newMapView.onStyleLoaded = perform - return newMapView - } - - /// Allows you to set properties of the underlying MLNMapView directly - /// in cases where these have not been ported to DSL yet. - /// Use this function to modify various properties of the MLNMapView instance. - /// For example, you can enable the display of the user's location on the map by setting `showUserLocation` to true. - /// - /// This is an 'escape hatch' back to the non-DSL world - /// of MapLibre for features that have not been ported to DSL yet. - /// Be careful not to use this to modify properties that are - /// already ported to the DSL, like the camera for example, as your - /// modifications here may break updates that occur with modifiers. - /// In particular, this modifier is potentially dangerous as it runs on - /// EVERY call to `updateUIView`. - /// - /// - Parameter modifier: A closure that provides you with an MLNMapView so you can set properties. - /// - Returns: A MapView with the modifications applied. - /// - /// Example: - /// ```swift - /// MapView() - /// .mapViewModifier { mapView in - /// mapView.showUserLocation = true - /// } - /// ``` - /// - func unsafeMapViewModifier(_ modifier: @escaping (MLNMapView) -> Void) -> NavigationMapView { - var newMapView = self - newMapView.unsafeMapViewModifier = modifier - return newMapView - } - - // MARK: Default Gestures - - /// Add an tap gesture handler to the MapView - /// - /// - Parameters: - /// - count: The number of taps required to run the gesture. - /// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc), that also contains - /// information like the latitude and longitude of the tap. - /// - Returns: The modified map view. - func onTapMapGesture(count: Int = 1, - onTapChanged: @escaping (MapGestureContext) -> Void) -> NavigationMapView - { - var newMapView = self - - // Build the gesture and link it to the map view. - let gesture = MapGesture(method: .tap(numberOfTaps: count), - onChange: .context(onTapChanged)) - newMapView.gestures.append(gesture) - - return newMapView - } - - /// Add an tap gesture handler to the MapView that returns any visible map features that were tapped. - /// - /// - Parameters: - /// - count: The number of taps required to run the gesture. - /// - on layers: The set of layer ids that you would like to check for visible features that were tapped. If no - /// set is provided, all map layers are checked. - /// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc), that also contains - /// information like the latitude and longitude of the tap. Also emits an array of map features that were tapped. - /// Returns an empty array when nothing was tapped on the "on" layer ids that were provided. - /// - Returns: The modified map view. - func onTapMapGesture(count: Int = 1, on layers: Set?, - onTapChanged: @escaping (MapGestureContext, [any MLNFeature]) -> Void) -> NavigationMapView - { - var newMapView = self - - // Build the gesture and link it to the map view. - let gesture = MapGesture(method: .tap(numberOfTaps: count), - onChange: .feature(onTapChanged, layers: layers)) - newMapView.gestures.append(gesture) - - return newMapView - } - - /// Add a long press gesture handler to the MapView - /// - /// - Parameters: - /// - minimumDuration: The minimum duration in seconds the user must press the screen to run the gesture. - /// - onPressChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc). - /// - Returns: The modified map view. - func onLongPressMapGesture(minimumDuration: Double = 0.5, - onPressChanged: @escaping (MapGestureContext) -> Void) -> NavigationMapView - { - var newMapView = self - - // Build the gesture and link it to the map view. - let gesture = MapGesture(method: .longPress(minimumDuration: minimumDuration), - onChange: .context(onPressChanged)) - newMapView.gestures.append(gesture) - - return newMapView - } - - /// Add a default implementation for tapping clustered features. When tapped, the map zooms so that the cluster is - /// expanded. - /// - Parameter clusteredLayers: An array of layers to monitor that can contain clustered features. - /// - Returns: The modified MapView - func expandClustersOnTapping(clusteredLayers: [ClusterLayer]) -> NavigationMapView { - var newMapView = self - - newMapView.clusteredLayers = clusteredLayers - - return newMapView - } - - func mapViewContentInset(_ inset: UIEdgeInsets) -> Self { - var result = self - - result.mapViewContentInset = inset - - return result - } - - func mapControls(@MapControlsBuilder _ buildControls: () -> [MapControl]) -> Self { - var result = self - - result.controls = buildControls() - - return result - } - - func onMapViewPortUpdate(_ onViewPortChanged: @escaping (MapViewPort) -> Void) -> Self { - var result = self - result.onViewPortChanged = onViewPortChanged - return result - } -} - - -// MARK: - Private - -private extension NavigationMapView { - - @MainActor func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) { - mapView.contentInset = mapViewContentInset - - // Assume all controls are hidden by default (so that an empty list returns a map with no controls) - mapView.logoView.isHidden = true - mapView.compassView.isHidden = true - mapView.attributionButton.isHidden = true - - // Apply each control configuration - for control in controls { - control.configureMapView(mapView) - } - - if runUnsafe { - unsafeMapViewModifier?(mapView) - } - } -} diff --git a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift b/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift deleted file mode 100644 index 84b20bf..0000000 --- a/Sources/MapLibreNavigationSwiftUI/NavigationMapViewCoordinator.swift +++ /dev/null @@ -1,407 +0,0 @@ -// -// NavigationMapViewCoordinator.swift -// -// -// Created by Patrick Kladek on 23.05.24. -// - -import Foundation -import MapLibre -import MapLibreSwiftDSL -import MapLibreSwiftUI -import MapboxNavigation - -public class NavigationMapViewCoordinator: NSObject { - - enum NavigationState { - case running - case stopped - } - var navigationState: NavigationState = .stopped - - // This must be weak, the UIViewRepresentable owns the MLNMapView. - weak var mapView: MLNMapView? - var parent: NavigationMapView - var styleLoaded: Bool = false - - // Storage of variables as they were previously; these are snapshot - // every update cycle so we can avoid unnecessary updates - private var snapshotUserLayers: [StyleLayerDefinition] = [] - private var snapshotCamera: MapViewCamera? - private var snapshotStyleSource: MapStyleSource? - - // Indicates whether we are currently in a push-down camera update cycle. - // This is necessary in order to ensure we don't keep trying to reset a state value which we were already processing - // an update for. - var suppressCameraUpdatePropagation = false - - var onStyleLoaded: ((MLNStyle) -> Void)? - var onGesture: (MLNMapView, UIGestureRecognizer) -> Void - var onViewPortChanged: (MapViewPort) -> Void - - init(parent: NavigationMapView, - onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void, - onViewPortChanged: @escaping (MapViewPort) -> Void) - { - self.parent = parent - self.onGesture = onGesture - self.onViewPortChanged = onViewPortChanged - } - - // MARK: Core UIView Functionality - - @objc func captureGesture(_ sender: UIGestureRecognizer) { - guard let mapView else { - return - } - - onGesture(mapView, sender) - } - - // MARK: - Coordinator API - Camera + Manipulation - - /// Update the camera based on the MapViewCamera binding change. - /// - /// - Parameters: - /// - mapView: This is the camera updating protocol representation of the MLNMapView. This allows mockable testing - /// for - /// camera related MLNMapView functionality. - /// - camera: The new camera from the binding. - /// - animated: Whether to animate. - @MainActor func updateCamera(mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool) { - guard camera != snapshotCamera else { - // No action - camera has not changed. - return - } - guard self.navigationState == .stopped else { - // Navigation is in control of camera, abort - return - } - - suppressCameraUpdatePropagation = true - defer { - suppressCameraUpdatePropagation = false - } - - switch camera.state { - case let .centered( - onCoordinate: coordinate, - zoom: zoom, - pitch: pitch, - pitchRange: pitchRange, - direction: direction - ): - mapView.userTrackingMode = .none - - if mapView.frame.size == .zero { - // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, - // so let's do something else instead. - mapView.setCenter(coordinate, - zoomLevel: zoom, - direction: direction, - animated: animated) - - // this is a workaround for no camera - minimum and maximum will be reset below, but this adjusts it. - mapView.minimumPitch = pitch - mapView.maximumPitch = pitch - - } else { - let camera = mapView.camera - camera.centerCoordinate = coordinate - camera.heading = direction - camera.pitch = pitch - - let altitude = MLNAltitudeForZoomLevel(zoom, pitch, coordinate.latitude, mapView.frame.size) - camera.altitude = altitude - mapView.setCamera(camera, animated: animated) - } - - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - case let .trackingUserLocation(zoom: zoom, pitch: pitch, pitchRange: pitchRange, direction: direction): - mapView.userTrackingMode = .follow - - if mapView.frame.size == .zero { - // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, - // so let's do something else instead. - // Needs to be non-animated or else it messes up following - - mapView.setZoomLevel(zoom, animated: false) - mapView.direction = direction - - mapView.minimumPitch = pitch - mapView.maximumPitch = pitch - - } else { - let camera = mapView.camera - camera.heading = direction - camera.pitch = pitch - - let altitude = MLNAltitudeForZoomLevel( - zoom, - pitch, - mapView.camera.centerCoordinate.latitude, - mapView.frame.size - ) - camera.altitude = altitude - mapView.setCamera(camera, animated: animated) - } - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - case let .trackingUserLocationWithHeading(zoom: zoom, pitch: pitch, pitchRange: pitchRange): - mapView.userTrackingMode = .followWithHeading - - if mapView.frame.size == .zero { - // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, - // so let's do something else instead. - // Needs to be non-animated or else it messes up following - - mapView.setZoomLevel(zoom, animated: false) - mapView.minimumPitch = pitch - mapView.maximumPitch = pitch - - } else { - let camera = mapView.camera - - let altitude = MLNAltitudeForZoomLevel( - zoom, - pitch, - mapView.camera.centerCoordinate.latitude, - mapView.frame.size - ) - camera.altitude = altitude - camera.pitch = pitch - mapView.setCamera(camera, animated: animated) - } - - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - case let .trackingUserLocationWithCourse(zoom: zoom, pitch: pitch, pitchRange: pitchRange): - mapView.userTrackingMode = .followWithCourse - - if mapView.frame.size == .zero { - // On init, the mapView's frame is not set up yet, so manipulation via camera is broken, - // so let's do something else instead. - // Needs to be non-animated or else it messes up following - - mapView.setZoomLevel(zoom, animated: false) - mapView.minimumPitch = pitch - mapView.maximumPitch = pitch - - } else { - let camera = mapView.camera - - let altitude = MLNAltitudeForZoomLevel( - zoom, - pitch, - mapView.camera.centerCoordinate.latitude, - mapView.frame.size - ) - camera.altitude = altitude - camera.pitch = pitch - mapView.setCamera(camera, animated: animated) - } - - mapView.minimumPitch = pitchRange.rangeValue.lowerBound - mapView.maximumPitch = pitchRange.rangeValue.upperBound - case let .rect(boundingBox, padding): - mapView.setVisibleCoordinateBounds(boundingBox, - edgePadding: padding, - animated: animated, - completionHandler: nil) - case .showcase: - // TODO: Need a method these/or to finalize a goal here. - break - } - - snapshotCamera = camera - } - - // MARK: - Coordinator API - Styles + Layers - - @MainActor func 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... - // Try to reuse DSL-defined sources if possible (they are the same type)! - if let style = mapView.style { - var sourcesToRemove = Set() - for layer in snapshotUserLayers { - if let oldLayer = style.layer(withIdentifier: layer.identifier) { - style.removeLayer(oldLayer) - } - - if let specWithSource = layer as? SourceBoundStyleLayerDefinition { - switch specWithSource.source { - case .mglSource: - // Do Nothing - // DISCUSS: The idea is to exclude "unmanaged" sources and only manage the ones specified via the DSL and attached to a layer. - // This is a really hackish design and I don't particularly like it. - continue - case .source: - // Mark sources for removal after all user layers have been removed. - // Sources specified in this way should be used by a layer already in the style. - sourcesToRemove.insert(specWithSource.source.identifier) - } - } - } - - // Remove sources that were added by layers specified in the DSL - for sourceID in sourcesToRemove { - if let source = style.source(withIdentifier: sourceID) { - style.removeSource(source) - } else { - print("That's funny... couldn't find identifier \(sourceID)") - } - } - } - - // Snapshot the new user-defined layers - snapshotUserLayers = parent.userLayers - - // If the style is loaded, add the new layers to it. - // Otherwise, this will get invoked automatically by the style didFinishLoading callback - if let style = mapView.style { - addLayers(to: style) - } - } - - func addLayers(to mglStyle: MLNStyle) { - for layerSpec in parent.userLayers { - // DISCUSS: What preventions should we try to put in place against the user accidentally adding the same layer twice? - let newLayer = layerSpec.makeStyleLayer(style: mglStyle).makeMLNStyleLayer() - - // Unconditionally transfer the common properties - newLayer.isVisible = layerSpec.isVisible - - if let minZoom = layerSpec.minimumZoomLevel { - newLayer.minimumZoomLevel = minZoom - } - - if let maxZoom = layerSpec.maximumZoomLevel { - newLayer.maximumZoomLevel = maxZoom - } - - switch layerSpec.insertionPosition { - case let .above(layerID: id): - if let layer = mglStyle.layer(withIdentifier: id) { - mglStyle.insertLayer(newLayer, above: layer) - } else { - NSLog("Failed to find layer with ID \(id). Adding layer on top.") - mglStyle.addLayer(newLayer) - } - case let .below(layerID: id): - if let layer = mglStyle.layer(withIdentifier: id) { - mglStyle.insertLayer(newLayer, below: layer) - } else { - NSLog("Failed to find layer with ID \(id). Adding layer on top.") - mglStyle.addLayer(newLayer) - } - case .aboveOthers: - mglStyle.addLayer(newLayer) - case .belowOthers: - mglStyle.insertLayer(newLayer, at: 0) - } - } - } -} - -// MARK: - MLNMapViewDelegate - -extension NavigationMapViewCoordinator: MLNMapViewDelegate { - - public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { - self.styleLoaded = true - self.addLayers(to: mglStyle) - self.onStyleLoaded?(mglStyle) - } -} - -// MARK: - NavigationViewControllerDelegate - -extension NavigationMapViewCoordinator: NavigationViewControllerDelegate { - - public func navigationViewControllerDidFinish(_ navigationViewController: NavigationViewController) { - self.parent.route = nil - } -} - -// MARK: MapViewCamera - -public extension NavigationMapViewCoordinator { - - @MainActor private func updateParentCamera(mapView: MLNMapView, reason: MLNCameraChangeReason) { - // If any of these are a mismatch, we know the camera is no longer following a desired method, so we should - // detach and revert to a .centered camera. If any one of these is true, the desired camera state still - // matches the mapView's userTrackingMode - // NOTE: The use of assumeIsolated is just to make Swift strict concurrency checks happy. - // This invariant is upheld by the MLNMapView. - let userTrackingMode = mapView.userTrackingMode - let isProgrammaticallyTracking: Bool = switch parent.camera.state { - case .trackingUserLocation: - userTrackingMode == .follow - case .trackingUserLocationWithHeading: - userTrackingMode == .followWithHeading - case .trackingUserLocationWithCourse: - userTrackingMode == .followWithCourse - case .centered, .rect, .showcase: - false - } - - guard !isProgrammaticallyTracking else { - // Programmatic tracking is still active, we can ignore camera updates until we unset/fail this boolean - // check - return - } - - // Publish the MLNMapView's "raw" camera state to the MapView camera binding. - // This path only executes when the map view diverges from the parent state, so this is a "matter of fact" - // state propagation. - let newCamera: MapViewCamera = .center(mapView.centerCoordinate, - zoom: mapView.zoomLevel, - pitch: mapView.camera.pitch, - pitchRange: .freeWithinRange( - minimum: mapView.minimumPitch, - maximum: mapView.maximumPitch - ), - direction: mapView.direction, - reason: CameraChangeReason(reason)) - snapshotCamera = newCamera - parent.camera = newCamera - } - - /// The MapView's region has changed with a specific reason. - func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated _: Bool) { - // 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 - // TODO: We could put this in regionIsChangingWith if we calculate significant change/debounce. - Task { @MainActor in - updateViewPort(mapView: mapView, reason: reason) - } - - guard !suppressCameraUpdatePropagation else { - return - } - - // 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) - } - } -} - -// MARK: - Private - -private extension NavigationMapViewCoordinator { - - @MainActor func updateViewPort(mapView: MLNMapView, reason: MLNCameraChangeReason) { - // Calculate the Raw "ViewPort" - let calculatedViewPort = MapViewPort( - center: mapView.centerCoordinate, - zoom: mapView.zoomLevel, - direction: mapView.direction, - lastReasonForChange: CameraChangeReason(reason) - ) - - onViewPortChanged(calculatedViewPort) - } -} diff --git a/Sources/MapLibreSwiftUI/Examples/Other.swift b/Sources/MapLibreSwiftUI/Examples/Other.swift index 3a2dadf..c0dea02 100644 --- a/Sources/MapLibreSwiftUI/Examples/Other.swift +++ b/Sources/MapLibreSwiftUI/Examples/Other.swift @@ -27,11 +27,11 @@ import SwiftUI SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) .iconImage(UIImage(systemName: "mappin")!) } - .unsafeMapViewModifier { mapView in + .unsafeMapViewControllerModifier { viewController in // Not all properties have modifiers yet. Until they do, you can use this 'escape hatch' to the underlying // MLNMapView. Be careful: if you modify properties that the DSL controls already, they may be overridden. This // modifier is a "hack", not a final function. - mapView.logoView.isHidden = false - mapView.compassViewPosition = .topLeft + viewController.mapView.logoView.isHidden = false + viewController.mapView.compassViewPosition = .topLeft } } diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index d0cfbfa..cb3affc 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -5,7 +5,8 @@ import SwiftUI public struct MapView: UIViewControllerRepresentable { public typealias UIViewControllerType = T - + var cameraDisabled: Bool = true + @Binding var camera: MapViewCamera let styleSource: MapStyleSource @@ -20,7 +21,9 @@ public struct MapView: UIViewControllerRepresentable { /// 'Escape hatch' to MLNMapView until we have more modifiers. /// See ``unsafeMapViewModifier(_:)`` - var unsafeMapViewModifier: ((MLNMapView) -> Void)? + var unsafeMapViewModifier: ((T.MapType) -> Void)? + + var unsafeMapViewControllerModifier: ((T) -> Void)? var controls: [MapControl] = [ CompassView(), @@ -54,14 +57,14 @@ public struct MapView: UIViewControllerRepresentable { public func makeUIViewController(context: Context) -> T { // Create the map view - let controller = T() + let controller = T() controller.mapView.delegate = context.coordinator context.coordinator.mapView = controller.mapView // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as // content insets can trigger a change) context.coordinator.suppressCameraUpdatePropagation = true - self.applyModifiers(controller.mapView, runUnsafe: false) + self.applyModifiers(controller, runUnsafe: false) context.coordinator.suppressCameraUpdatePropagation = false controller.mapView.locationManager = locationManager @@ -72,16 +75,16 @@ public struct MapView: UIViewControllerRepresentable { } context.coordinator.updateCamera(mapView: controller.mapView, - camera: $camera.wrappedValue, + camera: self.$camera.wrappedValue, animated: false) controller.mapView.locationManager = controller.mapView.locationManager // Link the style loaded to the coordinator that emits the delegate event. - context.coordinator.onStyleLoaded = onStyleLoaded + context.coordinator.onStyleLoaded = self.onStyleLoaded // Add all gesture recognizers for gesture in gestures { - registerGesture(controller.mapView, context, gesture: gesture) + self.registerGesture(controller.mapView, context, gesture: gesture) } return controller @@ -90,7 +93,7 @@ public struct MapView: UIViewControllerRepresentable { public func updateUIViewController(_ uiViewController: T, context: Context) { context.coordinator.parent = self - applyModifiers(uiViewController.mapView, runUnsafe: true) + self.applyModifiers(uiViewController, runUnsafe: true) // FIXME: This should be a more selective update context.coordinator.updateStyleSource(styleSource, mapView: uiViewController.mapView) @@ -99,28 +102,36 @@ public struct MapView: UIViewControllerRepresentable { // FIXME: This isn't exactly telling us if the *map* is loaded, and the docs for setCenter say it needs to be. let isStyleLoaded = uiViewController.mapView.style != nil - context.coordinator.updateCamera(mapView: uiViewController.mapView, - camera: $camera.wrappedValue, - animated: isStyleLoaded) + if self.cameraDisabled == false { + context.coordinator.updateCamera(mapView: uiViewController.mapView, + camera: self.$camera.wrappedValue, + animated: isStyleLoaded) + } } - @MainActor private func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) { - mapView.contentInset = mapViewContentInset + @MainActor private func applyModifiers(_ mapViewController: T, runUnsafe: Bool) { + mapViewController.mapView.contentInset = self.mapViewContentInset // Assume all controls are hidden by default (so that an empty list returns a map with no controls) - mapView.logoView.isHidden = true - mapView.compassView.isHidden = true - mapView.attributionButton.isHidden = true + mapViewController.mapView.logoView.isHidden = true + mapViewController.mapView.compassView.isHidden = true + mapViewController.mapView.attributionButton.isHidden = true // Apply each control configuration for control in controls { - control.configureMapView(mapView) + control.configureMapView(mapViewController.mapView) } if runUnsafe { - unsafeMapViewModifier?(mapView) + unsafeMapViewControllerModifier?(mapViewController) } } + +// func cameraModifierDisabled(_ disabled: Bool) -> Self { +// var view = self +// view.cameraDisabled = disabled +// return view +// } } #Preview { diff --git a/Sources/MapLibreSwiftUI/MapViewController.swift b/Sources/MapLibreSwiftUI/MapViewController.swift index d3b8614..0177728 100644 --- a/Sources/MapLibreSwiftUI/MapViewController.swift +++ b/Sources/MapLibreSwiftUI/MapViewController.swift @@ -7,11 +7,12 @@ import UIKit import MapLibre -import MapboxNavigation -import MapboxCoreNavigation +//import MapboxNavigation +//import MapboxCoreNavigation public protocol WrappedViewController: UIViewController { - var mapView: MLNMapView { get } + associatedtype MapType: MLNMapView + var mapView: MapType { get } } public final class MapViewController: UIViewController, WrappedViewController { @@ -24,3 +25,7 @@ public final class MapViewController: UIViewController, WrappedViewController { self.view = MLNMapView(frame: .zero) } } + +//extension NavigationViewController: WrappedViewController { +// public typealias MapType = NavigationMapView +//} diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index 8b82d14..1b093b0 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -33,16 +33,16 @@ public extension MapView { /// Example: /// ```swift /// MapView() - /// .mapViewModifier { mapView in - /// mapView.showUserLocation = true + /// .unsafeMapViewControllerModifier { controller in + /// controller.mapView.showUserLocation = true /// } /// ``` /// - func unsafeMapViewModifier(_ modifier: @escaping (MLNMapView) -> Void) -> MapView { - var newMapView = self - newMapView.unsafeMapViewModifier = modifier - return newMapView - } + func unsafeMapViewControllerModifier(_ modifier: @escaping (T) -> Void) -> MapView { + var newMapView = self + newMapView.unsafeMapViewControllerModifier = modifier + return newMapView + } // MARK: Default Gestures @@ -114,25 +114,19 @@ public extension MapView { /// - Returns: The modified MapView func expandClustersOnTapping(clusteredLayers: [ClusterLayer]) -> MapView { var newMapView = self - newMapView.clusteredLayers = clusteredLayers - return newMapView } func mapViewContentInset(_ inset: UIEdgeInsets) -> Self { var result = self - result.mapViewContentInset = inset - return result } func mapControls(@MapControlsBuilder _ buildControls: () -> [MapControl]) -> Self { var result = self - result.controls = buildControls() - return result } @@ -141,4 +135,10 @@ public extension MapView { result.onViewPortChanged = onViewPortChanged return result } + + func cameraModifierDisabled(_ disabled: Bool) -> Self { + var view = self + view.cameraDisabled = disabled + return view + } } From c1ae13b728e129f8b0e48fc7740e426c94be09e5 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Fri, 7 Jun 2024 19:03:59 +0200 Subject: [PATCH 13/23] format code --- .../MapLibreSwiftUI/Examples/Gestures.swift | 4 +- Sources/MapLibreSwiftUI/Examples/Layers.swift | 2 +- Sources/MapLibreSwiftUI/Examples/Other.swift | 4 +- .../MapLibreSwiftUI/Examples/Polyline.swift | 2 +- Sources/MapLibreSwiftUI/MapView.swift | 80 +++++++++---------- .../MapLibreSwiftUI/MapViewController.swift | 34 +++----- .../MapLibreSwiftUI/MapViewCoordinator.swift | 2 +- .../MapLibreSwiftUI/MapViewModifiers.swift | 22 ++--- .../Models/Gesture/MapGesture.swift | 4 +- .../Models/Gesture/MapGestureContext.swift | 19 +++-- .../Models/MapCamera/CameraChangeReason.swift | 2 +- .../Models/MapCamera/CameraPitchRange.swift | 2 +- 12 files changed, 81 insertions(+), 96 deletions(-) diff --git a/Sources/MapLibreSwiftUI/Examples/Gestures.swift b/Sources/MapLibreSwiftUI/Examples/Gestures.swift index 2dbb2a2..f7e8575 100644 --- a/Sources/MapLibreSwiftUI/Examples/Gestures.swift +++ b/Sources/MapLibreSwiftUI/Examples/Gestures.swift @@ -18,7 +18,7 @@ import SwiftUI .iconColor(.white) } .onTapMapGesture(on: [tappableID], onTapChanged: { _, features in - print("Tapped on \(features.first?.description ?? "")") + print("Tapped on \(features.first?.description ?? "")") }) .ignoresSafeArea(.all) } @@ -26,7 +26,7 @@ import SwiftUI #Preview("Tappable Countries") { MapView(styleURL: demoTilesURL) .onTapMapGesture(on: ["countries-fill"], onTapChanged: { _, features in - print("Tapped on \(features.first?.description ?? "")") + print("Tapped on \(features.first?.description ?? "")") }) .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index 2a2384a..7096bc9 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -124,7 +124,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c .predicate(NSPredicate(format: "cluster != YES")) } .onTapMapGesture(on: ["simple-circles-non-clusters"], onTapChanged: { _, features in - print("Tapped on \(features.first?.debugDescription ?? "")") + print("Tapped on \(features.first?.debugDescription ?? "")") }) .expandClustersOnTapping(clusteredLayers: [ClusterLayer( layerIdentifier: "simple-circles-clusters", diff --git a/Sources/MapLibreSwiftUI/Examples/Other.swift b/Sources/MapLibreSwiftUI/Examples/Other.swift index c0dea02..05aac9e 100644 --- a/Sources/MapLibreSwiftUI/Examples/Other.swift +++ b/Sources/MapLibreSwiftUI/Examples/Other.swift @@ -31,7 +31,7 @@ import SwiftUI // Not all properties have modifiers yet. Until they do, you can use this 'escape hatch' to the underlying // MLNMapView. Be careful: if you modify properties that the DSL controls already, they may be overridden. This // modifier is a "hack", not a final function. - viewController.mapView.logoView.isHidden = false - viewController.mapView.compassViewPosition = .topLeft + viewController.mapView.logoView.isHidden = false + viewController.mapView.compassViewPosition = .topLeft } } diff --git a/Sources/MapLibreSwiftUI/Examples/Polyline.swift b/Sources/MapLibreSwiftUI/Examples/Polyline.swift index 216a0d2..d202c86 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, - camera: .constant(.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. diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index cb3affc..52af840 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -4,9 +4,9 @@ import MapLibreSwiftDSL import SwiftUI public struct MapView: UIViewControllerRepresentable { - public typealias UIViewControllerType = T - var cameraDisabled: Bool = true - + public typealias UIViewControllerType = T + var cameraDisabled: Bool = true + @Binding var camera: MapViewCamera let styleSource: MapStyleSource @@ -21,9 +21,9 @@ public struct MapView: UIViewControllerRepresentable { /// 'Escape hatch' to MLNMapView until we have more modifiers. /// See ``unsafeMapViewModifier(_:)`` - var unsafeMapViewModifier: ((T.MapType) -> Void)? - - var unsafeMapViewControllerModifier: ((T) -> Void)? + var unsafeMapViewModifier: ((T.MapType) -> Void)? + + var unsafeMapViewControllerModifier: ((T) -> Void)? var controls: [MapControl] = [ CompassView(), @@ -55,87 +55,81 @@ public struct MapView: UIViewControllerRepresentable { ) } - public func makeUIViewController(context: Context) -> T { + public func makeUIViewController(context: Context) -> T { // Create the map view - let controller = T() - controller.mapView.delegate = context.coordinator - context.coordinator.mapView = controller.mapView + let controller = T() + controller.mapView.delegate = context.coordinator + context.coordinator.mapView = controller.mapView // Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as // content insets can trigger a change) context.coordinator.suppressCameraUpdatePropagation = true - self.applyModifiers(controller, runUnsafe: false) + applyModifiers(controller, runUnsafe: false) context.coordinator.suppressCameraUpdatePropagation = false - controller.mapView.locationManager = locationManager + controller.mapView.locationManager = locationManager switch styleSource { case let .url(styleURL): - controller.mapView.styleURL = styleURL + controller.mapView.styleURL = styleURL } - context.coordinator.updateCamera(mapView: controller.mapView, - camera: self.$camera.wrappedValue, + context.coordinator.updateCamera(mapView: controller.mapView, + camera: $camera.wrappedValue, animated: false) - controller.mapView.locationManager = controller.mapView.locationManager + controller.mapView.locationManager = controller.mapView.locationManager // Link the style loaded to the coordinator that emits the delegate event. - context.coordinator.onStyleLoaded = self.onStyleLoaded + context.coordinator.onStyleLoaded = onStyleLoaded // Add all gesture recognizers for gesture in gestures { - self.registerGesture(controller.mapView, context, gesture: gesture) + registerGesture(controller.mapView, context, gesture: gesture) } - return controller + return controller } - public func updateUIViewController(_ uiViewController: T, context: Context) { + public func updateUIViewController(_ uiViewController: T, context: Context) { context.coordinator.parent = self - self.applyModifiers(uiViewController, runUnsafe: true) + applyModifiers(uiViewController, runUnsafe: true) // FIXME: This should be a more selective update - context.coordinator.updateStyleSource(styleSource, mapView: uiViewController.mapView) - context.coordinator.updateLayers(mapView: uiViewController.mapView) + context.coordinator.updateStyleSource(styleSource, mapView: uiViewController.mapView) + context.coordinator.updateLayers(mapView: uiViewController.mapView) // FIXME: This isn't exactly telling us if the *map* is loaded, and the docs for setCenter say it needs to be. - let isStyleLoaded = uiViewController.mapView.style != nil + let isStyleLoaded = uiViewController.mapView.style != nil - if self.cameraDisabled == false { - context.coordinator.updateCamera(mapView: uiViewController.mapView, - camera: self.$camera.wrappedValue, - animated: isStyleLoaded) - } + if cameraDisabled == false { + context.coordinator.updateCamera(mapView: uiViewController.mapView, + camera: $camera.wrappedValue, + animated: isStyleLoaded) + } } - @MainActor private func applyModifiers(_ mapViewController: T, runUnsafe: Bool) { - mapViewController.mapView.contentInset = self.mapViewContentInset + @MainActor private func applyModifiers(_ mapViewController: T, runUnsafe: Bool) { + mapViewController.mapView.contentInset = mapViewContentInset // Assume all controls are hidden by default (so that an empty list returns a map with no controls) - mapViewController.mapView.logoView.isHidden = true - mapViewController.mapView.compassView.isHidden = true - mapViewController.mapView.attributionButton.isHidden = true + mapViewController.mapView.logoView.isHidden = true + mapViewController.mapView.compassView.isHidden = true + mapViewController.mapView.attributionButton.isHidden = true // Apply each control configuration for control in controls { - control.configureMapView(mapViewController.mapView) + control.configureMapView(mapViewController.mapView) } if runUnsafe { - unsafeMapViewControllerModifier?(mapViewController) + unsafeMapViewControllerModifier?(mapViewController) } } - -// func cameraModifierDisabled(_ disabled: Bool) -> Self { -// var view = self -// view.cameraDisabled = disabled -// return view -// } } #Preview { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .ignoresSafeArea(.all) .previewDisplayName("Vanilla Map") diff --git a/Sources/MapLibreSwiftUI/MapViewController.swift b/Sources/MapLibreSwiftUI/MapViewController.swift index 0177728..c07ae72 100644 --- a/Sources/MapLibreSwiftUI/MapViewController.swift +++ b/Sources/MapLibreSwiftUI/MapViewController.swift @@ -1,31 +1,17 @@ -// -// MapViewController.swift -// -// -// Created by Patrick Kladek on 05.06.24. -// - -import UIKit import MapLibre -//import MapboxNavigation -//import MapboxCoreNavigation +import UIKit public protocol WrappedViewController: UIViewController { - associatedtype MapType: MLNMapView - var mapView: MapType { get } + associatedtype MapType: MLNMapView + var mapView: MapType { get } } public final class MapViewController: UIViewController, WrappedViewController { - - public var mapView: MLNMapView { - return self.view as! MLNMapView - } - - override public func loadView() { - self.view = MLNMapView(frame: .zero) - } -} + public var mapView: MLNMapView { + view as! MLNMapView + } -//extension NavigationViewController: WrappedViewController { -// public typealias MapType = NavigationMapView -//} + override public func loadView() { + view = MLNMapView(frame: .zero) + } +} diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 287d9aa..6c6d64b 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -297,7 +297,7 @@ public class MapViewCoordinator: NSObject, MLNMapViewD } } - // MARK: - MLNMapViewDelegate + // MARK: - MLNMapViewDelegate public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) { addLayers(to: mglStyle) diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index 1b093b0..c0a1a23 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -38,11 +38,11 @@ public extension MapView { /// } /// ``` /// - func unsafeMapViewControllerModifier(_ modifier: @escaping (T) -> Void) -> MapView { - var newMapView = self - newMapView.unsafeMapViewControllerModifier = modifier - return newMapView - } + func unsafeMapViewControllerModifier(_ modifier: @escaping (T) -> Void) -> MapView { + var newMapView = self + newMapView.unsafeMapViewControllerModifier = modifier + return newMapView + } // MARK: Default Gestures @@ -135,10 +135,10 @@ public extension MapView { result.onViewPortChanged = onViewPortChanged return result } - - func cameraModifierDisabled(_ disabled: Bool) -> Self { - var view = self - view.cameraDisabled = disabled - return view - } + + func cameraModifierDisabled(_ disabled: Bool) -> Self { + var view = self + view.cameraDisabled = disabled + return view + } } diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift index 5a2e783..e0c1cea 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift @@ -17,7 +17,7 @@ public class MapGesture: NSObject { } /// The Gesture's method, this is used to register it for the correct user interaction on the MapView. - public let method: Method + public let method: Method /// The onChange action that runs when the gesture changes on the map view. public let onChange: GestureAction @@ -30,7 +30,7 @@ public class MapGesture: NSObject { /// - Parameters: /// - method: The gesture recognizer method /// - onChange: The action to perform when the gesture is changed - public init(method: Method, onChange: GestureAction) { + public init(method: Method, onChange: GestureAction) { self.method = method self.onChange = onChange } diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift index 3e3a531..8c87213 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift @@ -14,11 +14,16 @@ public struct MapGestureContext { /// The underlying geographic coordinate at the point of the gesture. public let coordinate: CLLocationCoordinate2D - - public init(gestureMethod: MapGesture.Method, state: UIGestureRecognizer.State, point: CGPoint, coordinate: CLLocationCoordinate2D) { - self.gestureMethod = gestureMethod - self.state = state - self.point = point - self.coordinate = coordinate - } + + public init( + gestureMethod: MapGesture.Method, + state: UIGestureRecognizer.State, + point: CGPoint, + coordinate: CLLocationCoordinate2D + ) { + self.gestureMethod = gestureMethod + self.state = state + self.point = point + self.coordinate = coordinate + } } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift index 2268ea8..4212e0b 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -19,7 +19,7 @@ public enum CameraChangeReason: Hashable { /// If you need a full history of the full bit range, use MLNCameraChangeReason directly /// /// - Parameter mlnCameraChangeReason: The camera change reason options list from the MapLibre MapViewDelegate - public init?(_ mlnCameraChangeReason: MLNCameraChangeReason) { + public init?(_ mlnCameraChangeReason: MLNCameraChangeReason) { switch mlnCameraChangeReason.largestBitwiseReason { case .programmatic: self = .programmatic diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift index 2f29e79..11315bc 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitchRange.swift @@ -15,7 +15,7 @@ public enum CameraPitchRange: Hashable, Sendable { /// The range of acceptable pitch values. /// /// This is applied to the map view on camera updates. - public var rangeValue: ClosedRange { + public var rangeValue: ClosedRange { switch self { case .free: 0 ... 60 // TODO: set this to a maplibre constant (this is available on Android, but maybe not iOS)? From 0346e36145c564382c609e72cf5e5e119ad560f4 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Mon, 10 Jun 2024 13:49:19 +0200 Subject: [PATCH 14/23] fix tests --- .../Examples/LayerPreviewTests.swift | 10 +++++----- .../Examples/MapControlsTests.swift | 14 +++++++------- .../MapView/MapViewGestureTests.swift | 2 +- .../MapViewCoordinatorCameraTests.swift | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift index 862c7de..167dc10 100644 --- a/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift +++ b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift @@ -25,7 +25,7 @@ final class LayerPreviewTests: XCTestCase { func testRoseTint() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Silly example: a background layer on top of everything to create a tint effect BackgroundLayer(identifier: "rose-colored-glasses") .backgroundColor(.systemPink.withAlphaComponent(0.3)) @@ -36,7 +36,7 @@ final class LayerPreviewTests: XCTestCase { func testSimpleSymbol() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) .iconImage(UIImage(systemName: "mappin")!) @@ -46,7 +46,7 @@ final class LayerPreviewTests: XCTestCase { func testRotatedSymbolConst() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) .iconImage(UIImage(systemName: "location.north.circle.fill")!) @@ -57,7 +57,7 @@ final class LayerPreviewTests: XCTestCase { func testRotatedSymboleDynamic() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) .iconImage(UIImage(systemName: "location.north.circle.fill")!) @@ -68,7 +68,7 @@ final class LayerPreviewTests: XCTestCase { func testCirclesWithSymbols() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon CircleStyleLayer(identifier: "simple-circles", source: pointSource) .radius(16) diff --git a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift index 4389911..4d40b7c 100644 --- a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift +++ b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift @@ -6,7 +6,7 @@ import XCTest final class MapControlsTests: XCTestCase { func testEmptyControls() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { // No controls } @@ -15,7 +15,7 @@ final class MapControlsTests: XCTestCase { func testLogoOnly() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { LogoView() } @@ -24,7 +24,7 @@ final class MapControlsTests: XCTestCase { func testLogoChangePosition() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { LogoView() .position(.topLeft) @@ -34,7 +34,7 @@ final class MapControlsTests: XCTestCase { func testCompassOnly() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { CompassView() } @@ -43,7 +43,7 @@ final class MapControlsTests: XCTestCase { func testCompassChangePosition() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { CompassView() .position(.topLeft) @@ -53,7 +53,7 @@ final class MapControlsTests: XCTestCase { func testAttributionOnly() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { AttributionButton() } @@ -62,7 +62,7 @@ final class MapControlsTests: XCTestCase { func testAttributionChangePosition() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { AttributionButton() .position(.topLeft) diff --git a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift index 1332d24..031f972 100644 --- a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -5,7 +5,7 @@ import XCTest final class MapViewGestureTests: XCTestCase { let maplibreMapView = MLNMapView() - let mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) + let mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) // MARK: Gesture View Modifiers diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index aad4f21..890f262 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -5,8 +5,8 @@ import XCTest final class MapViewCoordinatorCameraTests: XCTestCase { var maplibreMapView: MockMLNMapViewCameraUpdating! - var mapView: MapView! - var coordinator: MapView.Coordinator! + var mapView: MapView! + var coordinator: MapView.Coordinator! override func setUp() async throws { maplibreMapView = MockMLNMapViewCameraUpdating() From 13a541abefa3908ab90748f0fd6799dee19b3ba3 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Tue, 11 Jun 2024 18:18:23 +0200 Subject: [PATCH 15/23] allow injecting custom initialiser --- Sources/MapLibreSwiftUI/MapView.swift | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 52af840..c85442e 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -9,6 +9,7 @@ public struct MapView: UIViewControllerRepresentable { @Binding var camera: MapViewCamera + let makeViewController: (() -> T)? let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] @@ -36,16 +37,31 @@ public struct MapView: UIViewControllerRepresentable { var clusteredLayers: [ClusterLayer]? public init( + makeViewController: @autoclosure @escaping () -> T, styleURL: URL, camera: Binding = .constant(.default()), locationManager: MLNLocationManager? = nil, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { + self.makeViewController = makeViewController styleSource = .url(styleURL) _camera = camera userLayers = makeMapContent() self.locationManager = locationManager } + + public init( + styleURL: URL, + camera: Binding = .constant(.default()), + locationManager: MLNLocationManager? = nil, + @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } + ) { + self.makeViewController = nil + styleSource = .url(styleURL) + _camera = camera + userLayers = makeMapContent() + self.locationManager = locationManager + } public func makeCoordinator() -> MapViewCoordinator { MapViewCoordinator( @@ -57,7 +73,7 @@ public struct MapView: UIViewControllerRepresentable { public func makeUIViewController(context: Context) -> T { // Create the map view - let controller = T() + let controller = self.makeViewController?() ?? T() controller.mapView.delegate = context.coordinator context.coordinator.mapView = controller.mapView @@ -129,7 +145,7 @@ public struct MapView: UIViewControllerRepresentable { } #Preview { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .ignoresSafeArea(.all) .previewDisplayName("Vanilla Map") From 013ef568181af3419dc95348dba9c97f59826632 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Tue, 11 Jun 2024 18:21:07 +0200 Subject: [PATCH 16/23] run linter --- Sources/MapLibreSwiftUI/MapView.swift | 36 +++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index c85442e..7e9394a 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -9,7 +9,7 @@ public struct MapView: UIViewControllerRepresentable { @Binding var camera: MapViewCamera - let makeViewController: (() -> T)? + let makeViewController: (() -> T)? let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] @@ -37,31 +37,31 @@ public struct MapView: UIViewControllerRepresentable { var clusteredLayers: [ClusterLayer]? public init( - makeViewController: @autoclosure @escaping () -> T, + makeViewController: @autoclosure @escaping () -> T, styleURL: URL, camera: Binding = .constant(.default()), locationManager: MLNLocationManager? = nil, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { - self.makeViewController = makeViewController + self.makeViewController = makeViewController + styleSource = .url(styleURL) + _camera = camera + userLayers = makeMapContent() + self.locationManager = locationManager + } + + public init( + styleURL: URL, + camera: Binding = .constant(.default()), + locationManager: MLNLocationManager? = nil, + @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } + ) { + makeViewController = nil styleSource = .url(styleURL) _camera = camera userLayers = makeMapContent() self.locationManager = locationManager } - - public init( - styleURL: URL, - camera: Binding = .constant(.default()), - locationManager: MLNLocationManager? = nil, - @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } - ) { - self.makeViewController = nil - styleSource = .url(styleURL) - _camera = camera - userLayers = makeMapContent() - self.locationManager = locationManager - } public func makeCoordinator() -> MapViewCoordinator { MapViewCoordinator( @@ -73,7 +73,7 @@ public struct MapView: UIViewControllerRepresentable { public func makeUIViewController(context: Context) -> T { // Create the map view - let controller = self.makeViewController?() ?? T() + let controller = makeViewController?() ?? T() controller.mapView.delegate = context.coordinator context.coordinator.mapView = controller.mapView @@ -145,7 +145,7 @@ public struct MapView: UIViewControllerRepresentable { } #Preview { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .ignoresSafeArea(.all) .previewDisplayName("Vanilla Map") From 565da2399b23d84c0d5a93285d0182c1309a4a2b Mon Sep 17 00:00:00 2001 From: PW Date: Mon, 8 Jul 2024 13:13:16 +0200 Subject: [PATCH 17/23] generic init implementation to keep cleaner callsite --- Sources/MapLibreSwiftUI/Examples/Camera.swift | 2 +- .../MapLibreSwiftUI/Examples/Gestures.swift | 4 +- Sources/MapLibreSwiftUI/Examples/Layers.swift | 14 +++---- Sources/MapLibreSwiftUI/Examples/Other.swift | 2 +- .../MapLibreSwiftUI/Examples/Polyline.swift | 2 +- .../Examples/User Location.swift | 4 +- Sources/MapLibreSwiftUI/MapView.swift | 39 +++++++++++-------- .../Examples/LayerPreviewTests.swift | 15 ++++--- .../Examples/MapControlsTests.swift | 22 +++++++---- .../MapView/MapViewGestureTests.swift | 4 +- .../MapViewCoordinatorCameraTests.swift | 1 + 11 files changed, 66 insertions(+), 43 deletions(-) diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index 3853e03..a7f0f11 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -9,7 +9,7 @@ struct CameraDirectManipulationPreview: View { var targetCameraAfterDelay: MapViewCamera? = nil var body: some View { - MapView(styleURL: styleURL, camera: $camera) + MapView(styleURL: styleURL, camera: $camera) .onStyleLoaded { _ in onStyleLoaded?() } diff --git a/Sources/MapLibreSwiftUI/Examples/Gestures.swift b/Sources/MapLibreSwiftUI/Examples/Gestures.swift index f7e8575..a2f0d38 100644 --- a/Sources/MapLibreSwiftUI/Examples/Gestures.swift +++ b/Sources/MapLibreSwiftUI/Examples/Gestures.swift @@ -5,7 +5,7 @@ import SwiftUI #Preview("Tappable Circles") { let tappableID = "simple-circles" - return MapView(styleURL: demoTilesURL) { + return MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon CircleStyleLayer(identifier: tappableID, source: pointSource) .radius(16) @@ -24,7 +24,7 @@ import SwiftUI } #Preview("Tappable Countries") { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .onTapMapGesture(on: ["countries-fill"], onTapChanged: { _, features in print("Tapped on \(features.first?.description ?? "")") }) diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index 7096bc9..bbd40fd 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -34,7 +34,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Rose Tint") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Silly example: a background layer on top of everything to create a tint effect BackgroundLayer(identifier: "rose-colored-glasses") .backgroundColor(.systemPink.withAlphaComponent(0.3)) @@ -44,7 +44,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Simple Symbol") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) .iconImage(UIImage(systemName: "mappin")!) @@ -53,7 +53,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Rotated Symbols (Const)") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) .iconImage(UIImage(systemName: "location.north.circle.fill")!) @@ -63,7 +63,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Rotated Symbols (Dynamic)") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) .iconImage(UIImage(systemName: "location.north.circle.fill")!) @@ -73,7 +73,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c } #Preview("Circles with Symbols") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon CircleStyleLayer(identifier: "simple-circles", source: pointSource) .radius(16) @@ -94,7 +94,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c zoom: 5, direction: 0 ) - return MapView(styleURL: demoTilesURL, camera: $camera) { + return MapView(styleURL: demoTilesURL, camera: $camera) { // Clusters pins when they would touch // Cluster == YES shows only those pins that are clustered, using .text @@ -135,7 +135,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c // TODO: Fixme // #Preview("Multiple Symbol Icons") { -// MapView(styleURL: demoTilesURL) { +// MapView(styleURL: demoTilesURL) { // // Simple symbol layer demonstration with an icon // SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) // .iconImage(attribute: "icon", diff --git a/Sources/MapLibreSwiftUI/Examples/Other.swift b/Sources/MapLibreSwiftUI/Examples/Other.swift index 05aac9e..5c76bd9 100644 --- a/Sources/MapLibreSwiftUI/Examples/Other.swift +++ b/Sources/MapLibreSwiftUI/Examples/Other.swift @@ -4,7 +4,7 @@ import MapLibreSwiftDSL import SwiftUI #Preview("Unsafe MapView Modifier") { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // 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 d202c86..20e22ce 100644 --- a/Sources/MapLibreSwiftUI/Examples/Polyline.swift +++ b/Sources/MapLibreSwiftUI/Examples/Polyline.swift @@ -7,7 +7,7 @@ struct PolylinePreview: View { let styleURL: URL var body: some View { - MapView(styleURL: styleURL, + MapView(styleURL: styleURL, camera: .constant(.center(samplePedestrianWaypoints.first!, zoom: 14))) { // Note: This line does not add the source to the style as if it diff --git a/Sources/MapLibreSwiftUI/Examples/User Location.swift b/Sources/MapLibreSwiftUI/Examples/User Location.swift index 3c1e580..b30b575 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( + MapView( styleURL: demoTilesURL, camera: .constant(.trackUserLocation(zoom: 4, pitch: 45)), locationManager: locationManager @@ -24,7 +24,7 @@ private let locationManager = StaticLocationManager(initialLocation: CLLocation( } #Preview("Track user location with Course") { - MapView( + MapView( styleURL: demoTilesURL, camera: .constant(.trackUserLocationWithCourse(zoom: 4, pitch: 45)), locationManager: locationManager diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 7e9394a..c9e72da 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -9,7 +9,7 @@ public struct MapView: UIViewControllerRepresentable { @Binding var camera: MapViewCamera - let makeViewController: (() -> T)? + let makeViewController: (() -> T) let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] @@ -50,19 +50,6 @@ public struct MapView: UIViewControllerRepresentable { self.locationManager = locationManager } - public init( - styleURL: URL, - camera: Binding = .constant(.default()), - locationManager: MLNLocationManager? = nil, - @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } - ) { - makeViewController = nil - styleSource = .url(styleURL) - _camera = camera - userLayers = makeMapContent() - self.locationManager = locationManager - } - public func makeCoordinator() -> MapViewCoordinator { MapViewCoordinator( parent: self, @@ -73,7 +60,7 @@ public struct MapView: UIViewControllerRepresentable { public func makeUIViewController(context: Context) -> T { // Create the map view - let controller = makeViewController?() ?? T() + let controller = makeViewController() controller.mapView.delegate = context.coordinator context.coordinator.mapView = controller.mapView @@ -144,8 +131,28 @@ public struct MapView: UIViewControllerRepresentable { } } +public extension MapView where T == MapViewController { + + @MainActor + init( + styleURL: URL, + camera: Binding = .constant(.default()), + locationManager: MLNLocationManager? = nil, + @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } + ) { + makeViewController = { + return MapViewController() + + } + styleSource = .url(styleURL) + _camera = camera + userLayers = makeMapContent() + self.locationManager = locationManager + } +} + #Preview { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .ignoresSafeArea(.all) .previewDisplayName("Vanilla Map") diff --git a/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift index 167dc10..b1e98cb 100644 --- a/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift +++ b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift @@ -23,9 +23,10 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testRoseTint() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Silly example: a background layer on top of everything to create a tint effect BackgroundLayer(identifier: "rose-colored-glasses") .backgroundColor(.systemPink.withAlphaComponent(0.3)) @@ -34,9 +35,10 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testSimpleSymbol() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) .iconImage(UIImage(systemName: "mappin")!) @@ -44,9 +46,10 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testRotatedSymbolConst() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) .iconImage(UIImage(systemName: "location.north.circle.fill")!) @@ -55,9 +58,10 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testRotatedSymboleDynamic() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) .iconImage(UIImage(systemName: "location.north.circle.fill")!) @@ -66,9 +70,10 @@ final class LayerPreviewTests: XCTestCase { } } + @MainActor func testCirclesWithSymbols() { assertView { - MapView(styleURL: demoTilesURL) { + MapView(styleURL: demoTilesURL) { // Simple symbol layer demonstration with an icon CircleStyleLayer(identifier: "simple-circles", source: pointSource) .radius(16) diff --git a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift index 4d40b7c..dc3c8ea 100644 --- a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift +++ b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift @@ -4,27 +4,31 @@ import XCTest @testable import MapLibreSwiftUI final class MapControlsTests: XCTestCase { + + @MainActor func testEmptyControls() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { // No controls } } } + @MainActor func testLogoOnly() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { LogoView() } } } + @MainActor func testLogoChangePosition() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { LogoView() .position(.topLeft) @@ -32,18 +36,20 @@ final class MapControlsTests: XCTestCase { } } + @MainActor func testCompassOnly() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { CompassView() } } } + @MainActor func testCompassChangePosition() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { CompassView() .position(.topLeft) @@ -51,18 +57,20 @@ final class MapControlsTests: XCTestCase { } } + @MainActor func testAttributionOnly() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { AttributionButton() } } } + @MainActor func testAttributionChangePosition() { assertView { - MapView(styleURL: demoTilesURL) + MapView(styleURL: demoTilesURL) .mapControls { AttributionButton() .position(.topLeft) diff --git a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift index 031f972..9b17c8b 100644 --- a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -5,7 +5,9 @@ import XCTest final class MapViewGestureTests: XCTestCase { let maplibreMapView = MLNMapView() - let mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) + + @MainActor + let mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) // MARK: Gesture View Modifiers diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index 890f262..3cbff2b 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -8,6 +8,7 @@ final class MapViewCoordinatorCameraTests: XCTestCase { var mapView: MapView! var coordinator: MapView.Coordinator! + @MainActor override func setUp() async throws { maplibreMapView = MockMLNMapViewCameraUpdating() given(maplibreMapView).frame.willReturn(.zero) From 5ed30bd60644237e6fb5c7698a92a130c637f8ad Mon Sep 17 00:00:00 2001 From: PW Date: Mon, 8 Jul 2024 13:22:25 +0200 Subject: [PATCH 18/23] linting --- Sources/MapLibreSwiftUI/Examples/Polyline.swift | 2 +- Sources/MapLibreSwiftUI/MapView.swift | 6 ++---- Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift | 1 - .../MapLibreSwiftUITests/MapView/MapViewGestureTests.swift | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Sources/MapLibreSwiftUI/Examples/Polyline.swift b/Sources/MapLibreSwiftUI/Examples/Polyline.swift index 20e22ce..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, - camera: .constant(.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. diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index c9e72da..3e51f55 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -9,7 +9,7 @@ public struct MapView: UIViewControllerRepresentable { @Binding var camera: MapViewCamera - let makeViewController: (() -> T) + let makeViewController: () -> T let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] @@ -132,7 +132,6 @@ public struct MapView: UIViewControllerRepresentable { } public extension MapView where T == MapViewController { - @MainActor init( styleURL: URL, @@ -141,8 +140,7 @@ public extension MapView where T == MapViewController { @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { makeViewController = { - return MapViewController() - + MapViewController() } styleSource = .url(styleURL) _camera = camera diff --git a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift index dc3c8ea..529dfec 100644 --- a/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift +++ b/Tests/MapLibreSwiftUITests/Examples/MapControlsTests.swift @@ -4,7 +4,6 @@ import XCTest @testable import MapLibreSwiftUI final class MapControlsTests: XCTestCase { - @MainActor func testEmptyControls() { assertView { diff --git a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift index 9b17c8b..047ee49 100644 --- a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -5,7 +5,7 @@ import XCTest final class MapViewGestureTests: XCTestCase { let maplibreMapView = MLNMapView() - + @MainActor let mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) From 5ab5390242aafb85b27e00870fd51610635c6b4b Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Mon, 15 Jul 2024 12:02:00 +0200 Subject: [PATCH 19/23] code review --- Sources/MapLibreSwiftUI/MapView.swift | 2 +- Sources/MapLibreSwiftUI/MapViewController.swift | 4 ++-- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 2 +- Sources/MapLibreSwiftUI/MapViewModifiers.swift | 5 ++++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 3e51f55..8987b4d 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -3,7 +3,7 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -public struct MapView: UIViewControllerRepresentable { +public struct MapView: UIViewControllerRepresentable { public typealias UIViewControllerType = T var cameraDisabled: Bool = true diff --git a/Sources/MapLibreSwiftUI/MapViewController.swift b/Sources/MapLibreSwiftUI/MapViewController.swift index c07ae72..ff443b1 100644 --- a/Sources/MapLibreSwiftUI/MapViewController.swift +++ b/Sources/MapLibreSwiftUI/MapViewController.swift @@ -1,12 +1,12 @@ import MapLibre import UIKit -public protocol WrappedViewController: UIViewController { +public protocol MapViewHostViewController: UIViewController { associatedtype MapType: MLNMapView var mapView: MapType { get } } -public final class MapViewController: UIViewController, WrappedViewController { +public final class MapViewController: UIViewController, MapViewHostViewController { public var mapView: MLNMapView { view as! MLNMapView } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 6c6d64b..52051df 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -2,7 +2,7 @@ import Foundation import MapLibre import MapLibreSwiftDSL -public class MapViewCoordinator: NSObject, MLNMapViewDelegate { +public class MapViewCoordinator: NSObject, MLNMapViewDelegate { // This must be weak, the UIViewRepresentable owns the MLNMapView. weak var mapView: MLNMapView? var parent: MapView diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index c0a1a23..07f91a4 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -135,7 +135,10 @@ public extension MapView { result.onViewPortChanged = onViewPortChanged return result } - + + /// Prevent Maplibre-DSL from updating the camera, useful when the underlying ViewController is managing the camera, for example during navigation when Maplibre-Navigation is used. + /// - Parameter disabled: if true, prevents Maplibre-DSL from updating the camera + /// - Returns: The modified MapView func cameraModifierDisabled(_ disabled: Bool) -> Self { var view = self view.cameraDisabled = disabled From ebea66833428ee4df0701ab40795683c76c25268 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Mon, 15 Jul 2024 12:32:23 +0200 Subject: [PATCH 20/23] add maplibre-navigation instructs to readme --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.md b/README.md index 4c03021..9edfdc8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ can move fast without breaking anything important. * Overlays * Dynamic styling * Camera control / animation?? + * Navigation 2. Prevent most common classes of mistakes that users make with the lower level APIs (ex: adding the same source twice) 3. Deeper SwiftUI integration (ex: SwiftUI callout views) @@ -46,6 +47,45 @@ Then, for each target add either the DSL (for just the DSL) or both (for the Swi Check out the (super basic) [previews at the bottom of MapView.swift](Sources/MapLibreSwiftUI/MapView.swift) or more detailed [Examples](Sources/MapLibreSwiftUI/Examples) to see how it works in practice. +## Navigation + +If you need to support navigation add https://github.com/HudHud-Maps/maplibre-navigation-ios.git to your Package.swift and add this code: + +```swift +import MapboxCoreNavigation +import MapboxNavigation + +extension NavigationViewController: MapViewHostViewController { + public typealias MapType = NavigationMapView +} + + +@State var route: Route? +@State var navigationInProgress: Bool = false + +@ViewBuilder +var mapView: some View { + MapView(makeViewController: NavigationViewController(dayStyleURL: self.styleURL), styleURL: self.styleURL, camera: self.$mapStore.camera) { + + } + .unsafeMapViewControllerModifier { navigationViewController in + navigationViewController.delegate = self.mapStore + if let route = self.route, self.navigationInProgress == false { + let locationManager = SimulatedLocationManager(route: route) + navigationViewController.startNavigation(with: route, locationManager: locationManager) + self.navigationInProgress = true + } else if self.route == nil, self.navigationInProgress == true { + navigationViewController.endNavigation() + self.navigationInProgress = false + } + + navigationViewController.mapView.showsUserLocation = self.showUserLocation && self.mapStore.streetView == .disabled + } + .cameraModifierDisabled(self.route != nil) +} +``` +We choose this approach so MapLibreSwiftUI is not depdending on maplibre-navigation as most users don't need it. + ## Developer Quick Start This project uses [`swiftformat`](https://github.com/nicklockwood/SwiftFormat) to automatically handle basic swift formatting From aa3f51afc4f153707d0dcd2f93e6c7bc302fb19e Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Mon, 15 Jul 2024 12:39:12 +0200 Subject: [PATCH 21/23] swiftformat --- Sources/MapLibreSwiftUI/MapViewModifiers.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index 07f91a4..29a309b 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -135,10 +135,11 @@ public extension MapView { result.onViewPortChanged = onViewPortChanged return result } - - /// Prevent Maplibre-DSL from updating the camera, useful when the underlying ViewController is managing the camera, for example during navigation when Maplibre-Navigation is used. - /// - Parameter disabled: if true, prevents Maplibre-DSL from updating the camera - /// - Returns: The modified MapView + + /// Prevent Maplibre-DSL from updating the camera, useful when the underlying ViewController is managing the camera, + /// for example during navigation when Maplibre-Navigation is used. + /// - Parameter disabled: if true, prevents Maplibre-DSL from updating the camera + /// - Returns: The modified MapView func cameraModifierDisabled(_ disabled: Bool) -> Self { var view = self view.cameraDisabled = disabled From 18e34ac058347e427c87c0ba8c778100870f9f4f Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Mon, 15 Jul 2024 12:44:47 +0200 Subject: [PATCH 22/23] rename to MLNMapViewController --- .../{MapViewController.swift => MLNMapViewController.swift} | 2 +- Sources/MapLibreSwiftUI/MapView.swift | 4 ++-- .../MapViewCoordinator/MapViewCoordinatorCameraTests.swift | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename Sources/MapLibreSwiftUI/{MapViewController.swift => MLNMapViewController.swift} (78%) diff --git a/Sources/MapLibreSwiftUI/MapViewController.swift b/Sources/MapLibreSwiftUI/MLNMapViewController.swift similarity index 78% rename from Sources/MapLibreSwiftUI/MapViewController.swift rename to Sources/MapLibreSwiftUI/MLNMapViewController.swift index ff443b1..65f3903 100644 --- a/Sources/MapLibreSwiftUI/MapViewController.swift +++ b/Sources/MapLibreSwiftUI/MLNMapViewController.swift @@ -6,7 +6,7 @@ public protocol MapViewHostViewController: UIViewController { var mapView: MapType { get } } -public final class MapViewController: UIViewController, MapViewHostViewController { +public final class MLNMapViewController: UIViewController, MapViewHostViewController { public var mapView: MLNMapView { view as! MLNMapView } diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 8987b4d..1a3a286 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -131,7 +131,7 @@ public struct MapView: UIViewControllerRepresentab } } -public extension MapView where T == MapViewController { +public extension MapView where T == MLNMapViewController { @MainActor init( styleURL: URL, @@ -140,7 +140,7 @@ public extension MapView where T == MapViewController { @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { makeViewController = { - MapViewController() + MLNMapViewController() } styleSource = .url(styleURL) _camera = camera diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index 3cbff2b..47efe30 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -5,8 +5,8 @@ import XCTest final class MapViewCoordinatorCameraTests: XCTestCase { var maplibreMapView: MockMLNMapViewCameraUpdating! - var mapView: MapView! - var coordinator: MapView.Coordinator! + var mapView: MapView! + var coordinator: MapView.Coordinator! @MainActor override func setUp() async throws { From aece53f80653d33e703218e88e42b0925e1fbae6 Mon Sep 17 00:00:00 2001 From: Patrick Kladek Date: Mon, 15 Jul 2024 12:46:17 +0200 Subject: [PATCH 23/23] remove unused modifier --- Sources/MapLibreSwiftUI/MapView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 1a3a286..8ed40eb 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -20,10 +20,6 @@ public struct MapView: UIViewControllerRepresentab public var mapViewContentInset: UIEdgeInsets = .zero - /// 'Escape hatch' to MLNMapView until we have more modifiers. - /// See ``unsafeMapViewModifier(_:)`` - var unsafeMapViewModifier: ((T.MapType) -> Void)? - var unsafeMapViewControllerModifier: ((T) -> Void)? var controls: [MapControl] = [