diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme index 0bdca09..a45b7e5 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MapLibreSwiftDSL.xcscheme @@ -1,6 +1,6 @@ [StyleLayerDefinition] { + return layers.flatMap { $0 } + } + + public static func buildOptional(_ layers: [StyleLayerDefinition]?) -> [StyleLayerDefinition] { + return layers ?? [] + } + + public static func buildExpression(_ layer: StyleLayerDefinition) -> [StyleLayerDefinition] { + return [layer] + } + + public static func buildExpression(_ expression: Void) -> [StyleLayerDefinition] { + return [] + } + + public static func buildExpression(_ styleCollection: StyleLayerCollection) -> [StyleLayerDefinition] { + return styleCollection.layers + } + + + public static func buildEither(first layer: [StyleLayerDefinition]) -> [StyleLayerDefinition] { + return layer + } + + public static func buildEither(second layer: [StyleLayerDefinition]) -> [StyleLayerDefinition] { + return layer + } +} diff --git a/Sources/MapLibreSwiftDSL/Style Layers/StyleLayerCollection.swift b/Sources/MapLibreSwiftDSL/Style Layers/StyleLayerCollection.swift new file mode 100644 index 0000000..468c967 --- /dev/null +++ b/Sources/MapLibreSwiftDSL/Style Layers/StyleLayerCollection.swift @@ -0,0 +1,6 @@ +import Foundation + +public protocol StyleLayerCollection { + + @MapViewContentBuilder var layers: [StyleLayerDefinition] { get } +} diff --git a/Sources/MapLibreSwiftUI/Examples/Camera.swift b/Sources/MapLibreSwiftUI/Examples/Camera.swift index e8ba7b8..b628747 100644 --- a/Sources/MapLibreSwiftUI/Examples/Camera.swift +++ b/Sources/MapLibreSwiftUI/Examples/Camera.swift @@ -4,26 +4,28 @@ import SwiftUI private let switzerland = CLLocationCoordinate2D(latitude: 46.801111, longitude: 8.226667) struct CameraDirectManipulationPreview: View { - @State private var camera = MapView.Camera.centerAndZoom(switzerland, 4) + @State private var camera = MapViewCamera.center(switzerland, zoom: 4) let styleURL: URL var body: some View { MapView(styleURL: styleURL, camera: $camera) .overlay(alignment: .bottom, content: { - switch camera { - case .centerAndZoom(let coord, let zoom): - Text("\(coord.latitude), \(coord.longitude) z \(zoom ?? 0)") - .padding() - .background(in: RoundedRectangle(cornerRadius: 8), - fillStyle: .init()) - .padding(.bottom, 42) - } + Text("\(camera.coordinate.latitude), \(camera.coordinate.longitude) z \(camera.zoom)") + .padding() + .foregroundColor(.white) + .background( + Rectangle() + .foregroundColor(.black) + .cornerRadius(8) + ) + + .padding(.bottom, 42) }) .task { try! await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) - camera = MapView.Camera.centerAndZoom(switzerland, 6) + camera = MapViewCamera.center(switzerland, zoom: 6) } } } diff --git a/Sources/MapLibreSwiftUI/Examples/Polyline.swift b/Sources/MapLibreSwiftUI/Examples/Polyline.swift index 60f861b..32372c5 100644 --- a/Sources/MapLibreSwiftUI/Examples/Polyline.swift +++ b/Sources/MapLibreSwiftUI/Examples/Polyline.swift @@ -7,7 +7,7 @@ struct PolylinePreview: View { let styleURL: URL var body: some View { - MapView(styleURL: styleURL, initialCamera: MapView.Camera.centerAndZoom(samplePedestrianWaypoints.first!, 14)) { + MapView(styleURL: styleURL, initialCamera: MapViewCamera.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/MapView.swift b/Sources/MapLibreSwiftUI/MapView.swift index b56935a..3611320 100644 --- a/Sources/MapLibreSwiftUI/MapView.swift +++ b/Sources/MapLibreSwiftUI/MapView.swift @@ -3,30 +3,29 @@ import InternalUtils import MapLibre import MapLibreSwiftDSL - public struct MapView: UIViewRepresentable { - // TODO: Support MLNStyle as well; having a DSL for that would be nice - enum MapStyleSource { - case url(URL) - } - - public enum Camera { - case centerAndZoom(CLLocationCoordinate2D, Double?) - } - - var camera: Binding? + + public private(set) var camera: Binding let styleSource: MapStyleSource let userLayers: [StyleLayerDefinition] - public init(styleURL: URL, camera: Binding? = nil, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] }) { + public init( + styleURL: URL, + camera: Binding = .constant(.default()), + @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } + ) { self.styleSource = .url(styleURL) self.camera = camera userLayers = makeMapContent() } - public init(styleURL: URL, initialCamera: Camera, @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] }) { + public init( + styleURL: URL, + initialCamera: MapViewCamera, + @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } + ) { self.init(styleURL: styleURL, camera: .constant(initialCamera), makeMapContent) } @@ -36,7 +35,8 @@ public struct MapView: UIViewRepresentable { // 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 } @@ -58,12 +58,27 @@ public struct MapView: UIViewRepresentable { public func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { DispatchQueue.main.async { - self.parent.camera?.wrappedValue = .centerAndZoom(mapView.centerCoordinate, mapView.zoomLevel) + 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? @@ -161,8 +176,10 @@ public struct MapView: UIViewRepresentable { mapView.styleURL = styleURL } - updateMapCamera(mapView, animated: false) - + context.coordinator.updateCamera(mapView: mapView, + camera: camera.wrappedValue, + animated: false) + // TODO: Make this settable via a modifier mapView.logoView.isHidden = true @@ -178,32 +195,13 @@ public struct MapView: UIViewRepresentable { // FIXME: This should be a more selective update context.coordinator.updateStyleSource(styleSource, mapView: mapView) context.coordinator.updateLayers(mapView: mapView) - + // FIXME: This isn't exactly telling us if the *map* is loaded, and the docs for setCenter say it needs t obe. let isStyleLoaded = mapView.style != nil - updateMapCamera(mapView, animated: isStyleLoaded) - } - - private func updateMapCamera(_ mapView: MLNMapView, animated: Bool) { - if let camera = self.camera { - switch camera.wrappedValue { - case .centerAndZoom(let center, let zoom): - // TODO: Determine if MapLibre is smart enough to keep animating to the same place multiple times; if not, add a check here to prevent suprious updates. - if let z = zoom { - mapView.setCenter(center, zoomLevel: z, animated: animated) - } else { - mapView.setCenter(center, animated: animated) - } - } - } - } -} - -@resultBuilder -public enum MapViewContentBuilder { - public static func buildBlock(_ layers: StyleLayerDefinition...) -> [StyleLayerDefinition] { - return layers + context.coordinator.updateCamera(mapView: mapView, + camera: camera.wrappedValue, + animated: isStyleLoaded) } } diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift new file mode 100644 index 0000000..3860508 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/CameraState.swift @@ -0,0 +1,37 @@ +import Foundation +import MapLibre + +/// The CameraState is used to understand the current context of the MapView's camera. +public enum CameraState { + + /// Centered on a coordinate + case centered + + /// The camera is currently following a location provider. + case trackingUserLocation + + /// Centered on a bounding box/rectangle. + case rect + + /// Showcasing a GeoJSON/Polygon + case showcase +} + +extension CameraState: Equatable { + + public static func ==(lhs: CameraState, rhs: CameraState) -> Bool { + switch (lhs, rhs) { + + case (.centered, .centered): + return true + case (.trackingUserLocation, .trackingUserLocation): + return true + case (.rect, .rect): + return true + case (.showcase, .showcase): + return true + default: + return false + } + } +} diff --git a/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift new file mode 100644 index 0000000..0787544 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift @@ -0,0 +1,69 @@ +import Foundation +import CoreLocation + +public struct MapViewCamera { + + public var state: CameraState + public var coordinate: CLLocationCoordinate2D + public var zoom: Double + public var pitch: Double + public var course: CLLocationDirection + + /// A camera centered at 0.0, 0.0. This is typically used as a backup, + /// pre-load for an expected camera update (e.g. before a location provider produces + /// it's first location). + /// + /// - 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) + } + + /// Center the map on a specific location. + /// + /// - 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). + /// - Returns: The constructed MapViewCamera. + public static func center(_ coordinate: CLLocationCoordinate2D, + zoom: Double, + pitch: Double = 90.0, + course: Double = 0) -> MapViewCamera { + + return MapViewCamera(state: .centered, + coordinate: coordinate, + zoom: zoom, + pitch: pitch, + course: course) + } + + public static func trackUserLocation(_ location: CLLocation, + zoom: Double, + pitch: Double = 90.0) -> MapViewCamera { + + return MapViewCamera(state: .trackingUserLocation, + coordinate: location.coordinate, + zoom: zoom, + pitch: pitch, + course: location.course) + } + + // TODO: Create init methods for other camera states once supporting materials are understood (e.g. BoundingBox) +} + +extension MapViewCamera: Equatable { + + public static func ==(lhs: MapViewCamera, rhs: MapViewCamera) -> Bool { + return lhs.state == rhs.state + && lhs.coordinate.latitude == rhs.coordinate.latitude + && lhs.coordinate.longitude == rhs.coordinate.longitude + && lhs.zoom == rhs.zoom + && lhs.pitch == rhs.pitch + && lhs.course == rhs.course + } +} diff --git a/Sources/MapLibreSwiftUI/Models/MapStyleSource.swift b/Sources/MapLibreSwiftUI/Models/MapStyleSource.swift new file mode 100644 index 0000000..8bcd8d3 --- /dev/null +++ b/Sources/MapLibreSwiftUI/Models/MapStyleSource.swift @@ -0,0 +1,6 @@ +import Foundation + +// TODO: Support MLNStyle as well; having a DSL for that would be nice +public enum MapStyleSource { + case url(URL) +}