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