Skip to content

Commit

Permalink
Revisions for camera behavior, added basic gesture methods, separated…
Browse files Browse the repository at this point in the history
… out core MapView
  • Loading branch information
Archdoog committed Feb 2, 2024
1 parent b6233a0 commit 5cdf002
Show file tree
Hide file tree
Showing 12 changed files with 548 additions and 225 deletions.
3 changes: 2 additions & 1 deletion Sources/MapLibreSwiftUI/Examples/Polyline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation
import MapLibre

extension MLNCameraChangeReason: CustomDebugStringConvertible {
public var debugDescription: String {
switch self.lastValue {

case .programmatic: return ".programmatic"
case .resetNorth: return ".resetNorth"
case .gesturePan: return ".gesturePan"
case .gesturePinch: return ".gesturePinch"
case .gestureRotate: return ".gestureRotate"
case .gestureZoomIn: return ".gestureZoomIn"
case .gestureZoomOut: return ".gestureZoomOut"
case .gestureOneFingerZoom: return ".gestureOneFingerZoom"
case .gestureTilt: return ".gestureTilt"
case .transitionCancelled: return ".transitionCancelled"
default: return "none"
}
}

/// Get the last value from the MLNCameraChangeReason option set.
public var lastValue: MLNCameraChangeReason {
// Start at 1
var mask: UInt = 1
var result: UInt = 0

while mask <= self.rawValue {
// If the raw value matches the remaining mask.
if self.rawValue & mask != 0 {
result = mask
}
// Shift all the way until the rawValue has been allocated and we have the true last value.
mask <<= 1
}

return MLNCameraChangeReason(rawValue: result)
}
}
9 changes: 0 additions & 9 deletions Sources/MapLibreSwiftUI/MapView Modifiers.swift

This file was deleted.

243 changes: 63 additions & 180 deletions Sources/MapLibreSwiftUI/MapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,230 +5,85 @@ import MapLibreSwiftDSL

public struct MapView: UIViewRepresentable {

public private(set) var camera: Binding<MapViewCamera>
@Binding var camera: MapViewCamera

let styleSource: MapStyleSource
let userLayers: [StyleLayerDefinition]
var gestures = [MapGesture]()

/// 'Escape hatch' to MLNMapView until we have more modifiers.
/// See ``unsafeMapViewModifier(_:)``
private var unsafeMapViewModifier: ((MLNMapView) -> Void)?
var unsafeMapViewModifier: ((MLNMapView) -> Void)?

public init(
styleURL: URL,
camera: Binding<MapViewCamera> = .constant(.default()),
@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<String>()
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):
mapView.styleURL = styleURL
}

context.coordinator.updateCamera(mapView: mapView,
camera: camera.wrappedValue,
camera: $camera.wrappedValue,
animated: false)

// TODO: Make this settable via a modifier
mapView.logoView.isHidden = true


// Gesture recogniser setup
let tapGesture = UITapGestureRecognizer(
target: context.coordinator,
action: #selector(context.coordinator.captureGesture(_:))
)
mapView.addGestureRecognizer(tapGesture)

let longPressGesture = UILongPressGestureRecognizer(
target: context.coordinator,
action: #selector(context.coordinator.captureGesture(_:))
)
mapView.addGestureRecognizer(longPressGesture)

return mapView
}

public func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}


public func updateUIView(_ mapView: MLNMapView, context: Context) {
context.coordinator.parent = self

// MARK: Modifiers
unsafeMapViewModifier?(mapView)

// MARK: End Modifiers

// FIXME: This should be a more selective update
context.coordinator.updateStyleSource(styleSource, mapView: mapView)
context.coordinator.updateLayers(mapView: mapView)
Expand All @@ -237,9 +92,37 @@ public struct MapView: UIViewRepresentable {
let isStyleLoaded = mapView.style != nil

context.coordinator.updateCamera(mapView: mapView,
camera: camera.wrappedValue,
camera: $camera.wrappedValue,
animated: isStyleLoaded)
}

private func processGesture(_ mapView: MLNMapView, _ sender: UIGestureRecognizer) {
let point = sender.location(in: mapView)
let coordinate = mapView.convert(point, toCoordinateFrom: mapView)

switch sender {
case is UITapGestureRecognizer:
for gesture in gestures.filter({ $0.method == .tap }) {
gesture.action(
MapGestureContext(gesture: gesture.method,
point: point,
coordinate: coordinate,
numberOfTaps: sender.numberOfTouches)
)
}
case is UILongPressGestureRecognizer:
for gesture in gestures.filter({ $0.method == .longPress }) {
gesture.action(
MapGestureContext(gesture: gesture.method,
point: point,
coordinate: coordinate,
numberOfTaps: sender.numberOfTouches)
)
}
default:
print("Log unhandled gesture")
}
}
}

struct MapView_Previews: PreviewProvider {
Expand Down
Loading

0 comments on commit 5cdf002

Please sign in to comment.