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/.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 } + } - public static func buildEither(first layer: [StyleLayerDefinition]) -> [StyleLayerDefinition] { - return layer + public static func buildEither(first components: [StyleLayerDefinition]) -> [StyleLayerDefinition] { + return components } - public static func buildEither(second layer: [StyleLayerDefinition]) -> [StyleLayerDefinition] { - return layer + public static func buildEither(second components: [StyleLayerDefinition]) -> [StyleLayerDefinition] { + return components + } + + public static func buildOptional(_ components: [StyleLayerDefinition]?) -> [StyleLayerDefinition] { + return components ?? [] + } + + // 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 82% rename from Sources/MapLibreSwiftDSL/Data Sources.swift rename to Sources/MapLibreSwiftDSL/ShapeDataBuilder.swift index 561d20b..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,21 +54,34 @@ 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 } } + 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/Support/DefaultResultBuilder.swift b/Sources/MapLibreSwiftDSL/Support/DefaultResultBuilder.swift new file mode 100644 index 0000000..7d40850 --- /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. +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..4d4e483 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -7,11 +7,16 @@ 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("\(camera.coordinate.latitude), \(camera.coordinate.longitude) z \(camera.zoom)") + Text("\(String(describing: camera.state)) z \(camera.zoom)") .padding() .foregroundColor(.white) .background( @@ -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/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..40f206b --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNCameraChangeReason.swift @@ -0,0 +1,24 @@ +import Foundation +import MapLibre + +extension 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 + + 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/Extensions/MapLibre/MLNMapViewCameraUpdating.swift b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift new file mode 100644 index 0000000..d3f2e17 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/MapLibre/MLNMapViewCameraUpdating.swift @@ -0,0 +1,20 @@ +import Foundation +import CoreLocation +import MapLibre +import Mockable + +@Mockable +protocol MLNMapViewCameraUpdating: AnyObject { + var userTrackingMode: MLNUserTrackingMode { get set } + var minimumPitch: CGFloat { get set } + var maximumPitch: CGFloat { get set } + func setCenter(_ coordinate: CLLocationCoordinate2D, + zoomLevel: Double, + direction: CLLocationDirection, + animated: Bool) + func setZoomLevel(_ zoomLevel: Double, animated: Bool) +} + +extension MLNMapView: MLNMapViewCameraUpdating { + // No definition +} diff --git a/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift new file mode 100644 index 0000000..0e2e914 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/MapView/MapViewGestures.swift @@ -0,0 +1,79 @@ +import Foundation +import SwiftUI +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 + } + + // 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: UIGestureRecognizing) -> 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) + } + + return MapGestureContext(gestureMethod: gesture.method, + state: sender.state, + point: point, + coordinate: mapView.convert(point, toCoordinateFrom: mapView)) + } +} diff --git a/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift new file mode 100644 index 0000000..eb3b480 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Extensions/UIKit/UIGestureRecognizing.swift @@ -0,0 +1,13 @@ +import UIKit +import Mockable + +@Mockable +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: UIGestureRecognizing { + // No definition +} 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..159d7f5 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -5,14 +5,17 @@ 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]() + var onStyleLoaded: ((MLNStyle) -> Void)? + /// '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 +23,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 +56,31 @@ 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 - + + // 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) + } + 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,22 +89,18 @@ public struct MapView: UIViewRepresentable { let isStyleLoaded = mapView.style != nil context.coordinator.updateCamera(mapView: mapView, - camera: camera.wrappedValue, + camera: $camera.wrappedValue, animated: isStyleLoaded) } } -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 new file mode 100644 index 0000000..9f9ceff --- /dev/null +++ b/Sources/MapLibreSwiftUI/MapViewCoordinator.swift @@ -0,0 +1,203 @@ +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? + var onStyleLoaded: ((MLNStyle) -> Void)? + 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 + + /// 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. + return + } + + switch camera.state { + case .centered(onCoordinate: let coordinate): + mapView.userTrackingMode = .none + mapView.setCenter(coordinate, + zoomLevel: camera.zoom, + direction: camera.direction, + 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 + } + + // Set the correct pitch range. + mapView.minimumPitch = camera.pitch.rangeValue.lowerBound + mapView.maximumPitch = camera.pitch.rangeValue.upperBound + + 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) + onStyleLoaded?(mglStyle) + } + + /// The MapView's region has changed with a specific reason. + public func mapView(_ mapView: MLNMapView, regionDidChangeWith reason: MLNCameraChangeReason, animated: Bool) { + // Validate that the mapView.userTrackingMode still matches our desired camera state for each tracking type. + let isFollowing = parent.camera.state == .trackingUserLocation && mapView.userTrackingMode == .follow + let isFollowingHeading = parent.camera.state == .trackingUserLocationWithHeading && mapView.userTrackingMode == .followWithHeading + let isFollowingCourse = parent.camera.state == .trackingUserLocationWithCourse && mapView.userTrackingMode == .followWithCourse + + // If any of these are a mismatch, we know the camera is no longer following a desired method, so we should detach and revert + // to a .centered camera. + // If any one of these is true, the desired camera state still matches the mapView's userTrackingMode + if isFollowing || isFollowingHeading || isFollowingCourse { + // User tracking is still active, we can ignore camera updates until we unset/fail this boolean check + return + } + + // The user's desired camera is not a user tracking method, now we need to publish the MLNMapView's camera state to the MapView camera binding. + parent.camera = .center(mapView.centerCoordinate, + zoom: mapView.zoomLevel, + reason: CameraChangeReason(reason)) + } +} diff --git a/Sources/MapLibreSwiftUI/MapViewModifiers.swift b/Sources/MapLibreSwiftUI/MapViewModifiers.swift new file mode 100644 index 0000000..71319f9 --- /dev/null +++ b/Sources/MapLibreSwiftUI/MapViewModifiers.swift @@ -0,0 +1,87 @@ +// 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 { + + /// 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. + 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. + /// 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 + + /// Add an tap gesture handler to the MapView + /// + /// - Parameters: + /// - count: The number of taps required to run the gesture. + /// - onTapChanged: Emits the context whenever the gesture changes (e.g. began, ended, etc). + /// - Returns: The modified map view. + 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(numberOfTaps: count), + onChange: onTapChanged) + newMapView.gestures.append(gesture) + + 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 + + // Build the gesture and link it to the map view. + 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 new file mode 100644 index 0000000..f299030 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGesture.swift @@ -0,0 +1,38 @@ +import UIKit + +public class MapGesture: NSObject { + + public enum Method: Equatable { + + /// A standard tap gesture (UITapGestureRecognizer) + /// + /// - Parameters: + /// - numberOfTaps: The number of taps required for the gesture to trigger + case tap(numberOfTaps: Int = 1) + + /// A standard long press gesture (UILongPressGestureRecognizer) + /// + /// - Parameters: + /// - minimumDuration: The minimum duration of the press in seconds. + case longPress(minimumDuration: Double = 0.5) + } + + /// The Gesture's method, this is used to register it for the correct user interaction on the MapView. + let method: Method + + /// The onChange action that runs when the gesture changes on the map view. + let onChange: (MapGestureContext) -> Void + + /// The underlying gesture recognizer + weak var gestureRecognizer: UIGestureRecognizer? + + /// Create a new gesture recognizer definition for the MapView + /// + /// - Parameters: + /// - method: The gesture recognizer method + /// - onChange: The action to perform when the gesture is changed + init(method: Method, onChange: @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 new file mode 100644 index 0000000..0394e1b --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/Gesture/MapGestureContext.swift @@ -0,0 +1,18 @@ +import UIKit +import CoreLocation + +/// The contextual representation of the gesture. +public struct MapGestureContext { + + /// The map gesture that produced the context. + 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 +} diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift new file mode 100644 index 0000000..adc82d0 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraChangeReason.swift @@ -0,0 +1,49 @@ +import Foundation +import MapLibre + +public enum CameraChangeReason: Hashable { + 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 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 + init?(_ mlnCameraChangeReason: MLNCameraChangeReason) { + switch mlnCameraChangeReason.largestBitwiseReason { + + 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: + return nil + } + } +} diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraPitch.swift new file mode 100644 index 0000000..56dbc83 --- /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 freeWithinRange(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 .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 3860508..1ff3e42 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -2,36 +2,52 @@ 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 { /// Centered on a coordinate - case centered + case centered(onCoordinate: CLLocationCoordinate2D) - /// The camera is currently following a location provider. + /// 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. + /// + /// 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: Equatable { - - public static func ==(lhs: CameraState, rhs: CameraState) -> Bool { - switch (lhs, rhs) { +extension CameraState: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { - case (.centered, .centered): - return true - case (.trackingUserLocation, .trackingUserLocation): - return true - case (.rect, .rect): - return true - case (.showcase, .showcase): - return true - default: - return false + case .centered(onCoordinate: let onCoordinate): + return "CameraState.centered(onCoordinate: \(onCoordinate)" + case .trackingUserLocation: + return "CameraState.trackingUserLocation" + case .trackingUserLocationWithHeading: + return "CameraState.trackingUserLocationWithHeading" + case .trackingUserLocationWithCourse: + return "CameraState.trackingUserLocationWithCourse" + case .rect(northeast: let northeast, southwest: let southwest): + return "CameraState.rect(northeast: \(northeast), southwest: \(southwest))" + case .showcase(shapeCollection: let shapeCollection): + return "CameraState.showcase(shapeCollection: \(shapeCollection))" } } } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift index 0787544..cdaa807 100644 --- a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -1,13 +1,29 @@ import Foundation import CoreLocation +import MapLibre -public struct MapViewCamera { +/// 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: 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. + /// + /// 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 @@ -15,11 +31,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) + return MapViewCamera(state: .centered(onCoordinate: Defaults.coordinate), + zoom: Defaults.zoom, + pitch: Defaults.pitch, + direction: Defaults.direction, + lastReasonForChange: .programmatic) } /// Center the map on a specific location. @@ -27,43 +43,78 @@ public struct MapViewCamera { /// - Parameters: /// - 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). + /// - pitch: Set the camera pitch method. + /// - direction: The course. Default is 0 (North). /// - Returns: The constructed MapViewCamera. public static func center(_ coordinate: CLLocationCoordinate2D, zoom: Double, - pitch: Double = 90.0, - course: Double = 0) -> MapViewCamera { + pitch: CameraPitch = Defaults.pitch, + direction: CLLocationDirection = Defaults.direction, + reason: CameraChangeReason? = nil) -> MapViewCamera { - return MapViewCamera(state: .centered, - coordinate: coordinate, + return MapViewCamera(state: .centered(onCoordinate: coordinate), zoom: zoom, pitch: pitch, - course: course) + direction: direction, + 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: Set the camera pitch method. + /// - Returns: The MapViewCamera representing the scenario + public static func trackUserLocation(zoom: Double = Defaults.zoom, + pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { + // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. return MapViewCamera(state: .trackingUserLocation, - coordinate: location.coordinate, zoom: zoom, pitch: pitch, - course: location.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 { + /// Enables user location tracking within the MapView. + /// + /// 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: Set the camera pitch method. + /// - Returns: The MapViewCamera representing the scenario + public static func trackUserLocationWithHeading(zoom: Double = Defaults.zoom, + pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { + + // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. + return MapViewCamera(state: .trackingUserLocationWithHeading, + zoom: zoom, + pitch: pitch, + direction: Defaults.direction, + lastReasonForChange: .programmatic) + } - 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 + /// Enables user location tracking within the MapView. + /// + /// 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: Set the camera pitch method. + /// - Returns: The MapViewCamera representing the scenario + public static func trackUserLocationWithCourse(zoom: Double = Defaults.zoom, + pitch: CameraPitch = Defaults.pitch) -> MapViewCamera { + + // Coordinate is ignored when tracking user location. However, pitch and zoom are valid. + return MapViewCamera(state: .trackingUserLocationWithCourse, + zoom: zoom, + pitch: pitch, + direction: Defaults.direction, + lastReasonForChange: .programmatic) } + + // TODO: Create init methods for other camera states once supporting materials are understood (e.g. BoundingBox) } 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 0000000..76aa78a Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/CameraPreviewTests/testCameraPreview.CameraPreview.png differ diff --git a/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testCirclesWithSymbols.1.png b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testCirclesWithSymbols.1.png new file mode 100644 index 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testCirclesWithSymbols.1.png differ 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 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRoseTint.1.png differ 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 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymbolConst.1.png differ 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 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testRotatedSymboleDynamic.1.png differ 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 0000000..22296ce Binary files /dev/null and b/Tests/MapLibreSwiftUITests/Examples/__Snapshots__/LayerPreviewTests/testSimpleSymbol.1.png differ 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..e4b8e3a --- /dev/null +++ b/Tests/MapLibreSwiftUITests/MapView/MapViewGestureTests.swift @@ -0,0 +1,80 @@ +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 + // Do nothing + } + + XCTAssertEqual(newMapView.gestures.first?.method, .tap()) + } + + func testMapViewOnLongPressGestureModifier() { + let newMapView = mapView.onLongPressMapGesture { _ in + // Do nothing + } + + XCTAssertEqual(newMapView.gestures.first?.method, .longPress()) + } + + // MARK: Gesture Processing + + func testTapGesture() { + let gesture = MapGesture(method: .tap(numberOfTaps: 2)) { _ in + // Do nothing + } + + let mockTapGesture = MockUIGestureRecognizing() + + 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)) + // 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 + // Do nothing + } + + let mockTapGesture = MockUIGestureRecognizing() + + 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)) + // 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 new file mode 100644 index 0000000..39a2f64 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/MapViewCoordinator/MapViewCoordinatorCameraTests.swift @@ -0,0 +1,173 @@ +import XCTest +import MockableTest +import CoreLocation +@testable import MapLibreSwiftUI + +final class MapViewCoordinatorCameraTests: XCTestCase { + + var maplibreMapView: MockMLNMapViewCameraUpdating! + var mapView: MapView! + var coordinator: MapView.Coordinator! + + override func setUp() async throws { + maplibreMapView = MockMLNMapViewCameraUpdating() + 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. We're testing that the snapshotCamera correctly exits the function + // when nothing changed. + coordinator.updateCamera(mapView: maplibreMapView, camera: camera, animated: false) + + // All of the actions only allow 1 count of set even though we've run the action twice. + // This verifies the comment above. + 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 new file mode 100644 index 0000000..3cfaa8e --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraChangeReasonTests.swift @@ -0,0 +1,56 @@ +import XCTest +import MapLibre +@testable import MapLibreSwiftUI + +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 = [.gestureZoomIn, .programmatic, ] + 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/CameraPitchTests.swift b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraPitchTests.swift new file mode 100644 index 0000000..36c5401 --- /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.freeWithinRange(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..b0f42ec --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/CameraStateTests.swift @@ -0,0 +1,43 @@ +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(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() { + 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() { + 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)) + 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 new file mode 100644 index 0000000..70eb264 --- /dev/null +++ b/Tests/MapLibreSwiftUITests/Models/MapCamera/MapViewCameraTests.swift @@ -0,0 +1,51 @@ +import XCTest +import CoreLocation +@testable import MapLibreSwiftUI + +final class MapViewCameraTests: XCTestCase { + + func testCenterCamera() { + let expectedCoordinate = CLLocationCoordinate2D(latitude: 12.3, longitude: 23.4) + let pitch: CameraPitch = .freeWithinRange(minimum: 12, maximum: 34) + let direction: CLLocationDirection = 23 + + let camera = MapViewCamera.center(expectedCoordinate, zoom: 12, pitch: pitch, direction: direction) + + XCTAssertEqual(camera.state, .centered(onCoordinate: expectedCoordinate)) + XCTAssertEqual(camera.zoom, 12) + XCTAssertEqual(camera.pitch, pitch) + XCTAssertEqual(camera.direction, direction) + } + + 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) + +} 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) + } + } + ) + } +} +