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 { + +}