Skip to content

Commit

Permalink
Merge pull request #39 from HudHud-Maps/enhancement/unify-navigation
Browse files Browse the repository at this point in the history
Support Maplibre Navigation iOS
  • Loading branch information
ianthetechie authored Jul 15, 2024
2 parents 50673f0 + aece53f commit 605d855
Show file tree
Hide file tree
Showing 22 changed files with 178 additions and 69 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
LastUpgradeVersion = "1540"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
LastUpgradeVersion = "1540"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
LastUpgradeVersion = "1540"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ can move fast without breaking anything important.
* Overlays
* Dynamic styling
* Camera control / animation??
* Navigation
2. Prevent most common classes of mistakes that users make with the lower level APIs (ex: adding the same source twice)
3. Deeper SwiftUI integration (ex: SwiftUI callout views)

Expand All @@ -46,6 +47,45 @@ Then, for each target add either the DSL (for just the DSL) or both (for the Swi
Check out the (super basic) [previews at the bottom of MapView.swift](Sources/MapLibreSwiftUI/MapView.swift)
or more detailed [Examples](Sources/MapLibreSwiftUI/Examples) to see how it works in practice.

## Navigation

If you need to support navigation add https://github.com/HudHud-Maps/maplibre-navigation-ios.git to your Package.swift and add this code:

```swift
import MapboxCoreNavigation
import MapboxNavigation

extension NavigationViewController: MapViewHostViewController {
public typealias MapType = NavigationMapView
}


@State var route: Route?
@State var navigationInProgress: Bool = false

@ViewBuilder
var mapView: some View {
MapView<NavigationViewController>(makeViewController: NavigationViewController(dayStyleURL: self.styleURL), styleURL: self.styleURL, camera: self.$mapStore.camera) {

}
.unsafeMapViewControllerModifier { navigationViewController in
navigationViewController.delegate = self.mapStore
if let route = self.route, self.navigationInProgress == false {
let locationManager = SimulatedLocationManager(route: route)
navigationViewController.startNavigation(with: route, locationManager: locationManager)
self.navigationInProgress = true
} else if self.route == nil, self.navigationInProgress == true {
navigationViewController.endNavigation()
self.navigationInProgress = false
}

navigationViewController.mapView.showsUserLocation = self.showUserLocation && self.mapStore.streetView == .disabled
}
.cameraModifierDisabled(self.route != nil)
}
```
We choose this approach so MapLibreSwiftUI is not depdending on maplibre-navigation as most users don't need it.

## Developer Quick Start

This project uses [`swiftformat`](https://github.com/nicklockwood/SwiftFormat) to automatically handle basic swift formatting
Expand Down
4 changes: 2 additions & 2 deletions Sources/MapLibreSwiftUI/Examples/Gestures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ import SwiftUI
.iconColor(.white)
}
.onTapMapGesture(on: [tappableID], onTapChanged: { _, features in
print("Tapped on \(features.first)")
print("Tapped on \(features.first?.description ?? "<nil>")")
})
.ignoresSafeArea(.all)
}

#Preview("Tappable Countries") {
MapView(styleURL: demoTilesURL)
.onTapMapGesture(on: ["countries-fill"], onTapChanged: { _, features in
print("Tapped on \(features.first)")
print("Tapped on \(features.first?.description ?? "<nil>")")
})
.ignoresSafeArea(.all)
}
2 changes: 1 addition & 1 deletion Sources/MapLibreSwiftUI/Examples/Layers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ let clustered = ShapeSource(identifier: "points", options: [.clustered: true, .c
.predicate(NSPredicate(format: "cluster != YES"))
}
.onTapMapGesture(on: ["simple-circles-non-clusters"], onTapChanged: { _, features in
print("Tapped on \(features.first)")
print("Tapped on \(features.first?.debugDescription ?? "<nil>")")
})
.expandClustersOnTapping(clusteredLayers: [ClusterLayer(
layerIdentifier: "simple-circles-clusters",
Expand Down
6 changes: 3 additions & 3 deletions Sources/MapLibreSwiftUI/Examples/Other.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ import SwiftUI
SymbolStyleLayer(identifier: "simple-symbols", source: pointSource)
.iconImage(UIImage(systemName: "mappin")!)
}
.unsafeMapViewModifier { mapView in
.unsafeMapViewControllerModifier { viewController 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
viewController.mapView.logoView.isHidden = false
viewController.mapView.compassViewPosition = .topLeft
}
}
3 changes: 1 addition & 2 deletions Sources/MapLibreSwiftUI/Examples/Preview Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@
import CoreLocation

let switzerland = CLLocationCoordinate2D(latitude: 47.03041, longitude: 8.29470)
let demoTilesURL =
URL(string: "https://demotiles.maplibre.org/style.json")!
public let demoTilesURL = URL(string: "https://demotiles.maplibre.org/style.json")!
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Mockable

// NOTE: We should eventually mark the entire protocol @MainActor, but Mockable generates some unsafe code at the moment
@Mockable
protocol MLNMapViewCameraUpdating: AnyObject {
public protocol MLNMapViewCameraUpdating: AnyObject {
@MainActor var userTrackingMode: MLNUserTrackingMode { get set }
@MainActor var minimumPitch: CGFloat { get set }
@MainActor var maximumPitch: CGFloat { get set }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Mockable
import UIKit

@Mockable
protocol UIGestureRecognizing: AnyObject {
public protocol UIGestureRecognizing: AnyObject {
@MainActor var state: UIGestureRecognizer.State { get }
@MainActor func location(in view: UIView?) -> CGPoint
@MainActor func location(ofTouch touchIndex: Int, in view: UIView?) -> CGPoint
Expand Down
17 changes: 17 additions & 0 deletions Sources/MapLibreSwiftUI/MLNMapViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import MapLibre
import UIKit

public protocol MapViewHostViewController: UIViewController {
associatedtype MapType: MLNMapView
var mapView: MapType { get }
}

public final class MLNMapViewController: UIViewController, MapViewHostViewController {
public var mapView: MLNMapView {

Check warning on line 10 in Sources/MapLibreSwiftUI/MLNMapViewController.swift

View workflow job for this annotation

GitHub Actions / test (MapLibreSwiftUI-Package, platform=iOS Simulator,name=iPhone 15,OS=17.2)

main actor-isolated property 'mapView' cannot be used to satisfy nonisolated protocol requirement

Check warning on line 10 in Sources/MapLibreSwiftUI/MLNMapViewController.swift

View workflow job for this annotation

GitHub Actions / test (MapLibreSwiftUI-Package, platform=iOS Simulator,name=iPhone 15,OS=17.2)

main actor-isolated property 'mapView' cannot be used to satisfy nonisolated protocol requirement
view as! MLNMapView
}

override public func loadView() {
view = MLNMapView(frame: .zero)
}
}
88 changes: 56 additions & 32 deletions Sources/MapLibreSwiftUI/MapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import MapLibre
import MapLibreSwiftDSL
import SwiftUI

public struct MapView: UIViewRepresentable {
public struct MapView<T: MapViewHostViewController>: UIViewControllerRepresentable {
public typealias UIViewControllerType = T
var cameraDisabled: Bool = true

@Binding var camera: MapViewCamera

let makeViewController: () -> T
let styleSource: MapStyleSource
let userLayers: [StyleLayerDefinition]

Expand All @@ -16,9 +20,7 @@ public struct MapView: UIViewRepresentable {

public var mapViewContentInset: UIEdgeInsets = .zero

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

var controls: [MapControl] = [
CompassView(),
Expand All @@ -31,93 +33,115 @@ public struct MapView: UIViewRepresentable {
var clusteredLayers: [ClusterLayer]?

public init(
makeViewController: @autoclosure @escaping () -> T,
styleURL: URL,
camera: Binding<MapViewCamera> = .constant(.default()),
locationManager: MLNLocationManager? = nil,
@MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] }
) {
self.makeViewController = makeViewController
styleSource = .url(styleURL)
_camera = camera
userLayers = makeMapContent()
self.locationManager = locationManager
}

public func makeCoordinator() -> MapViewCoordinator {
MapViewCoordinator(
public func makeCoordinator() -> MapViewCoordinator<T> {
MapViewCoordinator<T>(
parent: self,
onGesture: { processGesture($0, $1) },
onViewPortChanged: { onViewPortChanged?($0) }
)
}

public func makeUIView(context: Context) -> MLNMapView {
public func makeUIViewController(context: Context) -> T {
// Create the map view
let mapView = MLNMapView(frame: .zero)
mapView.delegate = context.coordinator
context.coordinator.mapView = mapView
let controller = makeViewController()
controller.mapView.delegate = context.coordinator
context.coordinator.mapView = controller.mapView

// Apply modifiers, suppressing camera update propagation (this messes with setting our initial camera as
// content insets can trigger a change)
context.coordinator.suppressCameraUpdatePropagation = true
applyModifiers(mapView, runUnsafe: false)
applyModifiers(controller, runUnsafe: false)
context.coordinator.suppressCameraUpdatePropagation = false

mapView.locationManager = locationManager
controller.mapView.locationManager = locationManager

switch styleSource {
case let .url(styleURL):
mapView.styleURL = styleURL
controller.mapView.styleURL = styleURL
}

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

// 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)
registerGesture(controller.mapView, context, gesture: gesture)
}

return mapView
return controller
}

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

applyModifiers(mapView, runUnsafe: true)
applyModifiers(uiViewController, runUnsafe: true)

// FIXME: This should be a more selective update
context.coordinator.updateStyleSource(styleSource, mapView: mapView)
context.coordinator.updateLayers(mapView: mapView)
context.coordinator.updateStyleSource(styleSource, mapView: uiViewController.mapView)
context.coordinator.updateLayers(mapView: uiViewController.mapView)

// FIXME: This isn't exactly telling us if the *map* is loaded, and the docs for setCenter say it needs to be.
let isStyleLoaded = mapView.style != nil
let isStyleLoaded = uiViewController.mapView.style != nil

context.coordinator.updateCamera(mapView: mapView,
camera: $camera.wrappedValue,
animated: isStyleLoaded)
if cameraDisabled == false {
context.coordinator.updateCamera(mapView: uiViewController.mapView,
camera: $camera.wrappedValue,
animated: isStyleLoaded)
}
}

@MainActor private func applyModifiers(_ mapView: MLNMapView, runUnsafe: Bool) {
mapView.contentInset = mapViewContentInset
@MainActor private func applyModifiers(_ mapViewController: T, runUnsafe: Bool) {
mapViewController.mapView.contentInset = mapViewContentInset

// Assume all controls are hidden by default (so that an empty list returns a map with no controls)
mapView.logoView.isHidden = true
mapView.compassView.isHidden = true
mapView.attributionButton.isHidden = true
mapViewController.mapView.logoView.isHidden = true
mapViewController.mapView.compassView.isHidden = true
mapViewController.mapView.attributionButton.isHidden = true

// Apply each control configuration
for control in controls {
control.configureMapView(mapView)
control.configureMapView(mapViewController.mapView)
}

if runUnsafe {
unsafeMapViewModifier?(mapView)
unsafeMapViewControllerModifier?(mapViewController)
}
}
}

public extension MapView where T == MLNMapViewController {
@MainActor
init(
styleURL: URL,
camera: Binding<MapViewCamera> = .constant(.default()),
locationManager: MLNLocationManager? = nil,
@MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] }
) {
makeViewController = {
MLNMapViewController()
}
styleSource = .url(styleURL)
_camera = camera
userLayers = makeMapContent()
self.locationManager = locationManager
}
}

Expand Down
10 changes: 4 additions & 6 deletions Sources/MapLibreSwiftUI/MapViewCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import Foundation
import MapLibre
import MapLibreSwiftDSL

public class MapViewCoordinator: NSObject {
public class MapViewCoordinator<T: MapViewHostViewController>: NSObject, MLNMapViewDelegate {
// This must be weak, the UIViewRepresentable owns the MLNMapView.
weak var mapView: MLNMapView?
var parent: MapView
var parent: MapView<T>

// Storage of variables as they were previously; these are snapshot
// every update cycle so we can avoid unnecessary updates
Expand All @@ -22,7 +22,7 @@ public class MapViewCoordinator: NSObject {
var onGesture: (MLNMapView, UIGestureRecognizer) -> Void
var onViewPortChanged: (MapViewPort) -> Void

init(parent: MapView,
init(parent: MapView<T>,
onGesture: @escaping (MLNMapView, UIGestureRecognizer) -> Void,
onViewPortChanged: @escaping (MapViewPort) -> Void)
{
Expand Down Expand Up @@ -296,11 +296,9 @@ public class MapViewCoordinator: NSObject {
}
}
}
}

// MARK: - MLNMapViewDelegate
// MARK: - MLNMapViewDelegate

extension MapViewCoordinator: MLNMapViewDelegate {
public func mapView(_: MLNMapView, didFinishLoading mglStyle: MLNStyle) {
addLayers(to: mglStyle)
onStyleLoaded?(mglStyle)
Expand Down
Loading

0 comments on commit 605d855

Please sign in to comment.