From 5cdf002c9c30fd871b5925a18ae400d198653d0f Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Thu, 1 Feb 2024 22:06:17 -0800 Subject: [PATCH 01/15] Revisions for camera behavior, added basic gesture methods, separated out core MapView --- .../MapLibreSwiftUI/Examples/Polyline.swift | 3 +- .../CoreLocation/CLLocationCoordinate2D.swift | 15 ++ .../MapLibre/MLNCameraChangeReason.swift | 39 +++ .../MapLibreSwiftUI/MapView Modifiers.swift | 9 - Sources/MapLibreSwiftUI/MapView.swift | 243 +++++------------- .../MapLibreSwiftUI/MapViewCoordinator.swift | 194 ++++++++++++++ .../MapLibreSwiftUI/MapViewModifiers.swift | 60 +++++ .../Models/Gesture/MapGesture.swift | 19 ++ .../Models/Gesture/MapGestureContext.swift | 17 ++ .../Models/MapCamera/CameraChangeReason.swift | 50 ++++ .../Models/MapCamera/CameraState.swift | 29 +-- .../Models/MapCamera/MapViewCamera.swift | 95 ++++++- 12 files changed, 548 insertions(+), 225 deletions(-) create mode 100644 Sources/MapLibreSwiftUI/Extensions/CoreLocation/CLLocationCoordinate2D.swift create mode 100644 Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift delete mode 100644 Sources/MapLibreSwiftUI/MapView Modifiers.swift create mode 100644 Sources/MapLibreSwiftUI/MapViewCoordinator.swift create mode 100644 Sources/MapLibreSwiftUI/MapViewModifiers.swift create mode 100644 Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift create mode 100644 Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift create mode 100644 Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift diff --git a/Sources/MapLibreSwiftUI/Examples/Polyline.swift b/Sources/MapLibreSwiftUI/Examples/Polyline.swift index 32372c5..849cdde 100644 --- a/Sources/MapLibreSwiftUI/Examples/Polyline.swift +++ b/Sources/MapLibreSwiftUI/Examples/Polyline.swift @@ -7,7 +7,8 @@ struct PolylinePreview: View { let styleURL: URL var body: some View { - MapView(styleURL: styleURL, initialCamera: MapViewCamera.center(samplePedestrianWaypoints.first!, zoom: 14)) { + MapView(styleURL: styleURL, + constantCamera: .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. // The source is added automatically if a layer references it. diff --git a/Sources/MapLibreSwiftUI/Extensions/CoreLocation/CLLocationCoordinate2D.swift b/Sources/MapLibreSwiftUI/Extensions/CoreLocation/CLLocationCoordinate2D.swift new file mode 100644 index 0000000..0046099 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/CoreLocation/CLLocationCoordinate2D.swift @@ -0,0 +1,15 @@ +import CoreLocation + +// TODO: We can delete chat about this. I'm not 100% on it, even though I want Hashable +// on the MapCameraView (so we can let a user present a MapView with a designated camera from NavigationLink) +extension CLLocationCoordinate2D: Hashable { + public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { + return lhs.latitude == rhs.latitude + && lhs.longitude == rhs.longitude + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(latitude) + hasher.combine(longitude) + } +} diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift new file mode 100644 index 0000000..a40afbf --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift @@ -0,0 +1,39 @@ +import Foundation +import MapLibre + +extension MLNCameraChangeReason: CustomDebugStringConvertible { + public var debugDescription: String { + switch self.lastValue { + + case .programmatic: return ".programmatic" + case .resetNorth: return ".resetNorth" + case .gesturePan: return ".gesturePan" + case .gesturePinch: return ".gesturePinch" + case .gestureRotate: return ".gestureRotate" + case .gestureZoomIn: return ".gestureZoomIn" + case .gestureZoomOut: return ".gestureZoomOut" + case .gestureOneFingerZoom: return ".gestureOneFingerZoom" + case .gestureTilt: return ".gestureTilt" + case .transitionCancelled: return ".transitionCancelled" + default: return "none" + } + } + + /// Get the last value from the MLNCameraChangeReason option set. + public var lastValue: MLNCameraChangeReason { + // Start at 1 + var mask: UInt = 1 + var result: UInt = 0 + + while mask <= self.rawValue { + // If the raw value matches the remaining mask. + if self.rawValue & mask != 0 { + result = mask + } + // Shift all the way until the rawValue has been allocated and we have the true last value. + mask <<= 1 + } + + return MLNCameraChangeReason(rawValue: result) + } +} diff --git a/Sources/MapLibreSwiftUI/MapView Modifiers.swift b/Sources/MapLibreSwiftUI/MapView Modifiers.swift deleted file mode 100644 index fb77600..0000000 --- a/Sources/MapLibreSwiftUI/MapView Modifiers.swift +++ /dev/null @@ -1,9 +0,0 @@ -// This file contains modifiers that are internal and specific to the MapView. -// They are not intended to be exposed directly in the public interface. - -import Foundation -import SwiftUI - -extension MapView { - // Placeholder -} diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 5364a98..e69e48a 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -5,14 +5,15 @@ import MapLibreSwiftDSL public struct MapView: UIViewRepresentable { - public private(set) var camera: Binding + @Binding var camera: MapViewCamera let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] + var gestures = [MapGesture]() /// 'Escape hatch' to MLNMapView until we have more modifiers. /// See ``unsafeMapViewModifier(_:)`` - private var unsafeMapViewModifier: ((MLNMapView) -> Void)? + var unsafeMapViewModifier: ((MLNMapView) -> Void)? public init( styleURL: URL, @@ -20,190 +21,32 @@ public struct MapView: UIViewRepresentable { @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { self.styleSource = .url(styleURL) - self.camera = camera - + self._camera = camera userLayers = makeMapContent() } public init( styleURL: URL, - initialCamera: MapViewCamera, + constantCamera: MapViewCamera, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { - self.init(styleURL: styleURL, camera: .constant(initialCamera), makeMapContent) + self.init(styleURL: styleURL, + camera: .constant(constantCamera), + makeMapContent) } - /// 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 - /// } - /// ``` - /// - public func unsafeMapViewModifier(_ modifier: @escaping (MLNMapView) -> Void) -> MapView { - var newMapView = self - newMapView.unsafeMapViewModifier = modifier - return newMapView + public func makeCoordinator() -> MapViewCoordinator { + MapViewCoordinator( + parent: self, + onGesture: { processGesture($0, $1) } + ) } - - public class Coordinator: NSObject, MLNMapViewDelegate { - var parent: MapView - - // 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? - - init(parent: MapView) { - self.parent = parent - } - - // MARK: - MLNMapViewDelegate - - public func mapView(_ mapView: MLNMapView, didFinishLoading mglStyle: MLNStyle) { - addLayers(to: mglStyle) - } - - func updateStyleSource(_ source: MapStyleSource, mapView: MLNMapView) { - switch (source, parent.styleSource) { - case (.url(let newURL), .url(let oldURL)): - if newURL != oldURL { - mapView.styleURL = newURL - } - } - } - - public func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { - DispatchQueue.main.async { - self.parent.camera.wrappedValue = .center(mapView.centerCoordinate, - zoom: mapView.zoomLevel) - } - } - - // MARK: - Coordinator API - - func updateCamera(mapView: MLNMapView, camera: MapViewCamera, animated: Bool) { - guard camera != snapshotCamera else { - // No action - camera has not changed. - return - } - - mapView.setCenter(camera.coordinate, - zoomLevel: camera.zoom, - direction: camera.course, - animated: animated) - - snapshotCamera = camera - } - - 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 .above(layerID: let 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 .below(layerID: let 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) - } - } - } - } - + public func makeUIView(context: Context) -> MLNMapView { // Create the map view let mapView = MLNMapView(frame: .zero) mapView.delegate = context.coordinator + context.coordinator.mapView = mapView switch styleSource { case .url(let styleURL): @@ -211,24 +54,36 @@ public struct MapView: UIViewRepresentable { } context.coordinator.updateCamera(mapView: mapView, - camera: camera.wrappedValue, + camera: $camera.wrappedValue, animated: false) // TODO: Make this settable via a modifier mapView.logoView.isHidden = true - + + // Gesture recogniser setup + let tapGesture = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(context.coordinator.captureGesture(_:)) + ) + mapView.addGestureRecognizer(tapGesture) + + let longPressGesture = UILongPressGestureRecognizer( + target: context.coordinator, + action: #selector(context.coordinator.captureGesture(_:)) + ) + mapView.addGestureRecognizer(longPressGesture) + return mapView } - - public func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - + public func updateUIView(_ mapView: MLNMapView, context: Context) { context.coordinator.parent = self + // MARK: Modifiers unsafeMapViewModifier?(mapView) + // MARK: End Modifiers + // FIXME: This should be a more selective update context.coordinator.updateStyleSource(styleSource, mapView: mapView) context.coordinator.updateLayers(mapView: mapView) @@ -237,9 +92,37 @@ public struct MapView: UIViewRepresentable { let isStyleLoaded = mapView.style != nil context.coordinator.updateCamera(mapView: mapView, - camera: camera.wrappedValue, + camera: $camera.wrappedValue, animated: isStyleLoaded) } + + private func processGesture(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) { + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + + switch sender { + case is UITapGestureRecognizer: + for gesture in gestures.filter({ $0.method == .tap }) { + gesture.action( + MapGestureContext(gesture: gesture.method, + point: point, + coordinate: coordinate, + numberOfTaps: sender.numberOfTouches) + ) + } + case is UILongPressGestureRecognizer: + for gesture in gestures.filter({ $0.method == .longPress }) { + gesture.action( + MapGestureContext(gesture: gesture.method, + point: point, + coordinate: coordinate, + numberOfTaps: sender.numberOfTouches) + ) + } + default: + print("Log unhandled gesture") + } + } } struct MapView_Previews: PreviewProvider { diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift new file mode 100644 index 0000000..ac34a77 --- /dev/null +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -0,0 +1,194 @@ +import Foundation +import MapLibre +import MapLibreSwiftDSL + +public class MapViewCoordinator: NSObject { + + // This must be weak, the UIViewRepresentable owns the MLNMapView. + weak var mapView: MLNMapView? + var parent: MapView + + // 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 onGesture: (MLNMapView, UIGestureRecognizer) -> Void + + init(parent: MapView, + onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void) { + self.parent = parent + self.onGesture = onGesture + } + + // MARK: Core UIView Functionality + + @objc func captureGesture(_ sender: UIGestureRecognizer) { + guard let mapView else { + return + } + + onGesture(mapView, sender) + } + + // MARK: - Coordinator API - Camera + Manipulation + + func updateCamera(mapView: MLNMapView, camera: MapViewCamera, animated: Bool) { + guard camera != snapshotCamera else { + // No action - camera has not changed. + return + } + + switch camera.state { + case .centered: + mapView.userTrackingMode = .none + mapView.setCenter(camera.coordinate, + zoomLevel: camera.zoom, + direction: camera.course, + animated: animated) + case .trackingUserLocation: + mapView.userTrackingMode = .follow + mapView.setZoomLevel(camera.zoom, animated: false) + case .trackingUserLocationWithHeading: + mapView.userTrackingMode = .followWithHeading + mapView.setZoomLevel(camera.zoom, animated: false) + case .trackingUserLocationWithCourse: + mapView.userTrackingMode = .followWithCourse + mapView.setZoomLevel(camera.zoom, animated: false) + case .rect, .showcase: + // TODO: Need a method these/or to finalize a goal here. + break + } + + if let pitch = camera.pitch { + mapView.minimumPitch = pitch + mapView.maximumPitch = pitch + } else { + mapView.minimumPitch = 0 + mapView.maximumPitch = 90 + } + + snapshotCamera = camera + } + + // MARK: - Coordinator API - Styles + Layers + + func updateStyleSource(_ source: MapStyleSource, mapView: MLNMapView) { + switch (source, parent.styleSource) { + case (.url(let newURL), .url(let oldURL)): + if newURL != oldURL { + mapView.styleURL = newURL + } + } + } + + 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 .above(layerID: let 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 .below(layerID: let 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 MapViewCoordinator: MLNMapViewDelegate { + + public func mapView(_ mapView: MLNMapView, didFinishLoading mglStyle: MLNStyle) { + addLayers(to: mglStyle) + } + + /// The MapView's region has changed with a specific reason. + public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { + let isFollowing = parent.camera.state == .trackingUserLocation && mapView.userTrackingMode == .follow + let isFollowingHeading = parent.camera.state == .trackingUserLocationWithHeading && mapView.userTrackingMode == .followWithHeading + let isFollowingCourse = parent.camera.state == .trackingUserLocationWithCourse && mapView.userTrackingMode == .followWithCourse + + if isFollowing || isFollowingHeading || isFollowingCourse { + // User tracking, we can ignore camera updates until we unset this. + return + } + + parent.camera = .center(mapView.centerCoordinate, + zoom: mapView.zoomLevel, + reason: CameraChangeReason(reason)) + } +} diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift new file mode 100644 index 0000000..b2ce161 --- /dev/null +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -0,0 +1,60 @@ +// This file contains modifiers that are internal and specific to the MapView. +// They are not intended to be exposed directly in the public interface. + +import Foundation +import SwiftUI +import MapLibre + +extension MapView { + /// 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 + /// } + /// ``` + /// + public func unsafeMapViewModifier(_ modifier: @escaping (MLNMapView) -> Void) -> MapView { + var newMapView = self + newMapView.unsafeMapViewModifier = modifier + return newMapView + } + + // MARK: Default Gestures + + public func onTapMapGesture(onTapChanged: @escaping (MapGestureContext) -> Void) -> MapView { + var newMapView = self + + // Build the gesture and link it to the map view. + let gesture = MapGesture(method: .tap, action: onTapChanged) + newMapView.gestures.append(gesture) + + return newMapView + } + + public func onLongPressMapGesture(onPressChanged: @escaping (MapGestureContext) -> Void) -> MapView { + var newMapView = self + + // Build the gesture and link it to the map view. + let gesture = MapGesture(method: .longPress, action: onPressChanged) + newMapView.gestures.append(gesture) + + return newMapView + } +} diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift new file mode 100644 index 0000000..16f8ead --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct MapGesture { + + public enum Method: Equatable { + + /// A standard tap gesture (UITapGestureRecognizer) + case tap + + /// A standard long press gesture (UILongPressGestureRecognizer) + case longPress + } + + /// The Gesture's method, this is used to register it for the correct user interaction on the MapView. + let method: Method + + /// The action that runs when the gesture is triggered from the map view. + let action: (MapGestureContext) -> Void +} diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift new file mode 100644 index 0000000..e2f67ee --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift @@ -0,0 +1,17 @@ +import Foundation +import CoreLocation + +public struct MapGestureContext { + + /// The map gesture that produced the context. + public let gesture: MapGesture.Method + + /// The location that the gesture occured on the screen. + public let point: CGPoint + + /// The underlying geographic coordinate at the point of the gesture. + public let coordinate: CLLocationCoordinate2D + + /// The number of taps (of a tap gesture) + public let numberOfTaps: Int? +} diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift new file mode 100644 index 0000000..c732efe --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -0,0 +1,50 @@ +import Foundation +import MapLibre + +public enum CameraChangeReason: Hashable, Codable { + case programmatic + case resetNorth + case gesturePan + case gesturePinch + case gestureRotate + case gestureZoomIn + case gestureZoomOut + case gestureOneFingerZoom + case gestureTilt + case transitionCancelled + + /// Initialize a Swift CameraChangeReason from the MLN NSOption. + /// + /// This method will only show the last reason. 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) { + switch mlnCameraChangeReason.lastValue { + + case .programmatic: + self = .programmatic + case .resetNorth: + self = .resetNorth + case .gesturePan: + self = .gesturePan + case .gesturePinch: + self = .gesturePinch + case .gestureRotate: + self = .gestureRotate + case .gestureZoomIn: + self = .gestureZoomIn + case .gestureZoomOut: + self = .gestureZoomOut + case .gestureOneFingerZoom: + self = .gestureOneFingerZoom + case .gestureTilt: + self = .gestureTilt + case .transitionCancelled: + self = .transitionCancelled + default: + // TODO: MR Review Note - we could also have an "initial" value for an unset camera. + return nil + } + } +} diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index 3860508..5370971 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -2,36 +2,23 @@ import Foundation import MapLibre /// The CameraState is used to understand the current context of the MapView's camera. -public enum CameraState { +public enum CameraState: Hashable, Codable { /// Centered on a coordinate case centered - /// The camera is currently following a location provider. + /// Follow the user's location using the MapView's internal camera. case trackingUserLocation + /// Follow the user's location using the MapView's internal camera with the user's heading. + case trackingUserLocationWithHeading + + /// Follow the user's location using the MapView's internal camera with the users' course + case trackingUserLocationWithCourse + /// Centered on a bounding box/rectangle. case rect /// Showcasing a GeoJSON/Polygon case showcase } - -extension CameraState: Equatable { - - public static func ==(lhs: CameraState, rhs: CameraState) -> Bool { - switch (lhs, rhs) { - - case (.centered, .centered): - return true - case (.trackingUserLocation, .trackingUserLocation): - return true - case (.rect, .rect): - return true - case (.showcase, .showcase): - return true - default: - return false - } - } -} diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift index 0787544..505bd6d 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -1,14 +1,28 @@ import Foundation import CoreLocation +import MapLibre -public struct MapViewCamera { +public struct MapViewCamera: Hashable { + + public struct Defaults { + public static let coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0) + public static let zoom: Double = 10 + public static let pitch: Double? = nil + public static let course: Double = 0 + } public var state: CameraState public var coordinate: CLLocationCoordinate2D public var zoom: Double - public var pitch: Double + public var pitch: Double? public var course: CLLocationDirection + /// The reason the camera was changed. + /// + /// This can be used to see if the camera programmatically moved, + /// or manipulated through a user gesture. + public var lastReasonForChange: CameraChangeReason? + /// A camera centered at 0.0, 0.0. This is typically used as a backup, /// pre-load for an expected camera update (e.g. before a location provider produces /// it's first location). @@ -16,10 +30,11 @@ public struct MapViewCamera { /// - Returns: The constructed MapViewCamera. public static func `default`() -> MapViewCamera { return MapViewCamera(state: .centered, - coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0), - zoom: 10, - pitch: 90, - course: 0) + coordinate: Defaults.coordinate, + zoom: Defaults.zoom, + pitch: Defaults.pitch, + course: Defaults.course, + lastReasonForChange: .programmatic) } /// Center the map on a specific location. @@ -32,25 +47,76 @@ public struct MapViewCamera { /// - Returns: The constructed MapViewCamera. public static func center(_ coordinate: CLLocationCoordinate2D, zoom: Double, - pitch: Double = 90.0, - course: Double = 0) -> MapViewCamera { + pitch: Double? = Defaults.pitch, + course: Double = Defaults.course, + reason: CameraChangeReason? = nil) -> MapViewCamera { return MapViewCamera(state: .centered, coordinate: coordinate, zoom: zoom, pitch: pitch, - course: course) + course: course, + lastReasonForChange: reason) } - public static func trackUserLocation(_ location: CLLocation, - zoom: Double, - pitch: Double = 90.0) -> MapViewCamera { + /// Enables user location tracking within the MapView. + /// + /// This feature uses the MLNMapView's userTrackingMode = .follow + /// + /// - Parameters: + /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike pitch. + /// - pitch: Provide a fixed pitch value. The user will not be able to adjust pitch using gestures when this is set. Use nil/default to allow user control. + /// - Returns: The MapViewCamera representing the scenario + public static func trackUserLocation(zoom: Double = Defaults.zoom, + pitch: Double? = Defaults.pitch) -> MapViewCamera { + // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. return MapViewCamera(state: .trackingUserLocation, - coordinate: location.coordinate, + coordinate: Defaults.coordinate, + zoom: zoom, + pitch: pitch, + course: Defaults.course, + lastReasonForChange: .programmatic) + } + + /// Enables user location tracking within the MapView. + /// + /// This feature uses the MLNMapView's userTrackingMode = .follow + /// + /// - Parameters: + /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike pitch. + /// - pitch: Provide a fixed pitch value. The user will not be able to adjust pitch using gestures when this is set. Use nil/default to allow user control. + /// - Returns: The MapViewCamera representing the scenario + public static func trackUserLocationWithHeading(zoom: Double = Defaults.zoom, + pitch: Double? = Defaults.pitch) -> MapViewCamera { + + // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. + return MapViewCamera(state: .trackingUserLocationWithHeading, + coordinate: Defaults.coordinate, + zoom: zoom, + pitch: pitch, + course: Defaults.course, + lastReasonForChange: .programmatic) + } + + /// Enables user location tracking within the MapView. + /// + /// This feature uses the MLNMapView's userTrackingMode = .follow + /// + /// - Parameters: + /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike pitch. + /// - pitch: Provide a fixed pitch value. The user will not be able to adjust pitch using gestures when this is set. Use nil/default to allow user control. + /// - Returns: The MapViewCamera representing the scenario + public static func trackUserLocationWithCourse(zoom: Double = Defaults.zoom, + pitch: Double? = Defaults.pitch) -> MapViewCamera { + + // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. + return MapViewCamera(state: .trackingUserLocationWithCourse, + coordinate: Defaults.coordinate, zoom: zoom, pitch: pitch, - course: location.course) + course: Defaults.course, + lastReasonForChange: .programmatic) } // TODO: Create init methods for other camera states once supporting materials are understood (e.g. BoundingBox) @@ -65,5 +131,6 @@ extension MapViewCamera: Equatable { && lhs.zoom == rhs.zoom && lhs.pitch == rhs.pitch && lhs.course == rhs.course + && lhs.lastReasonForChange == rhs.lastReasonForChange } } From 8e4c1037a66c6f1d177fa8e648a72e9f9f9c49ec Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sat, 3 Feb 2024 10:22:41 -0800 Subject: [PATCH 02/15] Added additional logic for builders --- Sources/MapLibreSwiftDSL/Data Sources.swift | 13 +++++++++++++ .../MapLibreSwiftDSL/MapViewContentBuilder.swift | 9 +++++++++ Sources/MapLibreSwiftUI/MapView.swift | 15 +++++++++++++-- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 10 +++++----- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Sources/MapLibreSwiftDSL/Data Sources.swift b/Sources/MapLibreSwiftDSL/Data Sources.swift index 561d20b..988af55 100644 --- a/Sources/MapLibreSwiftDSL/Data Sources.swift +++ b/Sources/MapLibreSwiftDSL/Data Sources.swift @@ -70,6 +70,19 @@ public enum ShapeDataBuilder { return components.flatMap { $0 } } + // Handle if statements + public static func buildEither(first components: [MLNShape]) -> [MLNShape] { + return components + } + + public static func buildEither(second components: [MLNShape]) -> [MLNShape] { + return components + } + + public static func buildOptional(_ components: [MLNShape]?) -> [MLNShape] { + return components ?? [] + } + // Convert the collected MLNShape array to ShapeData public static func buildFinalResult(_ components: [MLNShape]) -> ShapeData { let features = components.compactMap { $0 as? MLNShape & MLNFeature } diff --git a/Sources/MapLibreSwiftDSL/MapViewContentBuilder.swift b/Sources/MapLibreSwiftDSL/MapViewContentBuilder.swift index 7062896..70965c0 100644 --- a/Sources/MapLibreSwiftDSL/MapViewContentBuilder.swift +++ b/Sources/MapLibreSwiftDSL/MapViewContentBuilder.swift @@ -23,6 +23,15 @@ public enum MapViewContentBuilder { return styleCollection.layers } + // Handle an array of MLNShape (if you want to directly pass arrays) + public static func buildArray(_ layer: [StyleLayerDefinition]) -> [StyleLayerDefinition] { + return layer + } + + // Handle for in of MLNShape + public static func buildArray(_ layer: [[StyleLayerDefinition]]) -> [StyleLayerDefinition] { + return layer.flatMap { $0 } + } public static func buildEither(first layer: [StyleLayerDefinition]) -> [StyleLayerDefinition] { return layer diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index e69e48a..6e792b4 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -38,7 +38,7 @@ public struct MapView: UIViewRepresentable { public func makeCoordinator() -> MapViewCoordinator { MapViewCoordinator( parent: self, - onGesture: { processGesture($0, $1) } + onGestureEnd: { processGestureEnd($0, $1) } ) } @@ -96,7 +96,18 @@ public struct MapView: UIViewRepresentable { animated: isStyleLoaded) } - private func processGesture(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) { + /// Runs on gesture ended. + /// + /// Note: Some gestures may need additional behaviors for different gesture.states. + /// + /// - Parameters: + /// - mapView: The MapView emitting the gesture. This is used to calculate the point and coordinate of the gesture. + /// - sender: The UIGestureRecognizer + private func processGestureEnd(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) { + guard sender.state == .ended else { + return + } + let point = sender.location(in: mapView) let coordinate = mapView.convert(point, toCoordinateFrom: mapView) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index ac34a77..330595d 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -12,22 +12,22 @@ public class MapViewCoordinator: NSObject { // every update cycle so we can avoid unnecessary updates private var snapshotUserLayers: [StyleLayerDefinition] = [] private var snapshotCamera: MapViewCamera? - private var onGesture: (MLNMapView, UIGestureRecognizer) -> Void + private var onGestureEnd: (MLNMapView, UIGestureRecognizer) -> Void init(parent: MapView, - onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void) { + onGestureEnd: @escaping (MLNMapView, UIGestureRecognizer) -> Void) { self.parent = parent - self.onGesture = onGesture + self.onGestureEnd = onGestureEnd } // MARK: Core UIView Functionality @objc func captureGesture(_ sender: UIGestureRecognizer) { - guard let mapView else { + guard let mapView, sender.state == .ended else { return } - onGesture(mapView, sender) + onGestureEnd(mapView, sender) } // MARK: - Coordinator API - Camera + Manipulation From 00ff5e3bbe2e45189631efe2c98517fb3a1d42cd Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Mon, 5 Feb 2024 22:08:31 -0800 Subject: [PATCH 03/15] Improvements based on feedback for gestures and similar --- .../MapLibreSwiftUI-Package.xcscheme | 10 +++ Package.resolved | 9 +++ Package.swift | 10 ++- .../MapViewContentBuilder.swift | 47 +++++++------- ...a Sources.swift => ShapeDataBuilder.swift} | 11 ++-- .../Support/DefaultResultBuilder.swift | 39 +++++++++++ Sources/MapLibreSwiftUI/Examples/Camera.swift | 2 +- .../MapLibre/MLNCameraChangeReason.swift | 18 +----- .../Extensions/MapView/MapViewGestures.swift | 64 +++++++++++++++++++ Sources/MapLibreSwiftUI/MapView.swift | 59 ++--------------- .../MapLibreSwiftUI/MapViewCoordinator.swift | 26 ++++---- .../MapLibreSwiftUI/MapViewModifiers.swift | 19 ++++-- .../Models/Gesture/MapGesture.swift | 31 +++++++-- .../Models/Gesture/MapGestureContext.swift | 13 ++-- .../Models/MapCamera/CameraChangeReason.swift | 2 +- .../Models/MapCamera/CameraPitch.swift | 30 +++++++++ .../Models/MapCamera/CameraState.swift | 36 +++++++++-- .../Models/MapCamera/MapViewCamera.swift | 60 +++++++---------- .../MapCamera/CameraChangeReasonTests.swift | 12 ++++ .../Models/MapCamera/CameraPitchTests.swift | 23 +++++++ .../Models/MapCamera/CameraStateTests.swift | 36 +++++++++++ .../Models/MapCamera/MapViewCameraTests.swift | 6 ++ 22 files changed, 388 insertions(+), 175 deletions(-) rename Sources/MapLibreSwiftDSL/{Data Sources.swift => ShapeDataBuilder.swift} (92%) create mode 100644 Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift create mode 100644 Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift create mode 100644 Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift create mode 100644 Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftUI-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftUI-Package.xcscheme index 6ccb3d7..afee237 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftUI-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftUI-Package.xcscheme @@ -77,6 +77,16 @@ ReferencedContainer = "container:"> + + + + [StyleLayerDefinition] { - return layers.flatMap { $0 } - } - - public static func buildOptional(_ layers: [StyleLayerDefinition]?) -> [StyleLayerDefinition] { - return layers ?? [] +public enum MapViewContentBuilder: DefaultResultBuilder { + public static func buildExpression(_ expression: StyleLayerDefinition) -> [StyleLayerDefinition] { + return [expression] } - public static func buildExpression(_ layer: StyleLayerDefinition) -> [StyleLayerDefinition] { - return [layer] + public static func buildExpression(_ expression: [StyleLayerDefinition]) -> [StyleLayerDefinition] { + return expression } public static func buildExpression(_ expression: Void) -> [StyleLayerDefinition] { return [] } - public static func buildExpression(_ styleCollection: StyleLayerCollection) -> [StyleLayerDefinition] { - return styleCollection.layers + public static func buildBlock(_ components: [StyleLayerDefinition]...) -> [StyleLayerDefinition] { + return components.flatMap { $0 } + } + + public static func buildArray(_ components: [StyleLayerDefinition]) -> [StyleLayerDefinition] { + return components + } + + public static func buildArray(_ components: [[StyleLayerDefinition]]) -> [StyleLayerDefinition] { + return components.flatMap { $0 } } - // Handle an array of MLNShape (if you want to directly pass arrays) - public static func buildArray(_ layer: [StyleLayerDefinition]) -> [StyleLayerDefinition] { - return layer + public static func buildEither(first components: [StyleLayerDefinition]) -> [StyleLayerDefinition] { + return components } - // Handle for in of MLNShape - public static func buildArray(_ layer: [[StyleLayerDefinition]]) -> [StyleLayerDefinition] { - return layer.flatMap { $0 } + public static func buildEither(second components: [StyleLayerDefinition]) -> [StyleLayerDefinition] { + return components } - public static func buildEither(first layer: [StyleLayerDefinition]) -> [StyleLayerDefinition] { - return layer + public static func buildOptional(_ components: [StyleLayerDefinition]?) -> [StyleLayerDefinition] { + return components ?? [] } - public static func buildEither(second layer: [StyleLayerDefinition]) -> [StyleLayerDefinition] { - return layer + // MARK: Custom Handler for StyleLayerCollection type. + + public static func buildExpression(_ styleCollection: StyleLayerCollection) -> [StyleLayerDefinition] { + return styleCollection.layers } } diff --git a/Sources/MapLibreSwiftDSL/Data Sources.swift b/Sources/MapLibreSwiftDSL/ShapeDataBuilder.swift similarity index 92% rename from Sources/MapLibreSwiftDSL/Data Sources.swift rename to Sources/MapLibreSwiftDSL/ShapeDataBuilder.swift index 988af55..1b64c04 100644 --- a/Sources/MapLibreSwiftDSL/Data Sources.swift +++ b/Sources/MapLibreSwiftDSL/ShapeDataBuilder.swift @@ -45,8 +45,7 @@ public struct ShapeSource: Source { @resultBuilder -public enum ShapeDataBuilder { - // Handle a single MLNShape element +public enum ShapeDataBuilder: DefaultResultBuilder { public static func buildExpression(_ expression: MLNShape) -> [MLNShape] { return [expression] } @@ -55,22 +54,22 @@ public enum ShapeDataBuilder { return expression } - // Combine elements into an array + public static func buildExpression(_ expression: Void) -> [MLNShape] { + return [] + } + public static func buildBlock(_ components: [MLNShape]...) -> [MLNShape] { return components.flatMap { $0 } } - // Handle an array of MLNShape (if you want to directly pass arrays) public static func buildArray(_ components: [MLNShape]) -> [MLNShape] { return components } - // Handle for in of MLNShape public static func buildArray(_ components: [[MLNShape]]) -> [MLNShape] { return components.flatMap { $0 } } - // Handle if statements public static func buildEither(first components: [MLNShape]) -> [MLNShape] { return components } diff --git a/Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift b/Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift new file mode 100644 index 0000000..43aa44f --- /dev/null +++ b/Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Enforces a basic set of result builder definiitons. +/// +/// This is just a tool to make a result builder easier to build, maintain sorting, etc. +public protocol DefaultResultBuilder { + + associatedtype Component + + static func buildExpression(_ expression: Component) -> [Component] + + static func buildExpression(_ expression: [Component]) -> [Component] + + // MARK: Handle void + + static func buildExpression(_ expression: Void) -> [Component] + + // MARK: Combine elements into an array + + static func buildBlock(_ components: [Component]...) -> [Component] + + // MARK: Handle Arrays + + static func buildArray(_ components: [Component]) -> [Component] + + // MARK: Handle for in loops + + static func buildArray(_ components: [[Component]]) -> [Component] + + // MARK: Handle if statements + + static func buildEither(first components: [Component]) -> [Component] + + static func buildEither(second components: [Component]) -> [Component] + + // MARK: Handle Optionals + + static func buildOptional(_ components: [Component]?) -> [Component] +} diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index 3ab37ff..5bfec4e 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -11,7 +11,7 @@ struct CameraDirectManipulationPreview: View { var body: some View { MapView(styleURL: styleURL, camera: $camera) .overlay(alignment: .bottom, content: { - Text("\(camera.coordinate.latitude), \(camera.coordinate.longitude) z \(camera.zoom)") + Text("\(String(describing: camera.state)) z \(camera.zoom)") .padding() .foregroundColor(.white) .background( diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift index a40afbf..6230bfb 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift @@ -1,23 +1,7 @@ import Foundation import MapLibre -extension MLNCameraChangeReason: CustomDebugStringConvertible { - public var debugDescription: String { - switch self.lastValue { - - case .programmatic: return ".programmatic" - case .resetNorth: return ".resetNorth" - case .gesturePan: return ".gesturePan" - case .gesturePinch: return ".gesturePinch" - case .gestureRotate: return ".gestureRotate" - case .gestureZoomIn: return ".gestureZoomIn" - case .gestureZoomOut: return ".gestureZoomOut" - case .gestureOneFingerZoom: return ".gestureOneFingerZoom" - case .gestureTilt: return ".gestureTilt" - case .transitionCancelled: return ".transitionCancelled" - default: return "none" - } - } +extension MLNCameraChangeReason { /// Get the last value from the MLNCameraChangeReason option set. public var lastValue: MLNCameraChangeReason { diff --git a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift new file mode 100644 index 0000000..f113599 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift @@ -0,0 +1,64 @@ +import Foundation +import MapLibre + +extension MapView { + + /// Register a gesture recognizer on the MapView. + /// + /// - Parameters: + /// - mapView: The MLNMapView that will host the gesture itself. + /// - context: The UIViewRepresentable context that will orchestrate the response sender + /// - gesture: The gesture definition. + func registerGesture(_ mapView: MLNMapView, _ context: Context, gesture: MapGesture) { + switch gesture.method { + + case .tap(numberOfTaps: let numberOfTaps): + let gestureRecognizer = UITapGestureRecognizer(target: context.coordinator, + action: #selector(context.coordinator.captureGesture(_:))) + gestureRecognizer.numberOfTapsRequired = numberOfTaps + mapView.addGestureRecognizer(gestureRecognizer) + gesture.gestureRecognizer = gestureRecognizer + + case .longPress(minimumDuration: let minimumDuration): + let gestureRecognizer = UILongPressGestureRecognizer(target: context.coordinator, + action: #selector(context.coordinator.captureGesture(_:))) + gestureRecognizer.minimumPressDuration = minimumDuration + + mapView.addGestureRecognizer(gestureRecognizer) + gesture.gestureRecognizer = gestureRecognizer + } + } + + /// Runs on each gesture change event and filters the appropriate gesture behavior based on the + /// user definition. + /// + /// Since the gestures run "onChange", we run this every time, event when state changes. The implementer is responsible for guarding + /// and handling whatever state logic they want. + /// + /// - Parameters: + /// - mapView: The MapView emitting the gesture. This is used to calculate the point and coordinate of the gesture. + /// - sender: The UIGestureRecognizer + func processGesture(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) { + guard let gesture = self.gestures.first(where: { $0.gestureRecognizer == sender }) else { + assertionFailure("\(sender) is not a registered UIGestureRecongizer on the MapView") + return + } + + // Build the context of the gesture's event. + var point: CGPoint + switch gesture.method { + + case .tap(numberOfTaps: let numberOfTaps): + point = sender.location(ofTouch: numberOfTaps - 1, in: mapView) + case .longPress: + point = sender.location(in: mapView) + } + + let context = MapGestureContext(gestureMethod: gesture.method, + state: sender.state, + point: point, + coordinate: mapView.convert(point, toCoordinateFrom: mapView)) + + gesture.onChange(context) + } +} diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index 6e792b4..da9d840 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -38,7 +38,7 @@ public struct MapView: UIViewRepresentable { public func makeCoordinator() -> MapViewCoordinator { MapViewCoordinator( parent: self, - onGestureEnd: { processGestureEnd($0, $1) } + onGesture: { processGesture($0, $1) } ) } @@ -60,19 +60,11 @@ public struct MapView: UIViewRepresentable { // TODO: Make this settable via a modifier mapView.logoView.isHidden = true - // Gesture recogniser setup - let tapGesture = UITapGestureRecognizer( - target: context.coordinator, - action: #selector(context.coordinator.captureGesture(_:)) - ) - mapView.addGestureRecognizer(tapGesture) - - let longPressGesture = UILongPressGestureRecognizer( - target: context.coordinator, - action: #selector(context.coordinator.captureGesture(_:)) - ) - mapView.addGestureRecognizer(longPressGesture) - + // Add all gesture recognizers + for gesture in gestures { + registerGesture(mapView, context, gesture: gesture) + } + return mapView } @@ -95,45 +87,6 @@ public struct MapView: UIViewRepresentable { camera: $camera.wrappedValue, animated: isStyleLoaded) } - - /// Runs on gesture ended. - /// - /// Note: Some gestures may need additional behaviors for different gesture.states. - /// - /// - Parameters: - /// - mapView: The MapView emitting the gesture. This is used to calculate the point and coordinate of the gesture. - /// - sender: The UIGestureRecognizer - private func processGestureEnd(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) { - guard sender.state == .ended else { - return - } - - let point = sender.location(in: mapView) - let coordinate = mapView.convert(point, toCoordinateFrom: mapView) - - switch sender { - case is UITapGestureRecognizer: - for gesture in gestures.filter({ $0.method == .tap }) { - gesture.action( - MapGestureContext(gesture: gesture.method, - point: point, - coordinate: coordinate, - numberOfTaps: sender.numberOfTouches) - ) - } - case is UILongPressGestureRecognizer: - for gesture in gestures.filter({ $0.method == .longPress }) { - gesture.action( - MapGestureContext(gesture: gesture.method, - point: point, - coordinate: coordinate, - numberOfTaps: sender.numberOfTouches) - ) - } - default: - print("Log unhandled gesture") - } - } } struct MapView_Previews: PreviewProvider { diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 330595d..c96f894 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -12,22 +12,22 @@ public class MapViewCoordinator: NSObject { // every update cycle so we can avoid unnecessary updates private var snapshotUserLayers: [StyleLayerDefinition] = [] private var snapshotCamera: MapViewCamera? - private var onGestureEnd: (MLNMapView, UIGestureRecognizer) -> Void + private var onGesture: (MLNMapView, UIGestureRecognizer) -> Void init(parent: MapView, - onGestureEnd: @escaping (MLNMapView, UIGestureRecognizer) -> Void) { + onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void) { self.parent = parent - self.onGestureEnd = onGestureEnd + self.onGesture = onGesture } // MARK: Core UIView Functionality @objc func captureGesture(_ sender: UIGestureRecognizer) { - guard let mapView, sender.state == .ended else { + guard let mapView else { return } - onGestureEnd(mapView, sender) + onGesture(mapView, sender) } // MARK: - Coordinator API - Camera + Manipulation @@ -39,11 +39,11 @@ public class MapViewCoordinator: NSObject { } switch camera.state { - case .centered: + case .centered(let coordinate): mapView.userTrackingMode = .none - mapView.setCenter(camera.coordinate, + mapView.setCenter(coordinate, zoomLevel: camera.zoom, - direction: camera.course, + direction: camera.direction, animated: animated) case .trackingUserLocation: mapView.userTrackingMode = .follow @@ -59,13 +59,9 @@ public class MapViewCoordinator: NSObject { break } - if let pitch = camera.pitch { - mapView.minimumPitch = pitch - mapView.maximumPitch = pitch - } else { - mapView.minimumPitch = 0 - mapView.maximumPitch = 90 - } + // Set the correct pitch range. + mapView.minimumPitch = camera.pitch.rangeValue.lowerBound + mapView.maximumPitch = camera.pitch.rangeValue.upperBound snapshotCamera = camera } diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index b2ce161..d1ef2f3 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -6,6 +6,7 @@ import SwiftUI import MapLibre extension MapView { + /// 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. @@ -38,21 +39,31 @@ extension MapView { // MARK: Default Gestures - public func onTapMapGesture(onTapChanged: @escaping (MapGestureContext) -> Void) -> MapView { + /// Add an onTap gesture to the MapView + /// + /// - Parameters: + /// - count: The number of taps required to run the gesture. + /// - onTapChanged: <#onTapChanged description#> + /// - Returns: <#description#> + public func onTapMapGesture(count: Int = 1, + onTapChanged: @escaping (MapGestureContext) -> Void) -> MapView { var newMapView = self // Build the gesture and link it to the map view. - let gesture = MapGesture(method: .tap, action: onTapChanged) + let gesture = MapGesture(method: .tap(numberOfTaps: count), + onChange: onTapChanged) newMapView.gestures.append(gesture) return newMapView } - public func onLongPressMapGesture(onPressChanged: @escaping (MapGestureContext) -> Void) -> MapView { + public func onLongPressMapGesture(minimumDuration: Double = 0.5, + onPressChanged: @escaping (MapGestureContext) -> Void) -> MapView { var newMapView = self // Build the gesture and link it to the map view. - let gesture = MapGesture(method: .longPress, action: onPressChanged) + let gesture = MapGesture(method: .longPress(minimumDuration: minimumDuration), + onChange: onPressChanged) newMapView.gestures.append(gesture) return newMapView diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift index 16f8ead..3c74e76 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift @@ -1,19 +1,38 @@ -import Foundation +import UIKit -public struct MapGesture { +public class MapGesture: NSObject { public enum Method: Equatable { /// A standard tap gesture (UITapGestureRecognizer) - case tap + /// + /// - Parameters: + /// - numberOfTaps: The number of taps required for the gesture to trigger + case tap(numberOfTaps: Int = 1) /// A standard long press gesture (UILongPressGestureRecognizer) - case longPress + /// + /// - Parameters: + /// - minimumDuration: The minimum duration of the press in seconds. + case longPress(minimumDuration: Double) } /// The Gesture's method, this is used to register it for the correct user interaction on the MapView. let method: Method - /// The action that runs when the gesture is triggered from the map view. - let action: (MapGestureContext) -> Void + /// The onChange action that runs when the gesture changes on the map view. + let onChange: (MapGestureContext) -> Void + + /// The underlying gesture recognizer + 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: @escaping (MapGestureContext) -> Void) { + self.method = method + self.onChange = onChange + } } diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift index e2f67ee..0394e1b 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift @@ -1,17 +1,18 @@ -import Foundation +import UIKit import CoreLocation +/// The contextual representation of the gesture. public struct MapGestureContext { - + /// The map gesture that produced the context. - public let gesture: MapGesture.Method + public let gestureMethod: MapGesture.Method + + /// The state of the on change event. + public let state: UIGestureRecognizer.State /// The location that the gesture occured on the screen. public let point: CGPoint /// The underlying geographic coordinate at the point of the gesture. public let coordinate: CLLocationCoordinate2D - - /// The number of taps (of a tap gesture) - public let numberOfTaps: Int? } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift index c732efe..adaf325 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -1,7 +1,7 @@ import Foundation import MapLibre -public enum CameraChangeReason: Hashable, Codable { +public enum CameraChangeReason: Hashable { case programmatic case resetNorth case gesturePan diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift new file mode 100644 index 0000000..a6c7492 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift @@ -0,0 +1,30 @@ +import Foundation +import MapLibre + +/// The current pitch state for the MapViewCamera +public enum CameraPitch: Hashable { + + /// The user is free to control pitch from it's default min to max. + case free + + /// The user is free to control pitch within the minimum and maximum range. + case withinRange(minimum: Double, maximum: Double) + + /// The pitch is fixed to a certain value. + case fixed(Double) + + /// The range of acceptable pitch values. + /// + /// This is applied to the map view on camera updates. + var rangeValue: ClosedRange { + switch self { + + case .free: + return 0...60 // TODO: set this to a maplibre constant (this is available on Android, but maybe not iOS)? + case .withinRange(minimum: let minimum, maximum: let maximum): + return minimum...maximum + case .fixed(let value): + return value...value + } + } +} diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index 5370971..977c914 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -2,23 +2,49 @@ import Foundation import MapLibre /// The CameraState is used to understand the current context of the MapView's camera. -public enum CameraState: Hashable, Codable { +public enum CameraState: Hashable { /// Centered on a coordinate - case centered + case centered(onCenter: CLLocationCoordinate2D) /// Follow the user's location using the MapView's internal camera. case trackingUserLocation /// 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 /// 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 /// Centered on a bounding box/rectangle. - case rect + case rect(northeast: CLLocationCoordinate2D, southwest: CLLocationCoordinate2D) // TODO: make a bounding box? - /// Showcasing a GeoJSON/Polygon - case showcase + /// Showcasing GeoJSON, Polygons, etc. + case showcase(shapeCollection: MLNShapeCollection) +} + +extension CameraState: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + + case .centered(onCenter: let onCenter): + return ".center(onCenter: \(onCenter)" + case .trackingUserLocation: + return ".trackingUserLocation" + case .trackingUserLocationWithHeading: + return ".trackingUserLocationWithHeading" + case .trackingUserLocationWithCourse: + return ".trackingUserLocationWithCourse" + case .rect(northeast: let northeast, southwest: let southwest): + return ".rect(northeast: \(northeast), southwest: \(southwest))" + case .showcase(shapeCollection: let shapeCollection): + return ".showcase(shapeCollection: \(shapeCollection))" + } + } } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift index 505bd6d..7196df6 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -2,20 +2,22 @@ import Foundation import CoreLocation import MapLibre +/// The SwiftUI MapViewCamera. +/// +/// This manages the camera state within the MapView. public struct MapViewCamera: Hashable { public struct Defaults { public static let coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0) public static let zoom: Double = 10 - public static let pitch: Double? = nil - public static let course: Double = 0 + public static let pitch: CameraPitch = .free + public static let direction: CLLocationDirection = 0 } public var state: CameraState - public var coordinate: CLLocationCoordinate2D public var zoom: Double - public var pitch: Double? - public var course: CLLocationDirection + public var pitch: CameraPitch + public var direction: CLLocationDirection /// The reason the camera was changed. /// @@ -29,11 +31,10 @@ public struct MapViewCamera: Hashable { /// /// - Returns: The constructed MapViewCamera. public static func `default`() -> MapViewCamera { - return MapViewCamera(state: .centered, - coordinate: Defaults.coordinate, + return MapViewCamera(state: .centered(onCenter: Defaults.coordinate), zoom: Defaults.zoom, pitch: Defaults.pitch, - course: Defaults.course, + direction: Defaults.direction, lastReasonForChange: .programmatic) } @@ -43,19 +44,18 @@ public struct MapViewCamera: Hashable { /// - coordinate: The coordinate to center the map on. /// - zoom: The zoom level. /// - pitch: The camera pitch. Default is 90 (straight down). - /// - course: The course. Default is 0 (North). + /// - direction: The course. Default is 0 (North). /// - Returns: The constructed MapViewCamera. public static func center(_ coordinate: CLLocationCoordinate2D, zoom: Double, - pitch: Double? = Defaults.pitch, - course: Double = Defaults.course, + pitch: CameraPitch = Defaults.pitch, + direction: CLLocationDirection = Defaults.direction, reason: CameraChangeReason? = nil) -> MapViewCamera { - return MapViewCamera(state: .centered, - coordinate: coordinate, + return MapViewCamera(state: .centered(onCenter: coordinate), zoom: zoom, pitch: pitch, - course: course, + direction: direction, lastReasonForChange: reason) } @@ -68,69 +68,53 @@ public struct MapViewCamera: Hashable { /// - pitch: Provide a fixed pitch value. The user will not be able to adjust pitch using gestures when this is set. Use nil/default to allow user control. /// - Returns: The MapViewCamera representing the scenario public static func trackUserLocation(zoom: Double = Defaults.zoom, - pitch: Double? = Defaults.pitch) -> MapViewCamera { + pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. return MapViewCamera(state: .trackingUserLocation, - coordinate: Defaults.coordinate, zoom: zoom, pitch: pitch, - course: Defaults.course, + direction: Defaults.direction, lastReasonForChange: .programmatic) } /// Enables user location tracking within the MapView. /// - /// This feature uses the MLNMapView's userTrackingMode = .follow + /// This feature uses the MLNMapView's userTrackingMode = .followWithHeading /// /// - Parameters: /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike pitch. /// - pitch: Provide a fixed pitch value. The user will not be able to adjust pitch using gestures when this is set. Use nil/default to allow user control. /// - Returns: The MapViewCamera representing the scenario public static func trackUserLocationWithHeading(zoom: Double = Defaults.zoom, - pitch: Double? = Defaults.pitch) -> MapViewCamera { + pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. return MapViewCamera(state: .trackingUserLocationWithHeading, - coordinate: Defaults.coordinate, zoom: zoom, pitch: pitch, - course: Defaults.course, + direction: Defaults.direction, lastReasonForChange: .programmatic) } /// Enables user location tracking within the MapView. /// - /// This feature uses the MLNMapView's userTrackingMode = .follow + /// This feature uses the MLNMapView's userTrackingMode = .followWithCourse /// /// - Parameters: /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike pitch. /// - pitch: Provide a fixed pitch value. The user will not be able to adjust pitch using gestures when this is set. Use nil/default to allow user control. /// - Returns: The MapViewCamera representing the scenario public static func trackUserLocationWithCourse(zoom: Double = Defaults.zoom, - pitch: Double? = Defaults.pitch) -> MapViewCamera { + pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. return MapViewCamera(state: .trackingUserLocationWithCourse, - coordinate: Defaults.coordinate, zoom: zoom, pitch: pitch, - course: Defaults.course, + direction: Defaults.direction, lastReasonForChange: .programmatic) } // TODO: Create init methods for other camera states once supporting materials are understood (e.g. BoundingBox) } - -extension MapViewCamera: Equatable { - - public static func ==(lhs: MapViewCamera, rhs: MapViewCamera) -> Bool { - return lhs.state == rhs.state - && lhs.coordinate.latitude == rhs.coordinate.latitude - && lhs.coordinate.longitude == rhs.coordinate.longitude - && lhs.zoom == rhs.zoom - && lhs.pitch == rhs.pitch - && lhs.course == rhs.course - && lhs.lastReasonForChange == rhs.lastReasonForChange - } -} diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift new file mode 100644 index 0000000..c197060 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift @@ -0,0 +1,12 @@ +import XCTest +import MapLibre +@testable import MapLibreSwiftUI + +final class CameraChangeReasonTests: XCTestCase { + + func testGestureOneFingerZoom() { + let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureOneFingerZoom] + XCTAssertEqual(CameraChangeReason(mlnReason), .gestureOneFingerZoom) + } + +} diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift new file mode 100644 index 0000000..71d1b2b --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import MapLibreSwiftUI + +final class CameraPitchTests: XCTestCase { + + func testFreePitch() { + let pitch: CameraPitch = .free + XCTAssertEqual(pitch.rangeValue.lowerBound, 0) + XCTAssertEqual(pitch.rangeValue.upperBound, 60) + } + + func testRangePitch() { + let pitch = CameraPitch.withinRange(minimum: 9, maximum: 29) + XCTAssertEqual(pitch.rangeValue.lowerBound, 9) + XCTAssertEqual(pitch.rangeValue.upperBound, 29) + } + + func testFixedPitch() { + let pitch = CameraPitch.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 new file mode 100644 index 0000000..4b44676 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -0,0 +1,36 @@ +import XCTest +import CoreLocation +@testable import MapLibreSwiftUI + +final class CameraStateTests: XCTestCase { + + func testCenterCameraState() { + let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let state: CameraState = .centered(onCenter: expectedCoordinate) + XCTAssertEqual(state, .centered(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) + } + + func testTrackingUserLocation() { + let state: CameraState = .trackingUserLocation + XCTAssertEqual(state, .trackingUserLocation) + } + + func testTrackingUserLocationWithHeading() { + let state: CameraState = .trackingUserLocationWithHeading + XCTAssertEqual(state, .trackingUserLocationWithHeading) + } + + func testTrackingUserLocationWithCourse() { + let state: CameraState = .trackingUserLocationWithCourse + XCTAssertEqual(state, .trackingUserLocationWithCourse) + } + + func testRect() { + let northeast = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let southwest = CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6) + + let state: CameraState = .rect(northeast: northeast, southwest: southwest) + XCTAssertEqual(state, .rect(northeast: northeast, southwest: southwest)) + } + +} diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift new file mode 100644 index 0000000..6e97796 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -0,0 +1,6 @@ +import XCTest +@testable import MapLibreSwiftUI + +final class MapViewCameraTests: XCTestCase { + +} From 7d3d8c8f72f4b1ffd732b0aa6f274e485e03b6bc Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Mon, 5 Feb 2024 22:09:41 -0800 Subject: [PATCH 04/15] Improvements based on feedback for gestures and similar --- .../MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift index adaf325..a52ca90 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -42,8 +42,7 @@ public enum CameraChangeReason: Hashable { self = .gestureTilt case .transitionCancelled: self = .transitionCancelled - default: - // TODO: MR Review Note - we could also have an "initial" value for an unset camera. + default: return nil } } From dc417e351a9b47f52ee6a40d54cac63667ecac90 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Mon, 5 Feb 2024 22:12:47 -0800 Subject: [PATCH 05/15] Improvements based on feedback for gestures and similar --- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index c96f894..949a079 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -174,15 +174,19 @@ extension MapViewCoordinator: MLNMapViewDelegate { /// The MapView's region has changed with a specific reason. public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { + // Validate that the mapView.userTrackingMode still matches our desired camera state for each tracking type. let isFollowing = parent.camera.state == .trackingUserLocation && mapView.userTrackingMode == .follow let isFollowingHeading = parent.camera.state == .trackingUserLocationWithHeading && mapView.userTrackingMode == .followWithHeading let isFollowingCourse = parent.camera.state == .trackingUserLocationWithCourse && mapView.userTrackingMode == .followWithCourse + // If any of these are a mismatch, we know the camera is no longer following a desired method, so we should detach and revert + // to a .centered camera. if isFollowing || isFollowingHeading || isFollowingCourse { // User tracking, we can ignore camera updates until we unset this. return } + // The user's desired camera is not a user tracking method, now we need to publish back the current mapView state to the camera binding. parent.camera = .center(mapView.centerCoordinate, zoom: mapView.zoomLevel, reason: CameraChangeReason(reason)) From 1209d548b20a7017bf7f323c00e66ef2979bf5d5 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Mon, 5 Feb 2024 22:21:44 -0800 Subject: [PATCH 06/15] Improvements based on feedback for gestures and similar --- Sources/MapLibreSwiftUI/MapViewModifiers.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index d1ef2f3..2449f65 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -39,12 +39,12 @@ extension MapView { // MARK: Default Gestures - /// Add an onTap gesture to the MapView + /// Add an tap gesture handler to the MapView /// /// - Parameters: /// - count: The number of taps required to run the gesture. - /// - onTapChanged: <#onTapChanged description#> - /// - Returns: <#description#> + /// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc). + /// - Returns: The modified map view. public func onTapMapGesture(count: Int = 1, onTapChanged: @escaping (MapGestureContext) -> Void) -> MapView { var newMapView = self @@ -57,6 +57,12 @@ extension MapView { return newMapView } + /// Add a long press gesture handler ot 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. public func onLongPressMapGesture(minimumDuration: Double = 0.5, onPressChanged: @escaping (MapGestureContext) -> Void) -> MapView { var newMapView = self From a076c356b79f4b943fc5e8232a914c1af1c28376 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 7 Feb 2024 17:10:56 -0800 Subject: [PATCH 07/15] Added testing for many of the swiftui mapview behaviors --- .github/workflows/test.yml | 4 +- Package.resolved | 9 + Package.swift | 9 +- Sources/MapLibreSwiftUI/Examples/Camera.swift | 20 +- Sources/MapLibreSwiftUI/Examples/Layers.swift | 152 ++++++++-------- Sources/MapLibreSwiftUI/Examples/Other.swift | 28 ++- .../MapLibre/MLNCameraChangeReason.swift | 5 +- .../MapLibre/MLNMapViewCamera.swift | 20 ++ .../Extensions/MapView/MapViewGestures.swift | 27 ++- .../UIKit/UIGestureRecognizer.swift | 13 ++ Sources/MapLibreSwiftUI/MapView.swift | 27 +-- .../MapLibreSwiftUI/MapViewCoordinator.swift | 6 +- .../MapLibreSwiftUI/MapViewModifiers.swift | 10 + .../Models/Gesture/MapGesture.swift | 2 +- .../Models/MapCamera/CameraChangeReason.swift | 4 +- .../Models/MapCamera/CameraState.swift | 12 +- .../Examples/CameraPreviewTests.swift | 14 ++ .../Examples/LayerPreviewTests.swift | 86 +++++++++ .../testCameraPreview.CameraPreview.png | Bin 0 -> 114543 bytes .../testCirclesWithSymbols.1.png | Bin 0 -> 70816 bytes .../LayerPreviewTests/testRoseTint.1.png | Bin 0 -> 70816 bytes .../testRotatedSymbolConst.1.png | Bin 0 -> 70816 bytes .../testRotatedSymboleDynamic.1.png | Bin 0 -> 70816 bytes .../LayerPreviewTests/testSimpleSymbol.1.png | Bin 0 -> 70816 bytes .../CoreLocation/CLLocationCoordinate2D.swift | 16 ++ .../MapView/MapViewGestureTests.swift | 78 ++++++++ .../MapViewCoordinatorCameraTests.swift | 172 ++++++++++++++++++ .../Models/Gesture/MapGestureTests.swift | 37 ++++ .../MapCamera/CameraChangeReasonTests.swift | 46 ++++- .../Models/MapCamera/CameraStateTests.swift | 7 + .../Models/MapCamera/MapViewCameraTests.swift | 32 ++++ .../Support/XCTestAssertView.swift | 52 ++++++ 32 files changed, 751 insertions(+), 137 deletions(-) create mode 100644 Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift create mode 100644 Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift create mode 100644 Tests/MapLibreSwiftUITests/Examples/CameraPreviewTests.swift create mode 100644 Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/CameraPreviewTests/testCameraPreview.CameraPreview.png create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testCirclesWithSymbols.1.png create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRoseTint.1.png create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymbolConst.1.png create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymboleDynamic.1.png create mode 100644 Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testSimpleSymbol.1.png create mode 100644 Tests/MapLibreSwiftUITests/Extensions/CoreLocation/CLLocationCoordinate2D.swift create mode 100644 Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift create mode 100644 Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift create mode 100644 Tests/MapLibreSwiftUITests/Models/Gesture/MapGestureTests.swift create mode 100644 Tests/MapLibreSwiftUITests/Support/XCTestAssertView.swift diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90c6c43..769cf58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: test: - runs-on: macos-13 + runs-on: macos-14 strategy: matrix: scheme: [ @@ -16,7 +16,7 @@ jobs: ] destination: [ # TODO: Add more destinations - 'platform=iOS Simulator,name=iPhone 15,OS=17.0.1' + 'platform=iOS Simulator,name=iPhone 15,OS=17.2' ] steps: diff --git a/Package.resolved b/Package.resolved index eba3e28..661a055 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,6 +18,15 @@ "revision" : "b8deecb8adc3b911de311ead5a13b98fbf2d7824" } }, + { + "identity" : "mockable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Kolos65/Mockable.git", + "state" : { + "revision" : "7af00c08880d375f2742ca55705abd69837fe6c3", + "version" : "0.0.2" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index a6cafbb..2fde427 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,7 @@ let package = Package( .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.0.0-pre9599200f2529de44ba62d4662cddb445dc19397d"), .package(url: "https://github.com/stadiamaps/maplibre-swift-macros.git", branch: "main"), // Testing + .package(url: "https://github.com/Kolos65/Mockable.git", from: "0.0.2"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.15.3"), ], targets: [ @@ -32,6 +33,10 @@ let package = Package( .target(name: "InternalUtils"), .target(name: "MapLibreSwiftDSL"), .product(name: "MapLibre", package: "maplibre-gl-native-distribution"), + .product(name: "Mockable", package: "Mockable") + ], + swiftSettings: [ + .define("MOCKING", .when(configuration: .debug)) ]), .target( name: "MapLibreSwiftDSL", @@ -50,7 +55,9 @@ let package = Package( .testTarget( name: "MapLibreSwiftUITests", dependencies: [ - "MapLibreSwiftUI" + "MapLibreSwiftUI", + .product(name: "MockableTest", package: "Mockable"), + .product(name: "SnapshotTesting", package: "swift-snapshot-testing") ] ), .testTarget( diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index 5bfec4e..4d4e483 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -7,9 +7,14 @@ struct CameraDirectManipulationPreview: View { @State private var camera = MapViewCamera.center(switzerland, zoom: 4) let styleURL: URL + var onStyleLoaded: (() -> Void)? = nil var body: some View { MapView(styleURL: styleURL, camera: $camera) + .onStyleLoaded { _ in + print("Style is loaded") + onStyleLoaded?() + } .overlay(alignment: .bottom, content: { Text("\(String(describing: camera.state)) z \(camera.zoom)") .padding() @@ -22,19 +27,16 @@ struct CameraDirectManipulationPreview: View { .padding(.bottom, 42) }) .task { - try! await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) + try? await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) camera = MapViewCamera.center(switzerland, zoom: 6) } } } -struct Camera_Previews: PreviewProvider { - static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - - CameraDirectManipulationPreview(styleURL: demoTilesURL) - .ignoresSafeArea(.all) - .previewDisplayName("Camera Binding") - } +#Preview("Camera Preview") { + CameraDirectManipulationPreview( + styleURL: URL(string: "https://demotiles.maplibre.org/style.json")! + ) + .ignoresSafeArea(.all) } diff --git a/Sources/MapLibreSwiftUI/Examples/Layers.swift b/Sources/MapLibreSwiftUI/Examples/Layers.swift index 31ab9ab..a6b34f7 100644 --- a/Sources/MapLibreSwiftUI/Examples/Layers.swift +++ b/Sources/MapLibreSwiftUI/Examples/Layers.swift @@ -3,89 +3,91 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -struct Layer_Previews: PreviewProvider { - static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! +let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - // A collection of points with various - // attributes - let pointSource = ShapeSource(identifier: "points") { - // Uses the DSL to quickly construct point features inline - MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139)) +// A collection of points with various +// attributes +let pointSource = ShapeSource(identifier: "points") { + // Uses the DSL to quickly construct point features inline + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139)) - MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0)) { feature in - feature.attributes["icon"] = "missing" - feature.attributes["heading"] = 45 - } + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0)) { feature in + feature.attributes["icon"] = "missing" + feature.attributes["heading"] = 45 + } - MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 39.02001, longitude: 1.482148)) { feature in - feature.attributes["icon"] = "club" - feature.attributes["heading"] = 145 - } - } + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 39.02001, longitude: 1.482148)) { feature in + feature.attributes["icon"] = "club" + feature.attributes["heading"] = 145 + } +} - MapView(styleURL: demoTilesURL) { - // Silly example: a background layer on top of everything to create a tint effect - BackgroundLayer(identifier: "rose-colored-glasses") - .backgroundColor(constant: .systemPink.withAlphaComponent(0.3)) - .renderAboveOthers() - } - .ignoresSafeArea(.all) - .previewDisplayName("Rose Tint") +#Preview("Rose Tint") { + MapView(styleURL: demoTilesURL) { + // Silly example: a background layer on top of everything to create a tint effect + BackgroundLayer(identifier: "rose-colored-glasses") + .backgroundColor(constant: .systemPink.withAlphaComponent(0.3)) + .renderAboveOthers() + } + .ignoresSafeArea(.all) +} - MapView(styleURL: demoTilesURL) { - // Simple symbol layer demonstration with an icon - SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "mappin")!) - } - .ignoresSafeArea(.all) - .previewDisplayName("Simple Symbol") +#Preview("Simple Symbol") { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!) + } + .ignoresSafeArea(.all) +} - MapView(styleURL: demoTilesURL) { - // Simple symbol layer demonstration with an icon - SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) - .iconRotation(constant: 45) - } - .ignoresSafeArea(.all) - .previewDisplayName("Rotated Symbols (Const)") +#Preview("Rotated Symbols (Const)") { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) + .iconRotation(constant: 45) + } + .ignoresSafeArea(.all) +} - MapView(styleURL: demoTilesURL) { - // Simple symbol layer demonstration with an icon - SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) - .iconRotation(featurePropertyNamed: "heading") - } - .ignoresSafeArea(.all) - .previewDisplayName("Rotated Symbols (Dynamic)") - - MapView(styleURL: demoTilesURL) { - // Simple symbol layer demonstration with an icon - CircleStyleLayer(identifier: "simple-circles", source: pointSource) - .radius(constant: 16) - .color(constant: .systemRed) - .strokeWidth(constant: 2) - .strokeColor(constant: .white) - - SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate)) - .iconColor(constant: .white) - } +#Preview("Rotated Symbols (Dynamic)") { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) + .iconRotation(featurePropertyNamed: "heading") + } .ignoresSafeArea(.all) - .previewDisplayName("Circles with Symbols") +} - // FIXME: This appears to be broken upstream; waiting for a new release -// MapView(styleURL: demoTilesURL) { -// // Simple symbol layer demonstration with an icon -// SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) -// .iconImage(attribute: "icon", -// mappings: [ -// "missing": UIImage(systemName: "mappin.slash")!, -// "club": UIImage(systemName: "figure.dance")! -// ], -// default: UIImage(systemName: "mappin")!) -// } -// .edgesIgnoringSafeArea(.all) -// .previewDisplayName("Multiple Symbol Icons") +#Preview("Circles with Symbols") { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + CircleStyleLayer(identifier: "simple-circles", source: pointSource) + .radius(constant: 16) + .color(constant: .systemRed) + .strokeWidth(constant: 2) + .strokeColor(constant: .white) + + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate)) + .iconColor(constant: .white) } + .ignoresSafeArea(.all) } + +// TODO: Fixme +//#Preview("Multiple Symbol Icons") { +// MapView(styleURL: demoTilesURL) { +// // Simple symbol layer demonstration with an icon +// SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) +// .iconImage(attribute: "icon", +// mappings: [ +// "missing": UIImage(systemName: "mappin.slash")!, +// "club": UIImage(systemName: "figure.dance")! +// ], +// default: UIImage(systemName: "mappin")!) +// } +// .edgesIgnoringSafeArea(.all) +//} diff --git a/Sources/MapLibreSwiftUI/Examples/Other.swift b/Sources/MapLibreSwiftUI/Examples/Other.swift index 0e7dec9..b4b1343 100644 --- a/Sources/MapLibreSwiftUI/Examples/Other.swift +++ b/Sources/MapLibreSwiftUI/Examples/Other.swift @@ -3,10 +3,8 @@ import MapLibre import MapLibreSwiftDSL import SwiftUI -struct Other_Previews: PreviewProvider { - static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - +#Preview("Unsafe MapView Modifier") { + MapView(styleURL: demoTilesURL) { // A collection of points with various // attributes let pointSource = ShapeSource(identifier: "points") { @@ -23,18 +21,14 @@ struct Other_Previews: PreviewProvider { feature.attributes["heading"] = 145 } } - - MapView(styleURL: demoTilesURL) { - // Demonstrates how to use the unsafeMapModifier to set MLNMapView properties that have not been exposed as modifiers yet. - SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) - .iconImage(constant: UIImage(systemName: "mappin")!) - } - .unsafeMapViewModifier({ mapView 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 - }) - .ignoresSafeArea(.all) - .previewDisplayName("Unsafe MapView Modifier") + + // Demonstrates how to use the unsafeMapModifier to set MLNMapView properties that have not been exposed as modifiers yet. + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!) + } + .unsafeMapViewModifier { mapView 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 } } diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift index 6230bfb..40f206b 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift @@ -3,8 +3,9 @@ import MapLibre extension MLNCameraChangeReason { - /// Get the last value from the MLNCameraChangeReason option set. - public var lastValue: MLNCameraChangeReason { + /// Get the MLNCameraChangeReason from the option set with the largest + /// bitwise value. + public var largestBitwiseReason: MLNCameraChangeReason { // Start at 1 var mask: UInt = 1 var result: UInt = 0 diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift new file mode 100644 index 0000000..b19012e --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift @@ -0,0 +1,20 @@ +import Foundation +import CoreLocation +import MapLibre +import Mockable + +@Mockable +protocol MLNMapViewCamera: AnyObject { + var userTrackingMode: MLNUserTrackingMode { get set } + var minimumPitch: CGFloat { get set } + var maximumPitch: CGFloat { get set } + func setCenter(_ coordinate: CLLocationCoordinate2D, + zoomLevel: Double, + direction: CLLocationDirection, + animated: Bool) + func setZoomLevel(_ zoomLevel: Double, animated: Bool) +} + +extension MLNMapView: MLNMapViewCamera { + // No definition +} diff --git a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift index f113599..e9302df 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import MapLibre extension MapView { @@ -44,21 +45,35 @@ extension 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). + gesture.onChange(context) + } + + /// Convert the sender data into a MapGestureContext + /// + /// - Parameters: + /// - mapView: The mapview that's emitting the gesture. + /// - gesture: The gesture definition for this event. + /// - sender: The UIKit gesture emitting from the map view. + /// - Returns: The calculated context from the sending UIKit gesture + func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, sender: UIGestureRecognizerProtocol) -> MapGestureContext { // Build the context of the gesture's event. var point: CGPoint switch gesture.method { case .tap(numberOfTaps: let numberOfTaps): + // Calculate the CGPoint of the last gesture tap point = sender.location(ofTouch: numberOfTaps - 1, in: mapView) case .longPress: + // Calculate the CGPoint of the long process gesture. point = sender.location(in: mapView) } - let context = MapGestureContext(gestureMethod: gesture.method, - state: sender.state, - point: point, - coordinate: mapView.convert(point, toCoordinateFrom: mapView)) - - gesture.onChange(context) + return MapGestureContext(gestureMethod: gesture.method, + state: sender.state, + point: point, + coordinate: mapView.convert(point, toCoordinateFrom: mapView)) } } diff --git a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift new file mode 100644 index 0000000..267a901 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift @@ -0,0 +1,13 @@ +import UIKit +import Mockable + +@Mockable +protocol UIGestureRecognizerProtocol: AnyObject { + var state: UIGestureRecognizer.State { get } + func location(in view: UIView?) -> CGPoint + func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint +} + +extension UIGestureRecognizer: UIGestureRecognizerProtocol { + // No definition +} diff --git a/Sources/MapLibreSwiftUI/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index da9d840..159d7f5 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -9,7 +9,9 @@ public struct MapView: UIViewRepresentable { let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] + var gestures = [MapGesture]() + var onStyleLoaded: ((MLNStyle) -> Void)? /// 'Escape hatch' to MLNMapView until we have more modifiers. /// See ``unsafeMapViewModifier(_:)`` @@ -60,6 +62,9 @@ public struct MapView: UIViewRepresentable { // TODO: Make this settable via a modifier mapView.logoView.isHidden = true + // Link the style loaded to the coordinator that emits the delegate event. + context.coordinator.onStyleLoaded = onStyleLoaded + // Add all gesture recognizers for gesture in gestures { registerGesture(mapView, context, gesture: gesture) @@ -89,17 +94,13 @@ public struct MapView: UIViewRepresentable { } } -struct MapView_Previews: PreviewProvider { - static var previews: some View { - let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! - - 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 - } +#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 } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 949a079..cda8aaa 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -12,7 +12,8 @@ public class MapViewCoordinator: NSObject { // every update cycle so we can avoid unnecessary updates private var snapshotUserLayers: [StyleLayerDefinition] = [] private var snapshotCamera: MapViewCamera? - private var onGesture: (MLNMapView, UIGestureRecognizer) -> Void + var onStyleLoaded: ((MLNStyle) -> Void)? + var onGesture: (MLNMapView, UIGestureRecognizer) -> Void init(parent: MapView, onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void) { @@ -32,7 +33,7 @@ public class MapViewCoordinator: NSObject { // MARK: - Coordinator API - Camera + Manipulation - func updateCamera(mapView: MLNMapView, camera: MapViewCamera, animated: Bool) { + func updateCamera(mapView: MLNMapViewCamera, camera: MapViewCamera, animated: Bool) { guard camera != snapshotCamera else { // No action - camera has not changed. return @@ -169,6 +170,7 @@ public class MapViewCoordinator: NSObject { extension MapViewCoordinator: MLNMapViewDelegate { public func mapView(_ mapView: MLNMapView, didFinishLoading mglStyle: MLNStyle) { + onStyleLoaded?(mglStyle) addLayers(to: mglStyle) } diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index 2449f65..174cb44 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -7,6 +7,16 @@ import MapLibre extension MapView { + /// Perform an action when the map view has loaded its style. + /// + /// - Parameter perform: The action to perform with the loaded style. + /// - Returns: The modified map view. + public func onStyleLoaded(_ perform: @escaping (MLNStyle) -> Void) -> MapView { + 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. diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift index 3c74e76..7a444f9 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift @@ -14,7 +14,7 @@ public class MapGesture: NSObject { /// /// - Parameters: /// - minimumDuration: The minimum duration of the press in seconds. - case longPress(minimumDuration: Double) + case longPress(minimumDuration: Double = 0.5) } /// The Gesture's method, this is used to register it for the correct user interaction on the MapView. diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift index a52ca90..a2b5039 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -15,12 +15,12 @@ public enum CameraChangeReason: Hashable { /// Initialize a Swift CameraChangeReason from the MLN NSOption. /// - /// This method will only show the last reason. If you need a full history of the full bit range, + /// This method will only show the largest reason. 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) { - switch mlnCameraChangeReason.lastValue { + switch mlnCameraChangeReason.largestBitwiseReason { case .programmatic: self = .programmatic diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index 977c914..1a2b9c3 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -34,17 +34,17 @@ extension CameraState: CustomDebugStringConvertible { switch self { case .centered(onCenter: let onCenter): - return ".center(onCenter: \(onCenter)" + return "CameraState.center(onCenter: \(onCenter)" case .trackingUserLocation: - return ".trackingUserLocation" + return "CameraState.trackingUserLocation" case .trackingUserLocationWithHeading: - return ".trackingUserLocationWithHeading" + return "CameraState.trackingUserLocationWithHeading" case .trackingUserLocationWithCourse: - return ".trackingUserLocationWithCourse" + return "CameraState.trackingUserLocationWithCourse" case .rect(northeast: let northeast, southwest: let southwest): - return ".rect(northeast: \(northeast), southwest: \(southwest))" + return "CameraState.rect(northeast: \(northeast), southwest: \(southwest))" case .showcase(shapeCollection: let shapeCollection): - return ".showcase(shapeCollection: \(shapeCollection))" + return "CameraState.showcase(shapeCollection: \(shapeCollection))" } } } diff --git a/Tests/MapLibreSwiftUITests/Examples/CameraPreviewTests.swift b/Tests/MapLibreSwiftUITests/Examples/CameraPreviewTests.swift new file mode 100644 index 0000000..5fafc7b --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Examples/CameraPreviewTests.swift @@ -0,0 +1,14 @@ +import XCTest +import SnapshotTesting +@testable import MapLibreSwiftUI + +final class CameraPreviewTests: XCTestCase { + + func testCameraPreview() { + assertView(named: "CameraPreview") { + CameraDirectManipulationPreview( + styleURL: URL(string: "https://demotiles.maplibre.org/style.json")! + ) + } + } +} diff --git a/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift new file mode 100644 index 0000000..e22e159 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Examples/LayerPreviewTests.swift @@ -0,0 +1,86 @@ +import XCTest +import MapLibre +import MapLibreSwiftDSL +@testable import MapLibreSwiftUI + +final class LayerPreviewTests: XCTestCase { + + let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")! + + // A collection of points with various + // attributes + let pointSource = ShapeSource(identifier: "points") { + // Uses the DSL to quickly construct point features inline + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 51.47778, longitude: -0.00139)) + + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0)) { feature in + feature.attributes["icon"] = "missing" + feature.attributes["heading"] = 45 + } + + MLNPointFeature(coordinate: CLLocationCoordinate2D(latitude: 39.02001, longitude: 1.482148)) { feature in + feature.attributes["icon"] = "club" + feature.attributes["heading"] = 145 + } + } + + func testRoseTint() { + assertView { + MapView(styleURL: demoTilesURL) { + // Silly example: a background layer on top of everything to create a tint effect + BackgroundLayer(identifier: "rose-colored-glasses") + .backgroundColor(constant: .systemPink.withAlphaComponent(0.3)) + .renderAboveOthers() + } + } + } + + func testSimpleSymbol() { + assertView { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!) + } + } + } + + func testRotatedSymbolConst() { + assertView { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) + .iconRotation(constant: 45) + } + } + } + + func testRotatedSymboleDynamic() { + assertView { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + SymbolStyleLayer(identifier: "rotated-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "location.north.circle.fill")!) + .iconRotation(featurePropertyNamed: "heading") + } + } + } + + func testCirclesWithSymbols() { + assertView { + MapView(styleURL: demoTilesURL) { + // Simple symbol layer demonstration with an icon + CircleStyleLayer(identifier: "simple-circles", source: pointSource) + .radius(constant: 16) + .color(constant: .systemRed) + .strokeWidth(constant: 2) + .strokeColor(constant: .white) + + SymbolStyleLayer(identifier: "simple-symbols", source: pointSource) + .iconImage(constant: UIImage(systemName: "mappin")!.withRenderingMode(.alwaysTemplate)) + .iconColor(constant: .white) + } + } + } +} diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/CameraPreviewTests/testCameraPreview.CameraPreview.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/CameraPreviewTests/testCameraPreview.CameraPreview.png new file mode 100644 index 0000000000000000000000000000000000000000..76aa78a3c182806a25535b580f55f0ddb09388b8 GIT binary patch literal 114543 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½@inL4aYSGi5lp-Be<4f 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 V-nAYhw=wLW?S{KZ>6>hR{Wq%Q_NV{= literal 0 HcmV?d00001 diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRoseTint.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRoseTint.1.png new file mode 100644 index 0000000000000000000000000000000000000000..22296cee048a86405a0c4dc8a0e0963142303412 GIT binary patch literal 70816 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 V-nAYhw=wLW?S{KZ>6>hR{Wq%Q_NV{= literal 0 HcmV?d00001 diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymbolConst.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymbolConst.1.png new file mode 100644 index 0000000000000000000000000000000000000000..22296cee048a86405a0c4dc8a0e0963142303412 GIT binary patch literal 70816 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 V-nAYhw=wLW?S{KZ>6>hR{Wq%Q_NV{= literal 0 HcmV?d00001 diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymboleDynamic.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymboleDynamic.1.png new file mode 100644 index 0000000000000000000000000000000000000000..22296cee048a86405a0c4dc8a0e0963142303412 GIT binary patch literal 70816 zcmeHQdpML^`yPWrMYGLLIc3-WG9o$&iBXMCcA<12q-@!dkem<0jvPAf5`_@aE^R|a z%5iIULQy-%9L6D)Lnf!jVVG}ftQq!Gju-t~r? z?Akg{LRJEU!OYvfjkFts5oMPpdcQ&|?^p$?sUOU%&p;v47dM zCtrmVg?KF{V$runB@%^ES)%7_waABme0>#n*=FvB!Tg~8^;gI+YLyN9P5EOaV>9+E z`+#46L_OI55jd}3%fU*zI?AOOi~(jlX``8!P*3BZxFQ2F>dH}P*~ZyB#AORTa$byz z%~vuA#0X(UrWCw1R?DUggW z`)6;y)!2M<)|3u%yX<26yP)PpzWY3QVAj-ALNyjjX!4yd`?J8aGj-^8$!b)0YLAOd z1RvNE?;xoEK_VOUqW&+IXh|{=7S#VATRJo63bMpX!3P$S36T}leN;6fa; zfD57V11^LtF+)6tD1;&jL?Mo=z(j^{4oqZRO8^$)U>?9ilRyyQG-4%z0H--192gm* zbHK=uC4i9uBb!FivB1cHk)f%R11}*jgpy*AT11wB)FNk$TndEjG-n2d>@-U9gTTSz zpdfG{WCa=-vIJ;k9OeZY8A|Jez=13Q0tW~jC@;gAK|$aEfdiq+N#+Ft2M8R~gG3+* z9Ft=N1PAU z*uZLrow+gsYX$EY)iT3w6l#KVB}G{488*V2ff9j%kW>i<`i2d_gU~bpLu_&ifEdJ? z6Cp?==^AFS$?OtL^PEH@;Gjv^Tm(RIa{dG)&0!%xH8>$+K2Qyy8VFkh)tHiA!HNLY z0IGqo^|v4aR0Eju*5kNJ7Y5>)kHf&$sxd87iPn#TssXA7@&yi@2!sVxplVEu6+ktBY5>)k zoF}H^2%s83HGpbN9}}FEB`lyqunJTSP&Gi+n3kavsA?1k)dpiQ!d2Tz8_iIbTMSm4 zq3H#983%*HjV)+!{MYcZ`4|j_#iHo!+J*M|=xO{D$G+2#x^mQ6wsH0jaoIwToEM{L z#+*tvyfjwQMKS1j%qE! z8Dv&qWYcF*U}Okm!t+BWnHN0i8ZE?~;XW47$Pf{}KW1P%~5rUwIn(+ETYoJN)aI1O+b;B@BP=>U?Gsse!n;Pmtv6qcO9k~4%V zLEr#^qhvM0QB%DF1dji7fg>{G%MP>`z;rKW$2YjYo)hGMZ0Q7C^ewdtH%B9Q40pD2 zr~%yBiY7crGa^gCI%-%)jn=quW>82oB6tjGMo2UMN2VETn{PZT+neBtQO^;#n-jj< zL?v`+1lY4~49Ob|Z9M{O1#F=5vu2(BO`9kP0A1IW|{5(o4a2=k7|YZ5QGLSXo& zMQF^TdF?CqQxd(Wcvj%{Wf6ia6p?Puo!S68%}dyQ7ym+kOzX0t;0iWLCbJOw=s@M!25SWlQnV|x>5$}S48-W|~jVg#nyZ{H$2)PGLYA95Qh{2@B z!v=tjP_zJS!~+%pu~FO%1c;3&6)-gvD&{KzQ{$08U}}i`0^#QY3n2U`ZVF)`Z-k-+ zi_Hfy z1YvIwLwJ4xVhAs10b&TsaDo`Z0~R2Lptw1M7*c3~)u4pu43&nt^@b0Jb}x~%Ug%n6 zh)d8>Zo6W?rN{FG^@9va{-hz5$RZs1Qy5nsAAVq^#Pqr~0|d2p6gm;YC|@ zT170^h-!6otGwGD>fP$(@dX5ltz+YEL%i6DD$*tLzSN{v%EzrMt!`6bpRxCD>Yz6+ zA!czV;}|Zt@;o8!SyM`j7mdDFwJlP2X^qpumybxM+A;Llv}GuH%mpnAmwu+RvhFm> zQJ$+7j=k!`k$vk^ueLU@l-ovXOXP2LleOO2%cXni94YM&(=l2)v3jxB{c%_6=+Huj zGtN`5$vp!%yw6k{>wMYhN50lSt}uxw`PFH*M|;}+O?TYCwUJ38sj#~I{;-lU-BEg4 z{rvEd>%Q1>Gh6SE+nWZeAJ>!?up2X9MK(sZ<+i!rM48!J-<2@pEben+V)NgnTuz@)R-dSO2eKQLmSH8_Wt=-oHfF| z20nSDKe?wt9-5s`NwK8qkJN{%J}`^xIuucSXF~F^J}+EPwi6^7)DRz0r&Am5do_6M zhFo9W90hXoTP@FfVZ$HqIai${be5{J)(o@ zM2|PZTh>o_J*U0M&-b~}@#OXg+5WrLn+tMQ#95o>h%O}ZU-N70v6K(DnoEq+qMYqS zN1~K(SGZruzbq<6ioCtpQzn+&mw#tu?azj-b)JP%5m}j>&l3*y z*-pE8k`OLzT6tBO4pj~?E<{9sW?-m_H6ciOy5 z%55@c!ElEhZt;c!i-!tsd)C-5H{RBprD$1}N6ss_?iQIm7!$^%cH*2V&b{*P7jLy6 zxt@r@NcnFkZ8Rg?Q~fAlm=y^kVQNZsyK}r6^V8#(O*K*awtc}3 zF}houma0f}OZe#hGo}O<8Hn_f(=FPZ$d=TI=vdvRN6}`LnU4u-Qag_IccY{zkXrjNUUy8 zOxMUnCozU-Ox$XQ|C0P(lixJL-+K*@bPYCHD{sxqO0R!&qVa3EtnN?$X;g1`;ArLE z3;XolTtlzl_|UMbknHL{^2wzTAM$j_(}TY&ir7$%F10shk+kdOKFq_yM2pyt_H(gI z^aCuYCzQ7^s~JlbM`^Pp^FlVudv6|UF1zwu=y(c2{)=u4lfP#Q*p!?GI}iKo+1+tr zn)Fl5`sV=y1GP(<$;Us8?@Z8QHkjfiS$Zy_{8ep5(*1HCkW}9U+etV(j;{AQJfujs z(rMH+!%NoE>c#nj?Gq8n{QDM3<3D2(Wa`}C?klt!+%}|GE`2R@gx$Z?0ylm)KP46! zdNxwC{iU&@I%~Y{jsp4qUcZ4aqhajOk;xdj?smr4F`~G2R9Bb(>MsoHf7qVcVLrm5 z9MXL+m7T;IZ~N;8W4+TBYMNuH=V^AmdfhdA^$enmL>cZ;_9=wC<-`XWos`*bS4a92 zwy632K1zRUIVa%Q&Nue=6zT8${&w|L@fnb=E_CcxjwsH)bt4o-7jCq$*%XxAfAe2W z9re;_%lztZO|-nQt9Cn;Fz{$Xv+b|yE9-l1v4*3R(`r`{DNYC20q=0Bt_FXX3|ow% zPes|&b(b9(KU0=aJ^E+B2aovV+#eg6{?|{)zpE|VpH}NiZQB^{%CuFLC}H#+ZxeEw_#Dv9~8aZ9cAkxiv6D~BM`lh9`rn@cQGl$p-q|J-!^}DZC&yM0y8({ zlhyN;5yi#{P2Pozb+huvKIvac$~Z`62&g0d(VFEnux9LxDZbZti@oWAg2gq_NiGNa zD~z}o2^CCmb^a&pU9-t&6Y90jC4KfRXz(uV2!HW(bT?kosM(wUOqOGP6Kc%kvUCo! zzn?L-W>KMwYsjsv#RQ3i$sSAiZ5S>hne+PtLgV41)Mw|V^Y31#J+i_Vd_MXif8y+;ZdH8G_LXa+6+4lOplhM@ zYER20)%9nK?+5t(W$Zg~%$9gOjS%PSJJdX3x83&9Yeo=0#Q%-=4}4>}C{e`6{|v3b zo)j4xIJYb9e3HwME-v>*ojHD2EUwRNPQstszr`lGEcu~OnR_%;SQt@c&mO~7WoE4* zjM~D;+lRu3dvD-Mnj1$bW5>$L6ZsXi#nI%#&o_NkUzIeP<=>`EBu?mQx{4ScmX5fv zk^eMuiY|9l@BZ{unUwc1m9^(;ZIo@~-R<&L_vwZ(vqOWU1+%mtOkckVmgK7AX zooCl8@|}}`Eq1p0?+bhmJ-*9&%CI;VfBdkvfAhP_LyFYeTJ?3>@N z6h`zNVmCx9apoV7XVg}9*Ep1BkGtYLi4C-nKP(hnJB^oAJn=ors;DR|>g+s*V#tiaFfCfV&kHuR zWyI$Q2oZ|dgE1AVS3($rkIyu=uRYKFrOy^;r}recPOWxy`0V+(q`uPx2{~nV1OD)` zQ9QD8EOuS8MbXvWy-BlYxz{iP8t2I^eXsV(|GaL+xg5_9U*f(7@*TqQgN-BHGvyW> z#4VXN^d;#((XUoc_}V%B@su&%c3;uIo_e&pHgIjM(RbGFyg0C@OtlDaw5MU`hIjIrW+4Y(;8qL-$hkJYrk&?Jw$LTx(z43doT=b%OH~}9cDzjanZi7m+sfs_#C1| zYxvjI)%~FtxA+Z=hS7+&Ot;A5&!K`nf54ZUYD4<|9#k3i3&Z^#v5IF-el89ZR*x92Vy+ZTf6440p(`Oxt#u1Hm;n@D2UO|P0BcCTCx-cGj15Tb|1_3kjC-jRHhwnt6KA* zd2q*_=WG&P=X#X-(643tb-bGK+IW49Hlt{gy!&RV=!nbZ)jBz@?UDIeZ)H*m_jZ27 z@SlyymO}=n+N1QFy4)l23MK~)Ob^7q65?K8Ug2CpV3NxqZL=p|N0kf`vjdYd*4-55 z&LX_RLVqE{){ljuy?jB|Ake@`$ndb!rrEsXbK71yij63KO_pjBaFc(~TlQyfgC)6N z<`rxPMe!lYEX5~+HW|obf0iY#AfO{fALp=h8Z5n2vjy18f&H1I^m@MQ%ib5^wTub= z!i4WfNuHUwXI{0y1Yfi46oxMr2w{^o5()C-j8_WWFY^k&uSkRo_Ya5o|Gsd;#IED& V-nAYhw=wLW?S{KZ>6>hR{Wq%Q_NV{= literal 0 HcmV?d00001 diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testSimpleSymbol.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testSimpleSymbol.1.png new file mode 100644 index 0000000000000000000000000000000000000000..22296cee048a86405a0c4dc8a0e0963142303412 GIT binary patch literal 70816 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 V-nAYhw=wLW?S{KZ>6>hR{Wq%Q_NV{= literal 0 HcmV?d00001 diff --git a/Tests/MapLibreSwiftUITests/Extensions/CoreLocation/CLLocationCoordinate2D.swift b/Tests/MapLibreSwiftUITests/Extensions/CoreLocation/CLLocationCoordinate2D.swift new file mode 100644 index 0000000..2a7e9a2 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Extensions/CoreLocation/CLLocationCoordinate2D.swift @@ -0,0 +1,16 @@ +import XCTest +import CoreLocation +@testable import MapLibreSwiftUI + +final class CLLocationCoordinate2DTests: XCTestCase { + + func testHashable() { + let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + + var hasher = Hasher() + coordinate.hash(into: &hasher) + let hashedValue = hasher.finalize() + + XCTAssertEqual(hashedValue, coordinate.hashValue) + } +} diff --git a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift new file mode 100644 index 0000000..417ac34 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -0,0 +1,78 @@ +import XCTest +import MockableTest +import MapLibre +@testable import MapLibreSwiftUI + +final class MapViewGestureTests: XCTestCase { + + let maplibreMapView = MLNMapView() + let mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) + + // MARK: Gesture View Modifiers + + func testMapViewOnTapGestureModifier() { + let newMapView = mapView.onTapMapGesture { _ in } + + XCTAssertEqual(newMapView.gestures.first?.method, .tap()) + } + + func testMapViewOnLongPressGestureModifier() { + let newMapView = mapView.onLongPressMapGesture { _ in } + + XCTAssertEqual(newMapView.gestures.first?.method, .longPress()) + } + + // MARK: Gesture Processing + + func testTapGesture() { + let gesture = MapGesture(method: .tap(numberOfTaps: 2)) { _ in + // No capture + } + + let mockTapGesture = MockUIGestureRecognizerProtocol() + + given(mockTapGesture) + .state.willReturn(.ended) + + given(mockTapGesture) + .location(ofTouch: .value(1), in: .any) + .willReturn(CGPoint(x: 10, y: 10)) + + let result = mapView.processContextFromGesture(maplibreMapView, + gesture: gesture, + sender: mockTapGesture) + + XCTAssertEqual(result.gestureMethod, .tap(numberOfTaps: 2)) + XCTAssertEqual(result.point, CGPoint(x: 10, y: 10)) + + // TODO: Delete this? The MLNMapView is technically converting something. Probably not reliably, but it could still be useful to track. + XCTAssertEqual(result.coordinate.latitude, 15, accuracy: 1) + XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1) + } + + func testLongPressGesture() { + let gesture = MapGesture(method: .longPress(minimumDuration: 1)) { _ in + // No capture + } + + let mockTapGesture = MockUIGestureRecognizerProtocol() + + given(mockTapGesture) + .state.willReturn(.ended) + + given(mockTapGesture) + .location(in: .any) + .willReturn(CGPoint(x: 10, y: 10)) + + let result = mapView.processContextFromGesture(maplibreMapView, + gesture: gesture, + sender: mockTapGesture) + + XCTAssertEqual(result.gestureMethod, .longPress(minimumDuration: 1)) + XCTAssertEqual(result.point, CGPoint(x: 10, y: 10)) + + // TODO: Delete this? The MLNMapView is technically converting something. Probably not reliably, but it could still be useful to track. + XCTAssertEqual(result.coordinate.latitude, 15, accuracy: 1) + XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1) + } +} diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift new file mode 100644 index 0000000..0a7c45c --- /dev/null +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -0,0 +1,172 @@ +import XCTest +import MockableTest +import CoreLocation +@testable import MapLibreSwiftUI + +final class MapViewCoordinatorCameraTests: XCTestCase { + + var maplibreMapView: MockMLNMapViewCamera! + var mapView: MapView! + var coordinator: MapView.Coordinator! + + override func setUp() async throws { + maplibreMapView = MockMLNMapViewCamera() + mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) + coordinator = MapView.Coordinator(parent: mapView) { _, _ in + // No action + } + } + + func testUnchangedCamera() { + let camera: MapViewCamera = .default() + + coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) + // Run a second update. + coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) + + // Note all of the actions only allow 1 count of set even though we've run the action + // twice. + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.none)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.value(MapViewCamera.Defaults.coordinate), + zoomLevel: .value(10), + direction: .value(0), + animated: .value(false)) + .called(count: 1) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.any, animated: .any) + .called(count: 0) + } + + func testCenterCameraUpdate() { + let coordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let newCamera: MapViewCamera = .center(coordinate, zoom: 13) + + coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.none)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.value(coordinate), + zoomLevel: .value(13), + direction: .value(0), + animated: .value(false)) + .called(count: 1) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.any, animated: .any) + .called(count: 0) + } + + func testUserTrackingCameraUpdate() { + let newCamera: MapViewCamera = .trackUserLocation() + + coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.follow)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.any, + zoomLevel: .any, + direction: .any, + animated: .any) + .called(count: 0) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.value(10), animated: .value(false)) + .called(count: 1) + } + + func testUserTrackingWithCourseCameraUpdate() { + let newCamera: MapViewCamera = .trackUserLocationWithCourse() + + coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.followWithCourse)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.any, + zoomLevel: .any, + direction: .any, + animated: .any) + .called(count: 0) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.value(10), animated: .value(false)) + .called(count: 1) + } + + func testUserTrackingWithHeadingUpdate() { + let newCamera: MapViewCamera = .trackUserLocationWithHeading() + + coordinator.updateCamera(mapView: maplibreMapView, camera: newCamera, animated: false) + + verify(maplibreMapView) + .userTrackingMode(newValue: .value(.followWithHeading)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setCenter(.any, + zoomLevel: .any, + direction: .any, + animated: .any) + .called(count: 0) + + verify(maplibreMapView) + .minimumPitch(newValue: .value(0)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .maximumPitch(newValue: .value(60)) + .setterCalled(count: 1) + + verify(maplibreMapView) + .setZoomLevel(.value(10), animated: .value(false)) + .called(count: 1) + } + + // TODO: Test Rect & Showcase once we build it! + +} diff --git a/Tests/MapLibreSwiftUITests/Models/Gesture/MapGestureTests.swift b/Tests/MapLibreSwiftUITests/Models/Gesture/MapGestureTests.swift new file mode 100644 index 0000000..f105153 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/Gesture/MapGestureTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import MapLibreSwiftUI + +final class MapGestureTests: XCTestCase { + + func testTapGestureDefaults() { + let gesture = MapGesture(method: .tap(), + onChange: { _ in }) + + XCTAssertEqual(gesture.method, .tap()) + XCTAssertNil(gesture.gestureRecognizer) + } + + func testTapGesture() { + let gesture = MapGesture(method: .tap(numberOfTaps: 3), + onChange: { _ in }) + + XCTAssertEqual(gesture.method, .tap(numberOfTaps: 3)) + XCTAssertNil(gesture.gestureRecognizer) + } + + func testLongPressGestureDefaults() { + let gesture = MapGesture(method: .longPress(), + onChange: { _ in }) + + XCTAssertEqual(gesture.method, .longPress()) + XCTAssertNil(gesture.gestureRecognizer) + } + + func testLongPressGesture() { + let gesture = MapGesture(method: .longPress(minimumDuration: 3), + onChange: { _ in }) + + XCTAssertEqual(gesture.method, .longPress(minimumDuration: 3)) + XCTAssertNil(gesture.gestureRecognizer) + } +} diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift index c197060..cd265c1 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift @@ -4,9 +4,53 @@ import MapLibre final class CameraChangeReasonTests: XCTestCase { + func testProgrammatic() { + let mlnReason: MLNCameraChangeReason = [.programmatic] + XCTAssertEqual(CameraChangeReason(mlnReason), .programmatic) + } + + func testTransitionCancelled() { + let mlnReason: MLNCameraChangeReason = [.transitionCancelled] + XCTAssertEqual(CameraChangeReason(mlnReason), .transitionCancelled) + } + + func testResetNorth() { + let mlnReason: MLNCameraChangeReason = [.programmatic, .resetNorth] + XCTAssertEqual(CameraChangeReason(mlnReason), .resetNorth) + } + + func testGesturePan() { + let mlnReason: MLNCameraChangeReason = [.gesturePan] + XCTAssertEqual(CameraChangeReason(mlnReason), .gesturePan) + } + + func testGesturePinch() { + let mlnReason: MLNCameraChangeReason = [.gesturePinch] + XCTAssertEqual(CameraChangeReason(mlnReason), .gesturePinch) + } + + func testGestureRotate() { + let mlnReason: MLNCameraChangeReason = [.gestureRotate] + XCTAssertEqual(CameraChangeReason(mlnReason), .gestureRotate) + } + + func testGestureTilt() { + let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureTilt] + XCTAssertEqual(CameraChangeReason(mlnReason), .gestureTilt) + } + + func testGestureZoomIn() { + let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureZoomIn] + XCTAssertEqual(CameraChangeReason(mlnReason), .gestureZoomIn) + } + + func testGestureZoomOut() { + let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureZoomOut] + XCTAssertEqual(CameraChangeReason(mlnReason), .gestureZoomOut) + } + func testGestureOneFingerZoom() { let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureOneFingerZoom] XCTAssertEqual(CameraChangeReason(mlnReason), .gestureOneFingerZoom) } - } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift index 4b44676..4fb56d1 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -8,21 +8,25 @@ final class CameraStateTests: XCTestCase { let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) let state: CameraState = .centered(onCenter: expectedCoordinate) XCTAssertEqual(state, .centered(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) + XCTAssertEqual(String(describing: state), "CameraState.center(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)") } func testTrackingUserLocation() { let state: CameraState = .trackingUserLocation XCTAssertEqual(state, .trackingUserLocation) + XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocation") } func testTrackingUserLocationWithHeading() { let state: CameraState = .trackingUserLocationWithHeading XCTAssertEqual(state, .trackingUserLocationWithHeading) + XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocationWithHeading") } func testTrackingUserLocationWithCourse() { let state: CameraState = .trackingUserLocationWithCourse XCTAssertEqual(state, .trackingUserLocationWithCourse) + XCTAssertEqual(String(describing: state), "CameraState.trackingUserLocationWithCourse") } func testRect() { @@ -31,6 +35,9 @@ final class CameraStateTests: XCTestCase { let state: CameraState = .rect(northeast: northeast, southwest: southwest) XCTAssertEqual(state, .rect(northeast: northeast, southwest: southwest)) + XCTAssertEqual( + String(describing: state), + "CameraState.rect(northeast: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4), southwest: CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6))") } } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index 6e97796..480324a 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -1,6 +1,38 @@ import XCTest +import CoreLocation @testable import MapLibreSwiftUI final class MapViewCameraTests: XCTestCase { + + + func testCenterCamera() { + let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let state: CameraState = .centered(onCenter: expectedCoordinate) + XCTAssertEqual(state, .centered(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) + } + + func testTrackingUserLocation() { + let state: CameraState = .trackingUserLocation + XCTAssertEqual(state, .trackingUserLocation) + } + + func testTrackingUserLocationWithHeading() { + let state: CameraState = .trackingUserLocationWithHeading + XCTAssertEqual(state, .trackingUserLocationWithHeading) + } + + func testTrackingUserLocationWithCourse() { + let state: CameraState = .trackingUserLocationWithCourse + XCTAssertEqual(state, .trackingUserLocationWithCourse) + } + + func testRect() { + let northeast = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let southwest = CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6) + + let state: CameraState = .rect(northeast: northeast, southwest: southwest) + XCTAssertEqual(state, .rect(northeast: northeast, southwest: southwest)) + } + } diff --git a/Tests/MapLibreSwiftUITests/Support/XCTestAssertView.swift b/Tests/MapLibreSwiftUITests/Support/XCTestAssertView.swift new file mode 100644 index 0000000..27a901b --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Support/XCTestAssertView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import XCTest +import SnapshotTesting +import MapLibreSwiftUI + +// TODO: This is a WIP that needs some additional eyes +extension XCTestCase { + + func assertView( + named name: String? = nil, + record: Bool = false, + frame: CGSize = CGSize(width: 430, height: 932), + expectation: XCTestExpectation? = nil, + @ViewBuilder content: () -> Content, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line + ) { + + let view = content() + .frame(width: frame.width, height: frame.height) + + assertSnapshot(matching: view, + as: .image(precision: 0.9, perceptualPrecision: 0.95), + named: name, + record: record, + file: file, + testName: testName, + line: line) + } +} + +// TODO: Figure this out, seems like the exp is being blocked or the map views onStyleLoaded is never run within the test context. +extension Snapshotting { + static func wait( + exp: XCTestExpectation, + timeout: TimeInterval, + on strategy: Self + ) -> Self { + Self( + pathExtension: strategy.pathExtension, + diffing: strategy.diffing, + asyncSnapshot: { value in + Async { callback in + _ = XCTWaiter.wait(for: [exp], timeout: timeout) + strategy.snapshot(value).run(callback) + } + } + ) + } +} + From a96821f874aade72d11174ce5d6a6bf6773010c0 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 7 Feb 2024 17:15:16 -0800 Subject: [PATCH 08/15] Added testing for many of the swiftui mapview behaviors --- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 5 +++-- Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index cda8aaa..2e2c1ea 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -183,12 +183,13 @@ extension MapViewCoordinator: MLNMapViewDelegate { // If any of these are a mismatch, we know the camera is no longer following a desired method, so we should detach and revert // to a .centered camera. + // If any one of these is true, the desired camera state still matches the mapView's userTrackingMode if isFollowing || isFollowingHeading || isFollowingCourse { - // User tracking, we can ignore camera updates until we unset this. + // User tracking is still active, we can ignore camera updates until we unset/fail this boolean check return } - // The user's desired camera is not a user tracking method, now we need to publish back the current mapView state to the camera binding. + // The user's desired camera is not a user tracking method, now we need to publish the MLNMapView's camera state to the MapView camera binding. parent.camera = .center(mapView.centerCoordinate, zoom: mapView.zoomLevel, reason: CameraChangeReason(reason)) diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index 1a2b9c3..02e05c9 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -8,6 +8,9 @@ public enum CameraState: Hashable { case centered(onCenter: CLLocationCoordinate2D) /// Follow the user's location using the MapView's internal camera. + /// + /// This feature uses the MLNMapView's userTrackingMode to .follow which automatically + /// follows the user from within the MLNMapView. case trackingUserLocation /// Follow the user's location using the MapView's internal camera with the user's heading. From d1fc6b5bccf1b14bf14c18a5bdc2c7d7ed8f096a Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 7 Feb 2024 17:17:49 -0800 Subject: [PATCH 09/15] Added testing for many of the swiftui mapview behaviors --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 661a055..f367042 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", "state" : { - "revision" : "3b88d990bc39ed5347852a536c0efd942a07fc97", - "version" : "6.0.0-pre9599200f2529de44ba62d4662cddb445dc19397d" + "revision" : "3df876f8f2c6c591b0f66a29b3e216020afc885c", + "version" : "6.0.0" } }, { diff --git a/Package.swift b/Package.swift index 2fde427..4ff6b59 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( ], dependencies: [ // .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution", .upToNextMajor(from: "5.13.0")), - .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.0.0-pre9599200f2529de44ba62d4662cddb445dc19397d"), + .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.0.0-preab83392d13e3b4259e8faa9db56ab17671aa112e"), .package(url: "https://github.com/stadiamaps/maplibre-swift-macros.git", branch: "main"), // Testing .package(url: "https://github.com/Kolos65/Mockable.git", from: "0.0.2"), From 622dd75b50389d14b0c0caf30c971dd1551ae4e1 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 7 Feb 2024 18:06:25 -0800 Subject: [PATCH 10/15] Updated version to latest --- Package.swift | 2 +- Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 4ff6b59..3deb313 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( ], dependencies: [ // .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution", .upToNextMajor(from: "5.13.0")), - .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.0.0-preab83392d13e3b4259e8faa9db56ab17671aa112e"), + .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.0.0"), .package(url: "https://github.com/stadiamaps/maplibre-swift-macros.git", branch: "main"), // Testing .package(url: "https://github.com/Kolos65/Mockable.git", from: "0.0.2"), diff --git a/Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift b/Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift index 43aa44f..7d40850 100644 --- a/Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift +++ b/Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift @@ -3,7 +3,7 @@ import Foundation /// Enforces a basic set of result builder definiitons. /// /// This is just a tool to make a result builder easier to build, maintain sorting, etc. -public protocol DefaultResultBuilder { +protocol DefaultResultBuilder { associatedtype Component From 5925c2dc3c4acf3403741bd272e25a5878305bea Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 7 Feb 2024 18:07:07 -0800 Subject: [PATCH 11/15] Updated version to latest --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 3deb313..4697fe7 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,6 @@ let package = Package( targets: ["MapLibreSwiftDSL"]), ], dependencies: [ -// .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution", .upToNextMajor(from: "5.13.0")), .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", from: "6.0.0"), .package(url: "https://github.com/stadiamaps/maplibre-swift-macros.git", branch: "main"), // Testing From c1eb952db44c6df72cbe0008ea9c1d79441cde90 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 7 Feb 2024 19:02:26 -0800 Subject: [PATCH 12/15] Updated version to latest --- ...a.swift => MLNMapViewCameraUpdating.swift} | 4 ++-- .../Extensions/MapView/MapViewGestures.swift | 2 +- ...nizer.swift => UIGestureRecognizing.swift} | 4 ++-- .../MapLibreSwiftUI/MapViewCoordinator.swift | 6 ++--- .../MapLibreSwiftUI/MapViewModifiers.swift | 2 +- .../Models/Gesture/MapGesture.swift | 2 +- .../Models/MapCamera/CameraChangeReason.swift | 6 ++--- .../Models/MapCamera/CameraPitch.swift | 4 ++-- .../Models/MapCamera/CameraState.swift | 6 ++--- .../Models/MapCamera/MapViewCamera.swift | 12 +++++----- .../MapView/MapViewGestureTests.swift | 22 ++++++++++--------- .../MapViewCoordinatorCameraTests.swift | 11 +++++----- .../MapCamera/CameraChangeReasonTests.swift | 2 +- .../Models/MapCamera/CameraPitchTests.swift | 2 +- .../Models/MapCamera/CameraStateTests.swift | 6 ++--- .../Models/MapCamera/MapViewCameraTests.swift | 6 ++--- 16 files changed, 49 insertions(+), 48 deletions(-) rename Sources/MapLibreSwiftUI/Extensions/MapLibre/{MLNMapViewCamera.swift => MLNMapViewCameraUpdating.swift} (83%) rename Sources/MapLibreSwiftUI/Extensions/UIKit/{UIGestureRecognizer.swift => UIGestureRecognizing.swift} (67%) diff --git a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift similarity index 83% rename from Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift rename to Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift index b19012e..d3f2e17 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift @@ -4,7 +4,7 @@ import MapLibre import Mockable @Mockable -protocol MLNMapViewCamera: AnyObject { +protocol MLNMapViewCameraUpdating: AnyObject { var userTrackingMode: MLNUserTrackingMode { get set } var minimumPitch: CGFloat { get set } var maximumPitch: CGFloat { get set } @@ -15,6 +15,6 @@ protocol MLNMapViewCamera: AnyObject { func setZoomLevel(_ zoomLevel: Double, animated: Bool) } -extension MLNMapView: MLNMapViewCamera { +extension MLNMapView: MLNMapViewCameraUpdating { // No definition } diff --git a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift index e9302df..0e2e914 100644 --- a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift +++ b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift @@ -58,7 +58,7 @@ extension MapView { /// - gesture: The gesture definition for this event. /// - sender: The UIKit gesture emitting from the map view. /// - Returns: The calculated context from the sending UIKit gesture - func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, sender: UIGestureRecognizerProtocol) -> MapGestureContext { + func processContextFromGesture(_ mapView: MLNMapView, gesture: MapGesture, sender: UIGestureRecognizing) -> MapGestureContext { // Build the context of the gesture's event. var point: CGPoint switch gesture.method { diff --git a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift similarity index 67% rename from Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift rename to Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift index 267a901..eb3b480 100644 --- a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizer.swift +++ b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift @@ -2,12 +2,12 @@ import UIKit import Mockable @Mockable -protocol UIGestureRecognizerProtocol: AnyObject { +protocol UIGestureRecognizing: AnyObject { var state: UIGestureRecognizer.State { get } func location(in view: UIView?) -> CGPoint func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint } -extension UIGestureRecognizer: UIGestureRecognizerProtocol { +extension UIGestureRecognizer: UIGestureRecognizing { // No definition } diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 2e2c1ea..39bfd5f 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -33,14 +33,14 @@ public class MapViewCoordinator: NSObject { // MARK: - Coordinator API - Camera + Manipulation - func updateCamera(mapView: MLNMapViewCamera, camera: MapViewCamera, animated: Bool) { + func updateCamera(mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool) { guard camera != snapshotCamera else { // No action - camera has not changed. return } switch camera.state { - case .centered(let coordinate): + case .coordinate(let coordinate): mapView.userTrackingMode = .none mapView.setCenter(coordinate, zoomLevel: camera.zoom, @@ -170,8 +170,8 @@ public class MapViewCoordinator: NSObject { extension MapViewCoordinator: MLNMapViewDelegate { public func mapView(_ mapView: MLNMapView, didFinishLoading mglStyle: MLNStyle) { - onStyleLoaded?(mglStyle) addLayers(to: mglStyle) + onStyleLoaded?(mglStyle) } /// The MapView's region has changed with a specific reason. diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift index 174cb44..71319f9 100644 --- a/Sources/MapLibreSwiftUI/MapViewModifiers.swift +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -7,7 +7,7 @@ import MapLibre extension MapView { - /// Perform an action when the map view has loaded its style. + /// 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. diff --git a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift index 7a444f9..f299030 100644 --- a/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift @@ -24,7 +24,7 @@ public class MapGesture: NSObject { let onChange: (MapGestureContext) -> Void /// The underlying gesture recognizer - var gestureRecognizer: UIGestureRecognizer? + weak var gestureRecognizer: UIGestureRecognizer? /// Create a new gesture recognizer definition for the MapView /// diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift index a2b5039..adc82d0 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -15,11 +15,11 @@ public enum CameraChangeReason: Hashable { /// Initialize a Swift CameraChangeReason from the MLN NSOption. /// - /// This method will only show the largest reason. If you need a full history of the full bit range, - /// use MLNCameraChangeReason directly + /// This method will only show the largest bitwise reason contained in MLNCameraChangeReason. + /// 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) { + init?(_ mlnCameraChangeReason: MLNCameraChangeReason) { switch mlnCameraChangeReason.largestBitwiseReason { case .programmatic: diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift index a6c7492..56dbc83 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift @@ -8,7 +8,7 @@ public enum CameraPitch: Hashable { case free /// The user is free to control pitch within the minimum and maximum range. - case withinRange(minimum: Double, maximum: Double) + case freeWithinRange(minimum: Double, maximum: Double) /// The pitch is fixed to a certain value. case fixed(Double) @@ -21,7 +21,7 @@ public enum CameraPitch: Hashable { case .free: return 0...60 // TODO: set this to a maplibre constant (this is available on Android, but maybe not iOS)? - case .withinRange(minimum: let minimum, maximum: let maximum): + case .freeWithinRange(minimum: let minimum, maximum: let maximum): return minimum...maximum case .fixed(let value): return value...value diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index 02e05c9..2ce6225 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -5,7 +5,7 @@ import MapLibre public enum CameraState: Hashable { /// Centered on a coordinate - case centered(onCenter: CLLocationCoordinate2D) + case coordinate(onCenter: CLLocationCoordinate2D) /// Follow the user's location using the MapView's internal camera. /// @@ -36,8 +36,8 @@ extension CameraState: CustomDebugStringConvertible { public var debugDescription: String { switch self { - case .centered(onCenter: let onCenter): - return "CameraState.center(onCenter: \(onCenter)" + case .coordinate(onCenter: let onCenter): + return "CameraState.coordinate(onCenter: \(onCenter)" case .trackingUserLocation: return "CameraState.trackingUserLocation" case .trackingUserLocationWithHeading: diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift index 7196df6..84be6fc 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -31,7 +31,7 @@ public struct MapViewCamera: Hashable { /// /// - Returns: The constructed MapViewCamera. public static func `default`() -> MapViewCamera { - return MapViewCamera(state: .centered(onCenter: Defaults.coordinate), + return MapViewCamera(state: .coordinate(onCenter: Defaults.coordinate), zoom: Defaults.zoom, pitch: Defaults.pitch, direction: Defaults.direction, @@ -43,7 +43,7 @@ public struct MapViewCamera: Hashable { /// - Parameters: /// - coordinate: The coordinate to center the map on. /// - zoom: The zoom level. - /// - pitch: The camera pitch. Default is 90 (straight down). + /// - pitch: Set the camera pitch method. /// - direction: The course. Default is 0 (North). /// - Returns: The constructed MapViewCamera. public static func center(_ coordinate: CLLocationCoordinate2D, @@ -52,7 +52,7 @@ public struct MapViewCamera: Hashable { direction: CLLocationDirection = Defaults.direction, reason: CameraChangeReason? = nil) -> MapViewCamera { - return MapViewCamera(state: .centered(onCenter: coordinate), + return MapViewCamera(state: .coordinate(onCenter: coordinate), zoom: zoom, pitch: pitch, direction: direction, @@ -65,7 +65,7 @@ public struct MapViewCamera: Hashable { /// /// - Parameters: /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike pitch. - /// - pitch: Provide a fixed pitch value. The user will not be able to adjust pitch using gestures when this is set. Use nil/default to allow user control. + /// - pitch: Set the camera pitch method. /// - Returns: The MapViewCamera representing the scenario public static func trackUserLocation(zoom: Double = Defaults.zoom, pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { @@ -84,7 +84,7 @@ public struct MapViewCamera: Hashable { /// /// - Parameters: /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike pitch. - /// - pitch: Provide a fixed pitch value. The user will not be able to adjust pitch using gestures when this is set. Use nil/default to allow user control. + /// - 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 { @@ -103,7 +103,7 @@ public struct MapViewCamera: Hashable { /// /// - Parameters: /// - zoom: Set the desired zoom. This is a one time event and the user can manipulate their zoom after unlike pitch. - /// - pitch: Provide a fixed pitch value. The user will not be able to adjust pitch using gestures when this is set. Use nil/default to allow user control. + /// - 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 { diff --git a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift index 417ac34..e4b8e3a 100644 --- a/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -11,13 +11,17 @@ final class MapViewGestureTests: XCTestCase { // MARK: Gesture View Modifiers func testMapViewOnTapGestureModifier() { - let newMapView = mapView.onTapMapGesture { _ in } + let newMapView = mapView.onTapMapGesture { _ in + // Do nothing + } XCTAssertEqual(newMapView.gestures.first?.method, .tap()) } func testMapViewOnLongPressGestureModifier() { - let newMapView = mapView.onLongPressMapGesture { _ in } + let newMapView = mapView.onLongPressMapGesture { _ in + // Do nothing + } XCTAssertEqual(newMapView.gestures.first?.method, .longPress()) } @@ -26,10 +30,10 @@ final class MapViewGestureTests: XCTestCase { func testTapGesture() { let gesture = MapGesture(method: .tap(numberOfTaps: 2)) { _ in - // No capture + // Do nothing } - let mockTapGesture = MockUIGestureRecognizerProtocol() + let mockTapGesture = MockUIGestureRecognizing() given(mockTapGesture) .state.willReturn(.ended) @@ -44,18 +48,17 @@ final class MapViewGestureTests: XCTestCase { XCTAssertEqual(result.gestureMethod, .tap(numberOfTaps: 2)) XCTAssertEqual(result.point, CGPoint(x: 10, y: 10)) - - // TODO: Delete this? The MLNMapView is technically converting something. Probably not reliably, but it could still be useful to track. + // This is what the un-rendered map view returns. We're simply testing it returns something. XCTAssertEqual(result.coordinate.latitude, 15, accuracy: 1) XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1) } func testLongPressGesture() { let gesture = MapGesture(method: .longPress(minimumDuration: 1)) { _ in - // No capture + // Do nothing } - let mockTapGesture = MockUIGestureRecognizerProtocol() + let mockTapGesture = MockUIGestureRecognizing() given(mockTapGesture) .state.willReturn(.ended) @@ -70,8 +73,7 @@ final class MapViewGestureTests: XCTestCase { XCTAssertEqual(result.gestureMethod, .longPress(minimumDuration: 1)) XCTAssertEqual(result.point, CGPoint(x: 10, y: 10)) - - // TODO: Delete this? The MLNMapView is technically converting something. Probably not reliably, but it could still be useful to track. + // This is what the un-rendered map view returns. We're simply testing it returns something. XCTAssertEqual(result.coordinate.latitude, 15, accuracy: 1) XCTAssertEqual(result.coordinate.longitude, -15, accuracy: 1) } diff --git a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift index 0a7c45c..39a2f64 100644 --- a/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -5,12 +5,12 @@ import CoreLocation final class MapViewCoordinatorCameraTests: XCTestCase { - var maplibreMapView: MockMLNMapViewCamera! + var maplibreMapView: MockMLNMapViewCameraUpdating! var mapView: MapView! var coordinator: MapView.Coordinator! override func setUp() async throws { - maplibreMapView = MockMLNMapViewCamera() + maplibreMapView = MockMLNMapViewCameraUpdating() mapView = MapView(styleURL: URL(string: "https://maplibre.org")!) coordinator = MapView.Coordinator(parent: mapView) { _, _ in // No action @@ -21,11 +21,12 @@ final class MapViewCoordinatorCameraTests: XCTestCase { let camera: MapViewCamera = .default() coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) - // Run a second update. + // Run a second update. We're testing that the snapshotCamera correctly exits the function + // when nothing changed. coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) - // Note all of the actions only allow 1 count of set even though we've run the action - // twice. + // All of the actions only allow 1 count of set even though we've run the action twice. + // This verifies the comment above. verify(maplibreMapView) .userTrackingMode(newValue: .value(.none)) .setterCalled(count: 1) diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift index cd265c1..3cfaa8e 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift @@ -40,7 +40,7 @@ final class CameraChangeReasonTests: XCTestCase { } func testGestureZoomIn() { - let mlnReason: MLNCameraChangeReason = [.programmatic, .gestureZoomIn] + let mlnReason: MLNCameraChangeReason = [.gestureZoomIn, .programmatic, ] XCTAssertEqual(CameraChangeReason(mlnReason), .gestureZoomIn) } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift index 71d1b2b..36c5401 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift @@ -10,7 +10,7 @@ final class CameraPitchTests: XCTestCase { } func testRangePitch() { - let pitch = CameraPitch.withinRange(minimum: 9, maximum: 29) + let pitch = CameraPitch.freeWithinRange(minimum: 9, maximum: 29) XCTAssertEqual(pitch.rangeValue.lowerBound, 9) XCTAssertEqual(pitch.rangeValue.upperBound, 29) } diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift index 4fb56d1..b8eef86 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -6,9 +6,9 @@ final class CameraStateTests: XCTestCase { func testCenterCameraState() { let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) - let state: CameraState = .centered(onCenter: expectedCoordinate) - XCTAssertEqual(state, .centered(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) - XCTAssertEqual(String(describing: state), "CameraState.center(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)") + let state: CameraState = .coordinate(onCenter: expectedCoordinate) + XCTAssertEqual(state, .coordinate(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) + XCTAssertEqual(String(describing: state), "CameraState.coordinate(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)") } func testTrackingUserLocation() { diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index 480324a..69bff3a 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -4,12 +4,10 @@ import CoreLocation final class MapViewCameraTests: XCTestCase { - - func testCenterCamera() { let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) - let state: CameraState = .centered(onCenter: expectedCoordinate) - XCTAssertEqual(state, .centered(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) + let state: CameraState = .coordinate(onCenter: expectedCoordinate) + XCTAssertEqual(state, .coordinate(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) } func testTrackingUserLocation() { From 6187b1af1c94cc799bc36d3e27be08cdbcf8a0bd Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 7 Feb 2024 19:02:50 -0800 Subject: [PATCH 13/15] Updated version to latest --- .../Models/MapCamera/MapViewCameraTests.swift | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index 69bff3a..0d8b374 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -3,34 +3,35 @@ import CoreLocation @testable import MapLibreSwiftUI final class MapViewCameraTests: XCTestCase { - - func testCenterCamera() { - let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) - let state: CameraState = .coordinate(onCenter: expectedCoordinate) - XCTAssertEqual(state, .coordinate(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) - } - - func testTrackingUserLocation() { - let state: CameraState = .trackingUserLocation - XCTAssertEqual(state, .trackingUserLocation) - } - - func testTrackingUserLocationWithHeading() { - let state: CameraState = .trackingUserLocationWithHeading - XCTAssertEqual(state, .trackingUserLocationWithHeading) - } - - func testTrackingUserLocationWithCourse() { - let state: CameraState = .trackingUserLocationWithCourse - XCTAssertEqual(state, .trackingUserLocationWithCourse) - } - - func testRect() { - let northeast = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) - let southwest = CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6) - - let state: CameraState = .rect(northeast: northeast, southwest: southwest) - XCTAssertEqual(state, .rect(northeast: northeast, southwest: southwest)) - } + + // TODO: Write all the camera tests +// func testCenterCamera() { +// let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) +// let state: CameraState = .coordinate(onCenter: expectedCoordinate) +// XCTAssertEqual(state, .coordinate(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) +// } +// +// func testTrackingUserLocation() { +// let state: CameraState = .trackingUserLocation +// XCTAssertEqual(state, .trackingUserLocation) +// } +// +// func testTrackingUserLocationWithHeading() { +// let state: CameraState = .trackingUserLocationWithHeading +// XCTAssertEqual(state, .trackingUserLocationWithHeading) +// } +// +// func testTrackingUserLocationWithCourse() { +// let state: CameraState = .trackingUserLocationWithCourse +// XCTAssertEqual(state, .trackingUserLocationWithCourse) +// } +// +// func testRect() { +// let northeast = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) +// let southwest = CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6) +// +// let state: CameraState = .rect(northeast: northeast, southwest: southwest) +// XCTAssertEqual(state, .rect(northeast: northeast, southwest: southwest)) +// } } From d3d0ec8d2cd4f4c1586c8b75ca1312ba50b79480 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 7 Feb 2024 19:10:16 -0800 Subject: [PATCH 14/15] Updated version to latest --- .../Models/MapCamera/MapViewCameraTests.swift | 72 +++++++++++-------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index 0d8b374..9d329f2 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -4,34 +4,48 @@ import CoreLocation final class MapViewCameraTests: XCTestCase { - // TODO: Write all the camera tests -// func testCenterCamera() { -// let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) -// let state: CameraState = .coordinate(onCenter: expectedCoordinate) -// XCTAssertEqual(state, .coordinate(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) -// } -// -// func testTrackingUserLocation() { -// let state: CameraState = .trackingUserLocation -// XCTAssertEqual(state, .trackingUserLocation) -// } -// -// func testTrackingUserLocationWithHeading() { -// let state: CameraState = .trackingUserLocationWithHeading -// XCTAssertEqual(state, .trackingUserLocationWithHeading) -// } -// -// func testTrackingUserLocationWithCourse() { -// let state: CameraState = .trackingUserLocationWithCourse -// XCTAssertEqual(state, .trackingUserLocationWithCourse) -// } -// -// func testRect() { -// let northeast = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) -// let southwest = CLLocationCoordinate2D(latitude: 34.5, longitude: 45.6) -// -// let state: CameraState = .rect(northeast: northeast, southwest: southwest) -// XCTAssertEqual(state, .rect(northeast: northeast, southwest: southwest)) -// } + func testCenterCamera() { + let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) + let direction: CLLocationDirection = 23 + + let camera = MapViewCamera.center(expectedCoordinate, zoom: 12, pitch: pitch, direction: direction) + + XCTAssertEqual(camera.state, .coordinate(onCenter: expectedCoordinate)) + XCTAssertEqual(camera.zoom, 12) + XCTAssertEqual(camera.pitch, pitch) + XCTAssertEqual(camera.direction, direction) + } + + func testTrackingUserLocation() { + let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) + let camera = MapViewCamera.trackUserLocation(pitch: pitch) + + XCTAssertEqual(camera.state, .trackingUserLocation) + XCTAssertEqual(camera.zoom, 10) + XCTAssertEqual(camera.pitch, pitch) + XCTAssertEqual(camera.direction, 0) + } + + func testTrackUserLocationWithCourse() { + let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) + let camera = MapViewCamera.trackUserLocationWithCourse(zoom: 18, pitch: pitch) + + XCTAssertEqual(camera.state, .trackingUserLocationWithCourse) + XCTAssertEqual(camera.zoom, 18) + XCTAssertEqual(camera.pitch, pitch) + XCTAssertEqual(camera.direction, 0) + } + + func testTrackUserLocationWithHeading() { + let camera = MapViewCamera.trackUserLocationWithHeading() + + XCTAssertEqual(camera.state, .trackingUserLocationWithHeading) + XCTAssertEqual(camera.zoom, 10) + XCTAssertEqual(camera.pitch, .free) + XCTAssertEqual(camera.direction, 0) + } + + // TODO: Add additional camera tests once behaviors are added (e.g. rect) } From ad561ea54605baa5558eef7faad67dae13a9bf0f Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 7 Feb 2024 19:19:29 -0800 Subject: [PATCH 15/15] Updated version to latest --- Sources/MapLibreSwiftUI/MapViewCoordinator.swift | 10 ++++++++-- .../MapLibreSwiftUI/Models/MapCamera/CameraState.swift | 6 +++--- .../Models/MapCamera/MapViewCamera.swift | 4 ++-- .../Models/MapCamera/CameraStateTests.swift | 6 +++--- .../Models/MapCamera/MapViewCameraTests.swift | 2 +- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift index 39bfd5f..9f9ceff 100644 --- a/Sources/MapLibreSwiftUI/MapViewCoordinator.swift +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -32,7 +32,13 @@ public class MapViewCoordinator: NSObject { } // 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. func updateCamera(mapView: MLNMapViewCameraUpdating, camera: MapViewCamera, animated: Bool) { guard camera != snapshotCamera else { // No action - camera has not changed. @@ -40,7 +46,7 @@ public class MapViewCoordinator: NSObject { } switch camera.state { - case .coordinate(let coordinate): + case .centered(onCoordinate: let coordinate): mapView.userTrackingMode = .none mapView.setCenter(coordinate, zoomLevel: camera.zoom, diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift index 2ce6225..1ff3e42 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -5,7 +5,7 @@ import MapLibre public enum CameraState: Hashable { /// Centered on a coordinate - case coordinate(onCenter: CLLocationCoordinate2D) + case centered(onCoordinate: CLLocationCoordinate2D) /// Follow the user's location using the MapView's internal camera. /// @@ -36,8 +36,8 @@ extension CameraState: CustomDebugStringConvertible { public var debugDescription: String { switch self { - case .coordinate(onCenter: let onCenter): - return "CameraState.coordinate(onCenter: \(onCenter)" + case .centered(onCoordinate: let onCoordinate): + return "CameraState.centered(onCoordinate: \(onCoordinate)" case .trackingUserLocation: return "CameraState.trackingUserLocation" case .trackingUserLocationWithHeading: diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift index 84be6fc..cdaa807 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -31,7 +31,7 @@ public struct MapViewCamera: Hashable { /// /// - Returns: The constructed MapViewCamera. public static func `default`() -> MapViewCamera { - return MapViewCamera(state: .coordinate(onCenter: Defaults.coordinate), + return MapViewCamera(state: .centered(onCoordinate: Defaults.coordinate), zoom: Defaults.zoom, pitch: Defaults.pitch, direction: Defaults.direction, @@ -52,7 +52,7 @@ public struct MapViewCamera: Hashable { direction: CLLocationDirection = Defaults.direction, reason: CameraChangeReason? = nil) -> MapViewCamera { - return MapViewCamera(state: .coordinate(onCenter: coordinate), + return MapViewCamera(state: .centered(onCoordinate: coordinate), zoom: zoom, pitch: pitch, direction: direction, diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift index b8eef86..b0f42ec 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -6,9 +6,9 @@ final class CameraStateTests: XCTestCase { func testCenterCameraState() { let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) - let state: CameraState = .coordinate(onCenter: expectedCoordinate) - XCTAssertEqual(state, .coordinate(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) - XCTAssertEqual(String(describing: state), "CameraState.coordinate(onCenter: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)") + let state: CameraState = .centered(onCoordinate: expectedCoordinate) + XCTAssertEqual(state, .centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4))) + XCTAssertEqual(String(describing: state), "CameraState.centered(onCoordinate: CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4)") } func testTrackingUserLocation() { diff --git a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift index 9d329f2..70eb264 100644 --- a/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -11,7 +11,7 @@ final class MapViewCameraTests: XCTestCase { let camera = MapViewCamera.center(expectedCoordinate, zoom: 12, pitch: pitch, direction: direction) - XCTAssertEqual(camera.state, .coordinate(onCenter: expectedCoordinate)) + XCTAssertEqual(camera.state, .centered(onCoordinate: expectedCoordinate)) XCTAssertEqual(camera.zoom, 12) XCTAssertEqual(camera.pitch, pitch) XCTAssertEqual(camera.direction, direction)