Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ let package = Package(
// Macros
.package(url: "https://github.com/swiftlang/swift-syntax.git", "509.0.0" ..< "602.0.0"),
// Testing
.package(url: "https://github.com/apple/swift-numerics.git", from: "1.1.0"),
.package(url: "https://github.com/Kolos65/Mockable.git", from: "0.3.1"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.3"),
// Macro Testing
Expand Down Expand Up @@ -88,6 +89,8 @@ let package = Package(
name: "MapLibreSwiftUITests",
dependencies: [
"MapLibreSwiftUI",
.product(name: "MapLibre", package: "maplibre-gl-native-distribution"),
.product(name: "Numerics", package: "swift-numerics"),
.product(name: "Mockable", package: "Mockable"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import CarPlay
import Foundation
import OSLog

private let logger = Logger(subsystem: "MapLibreSwiftUI", category: "MapViewCameraOperations")

@MainActor
public extension MapViewCamera {
// MARK: Zoom

/// Set a new zoom for the current camera state.
///
/// - Parameter newZoom: The new zoom value.
mutating func setZoom(_ newZoom: Double) {
/// - Parameters:
/// - newZoom: The new zoom value.
/// - proxy: An optional map view proxy, this allows the camera to convert from .rect/.showcase to centered.
/// Allowing zoom from the user's current viewport.
mutating func setZoom(_ newZoom: Double, proxy: MapViewProxy? = nil) {
switch state {
case let .centered(onCoordinate, _, pitch, pitchRange, direction):
state = .centered(onCoordinate: onCoordinate,
Expand All @@ -20,19 +28,30 @@ public extension MapViewCamera {
state = .trackingUserLocationWithHeading(zoom: newZoom, pitch: pitch, pitchRange: pitchRange)
case let .trackingUserLocationWithCourse(_, pitch, pitchRange):
state = .trackingUserLocationWithCourse(zoom: newZoom, pitch: pitch, pitchRange: pitchRange)
case .rect:
return
case .showcase:
return
case .rect, .showcase:
// This method requires the proxy.
guard let proxy else {
logger.debug("Cannot setZoom on a .rect or .showcase camera without a proxy")
return
}

state = .centered(onCoordinate: proxy.centerCoordinate,
zoom: newZoom,
pitch: MapViewCamera.Defaults.pitch,
pitchRange: .free,
direction: proxy.direction)
}

lastReasonForChange = .programmatic
}

/// Increment the zoom of the current camera state.
///
/// - Parameter newZoom: The value to increment the zoom by. Negative decrements the value.
mutating func incrementZoom(by increment: Double) {
/// - Parameters:
/// - newZoom: The value to increment the zoom by. Negative decrements the value.
/// - proxy: An optional map view proxy, this allows the camera to convert from .rect/.showcase to centered.
/// Allowing zoom from the user's current viewport.
mutating func incrementZoom(by increment: Double, proxy: MapViewProxy? = nil) {
switch state {
case let .centered(onCoordinate, zoom, pitch, pitchRange, direction):
state = .centered(onCoordinate: onCoordinate,
Expand All @@ -51,10 +70,18 @@ public extension MapViewCamera {
state = .trackingUserLocationWithHeading(zoom: zoom + increment, pitch: pitch, pitchRange: pitchRange)
case let .trackingUserLocationWithCourse(zoom, pitch, pitchRange):
state = .trackingUserLocationWithCourse(zoom: zoom + increment, pitch: pitch, pitchRange: pitchRange)
case .rect:
return
case .showcase:
return
case .rect, .showcase:
// This method requires the proxy.
guard let proxy else {
logger.debug("Cannot incrementZoom on a .rect or .showcase camera without a proxy")
return
}

state = .centered(onCoordinate: proxy.centerCoordinate,
zoom: proxy.zoomLevel + increment,
pitch: MapViewCamera.Defaults.pitch,
pitchRange: .free,
direction: proxy.direction)
}

lastReasonForChange = .programmatic
Expand All @@ -64,8 +91,11 @@ public extension MapViewCamera {

/// Set a new pitch for the current camera state.
///
/// - Parameter newPitch: The new pitch value.
mutating func setPitch(_ newPitch: Double) {
/// - Parameters:
/// - newPitch: The new pitch value.
/// - proxy: An optional map view proxy, this allows the camera to convert from .rect/.showcase to centered.
/// Allowing zoom from the user's current viewport.
mutating func setPitch(_ newPitch: Double, proxy: MapViewProxy? = nil) {
switch state {
case let .centered(onCoordinate, zoom, _, pitchRange, direction):
state = .centered(onCoordinate: onCoordinate,
Expand All @@ -79,14 +109,118 @@ public extension MapViewCamera {
state = .trackingUserLocationWithHeading(zoom: zoom, pitch: newPitch, pitchRange: pitchRange)
case let .trackingUserLocationWithCourse(zoom, _, pitchRange):
state = .trackingUserLocationWithCourse(zoom: zoom, pitch: newPitch, pitchRange: pitchRange)
case .rect:
return
case .showcase:
return
case .rect, .showcase:
// This method requires the proxy.
guard let proxy else {
logger.debug("Cannot setPitch on a .rect or .showcase camera without a proxy")
return
}

state = .centered(onCoordinate: proxy.centerCoordinate,
zoom: proxy.zoomLevel,
pitch: newPitch,
pitchRange: .free,
direction: proxy.direction)
}

lastReasonForChange = .programmatic
}

// TODO: Add direction set
/// Set the direction of the camera.
///
/// This will convert a rect and showcase camera to a centered camera while rotating.
/// Tracking user location with heading and course will ignore this behavior.
///
/// - Parameters:
/// - newDirection: The new camera direction (0 - North to 360)
/// - proxy: An optional map view proxy, this allows the camera to convert from .rect/.showcase to centered.
/// Allowing zoom from the user's current viewport.
mutating func setDirection(_ newDirection: Double, proxy: MapViewProxy? = nil) {
switch state {
case let .centered(onCoordinate: onCoordinate, zoom: zoom, pitch: pitch, pitchRange: pitchRange, _):
state = .centered(
onCoordinate: onCoordinate,
zoom: zoom,
pitch: pitch,
pitchRange: pitchRange,
direction: newDirection
)
case let .trackingUserLocation(zoom: zoom, pitch: pitch, pitchRange: pitchRange, _):
state = .trackingUserLocation(zoom: zoom, pitch: pitch, pitchRange: pitchRange, direction: newDirection)
case .trackingUserLocationWithHeading:
logger.debug("Cannot setPitch while .trackingUserLocationWithHeading")
case .trackingUserLocationWithCourse:
logger.debug("Cannot setPitch while .trackingUserLocationWithCourse")
case .rect, .showcase:
// This method requires the proxy.
guard let proxy else {
logger.debug("Cannot setDirection on a .rect or .showcase camera without a proxy")
return
}

state = .centered(onCoordinate: proxy.centerCoordinate,
zoom: proxy.zoomLevel,
pitch: MapViewCamera.Defaults.pitch,
pitchRange: .free,
direction: newDirection)
}

lastReasonForChange = .programmatic
}

// MARK: Car Play

/// Pans the camera for a CarPlay view.
///
/// - Parameters:
/// - newPitch: The new pitch value.
/// - proxy: An optional map view proxy, this allows the camera to convert from .rect/.showcase to centered.
/// Allowing zoom from the user's current viewport.
mutating func pan(_ direction: CPMapTemplate.PanDirection, proxy: MapViewProxy? = nil) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could create our own direction enum here, but I figured CarPlay is always available to import. Thoughts?

var currentZoom: Double?
var currentCenter: CLLocationCoordinate2D?

switch state {
case let .centered(onCoordinate: onCoordinate, zoom: zoom, _, _, _):
currentZoom = zoom
currentCenter = onCoordinate
case let .trackingUserLocation(zoom: zoom, _, _, _),
let .trackingUserLocationWithHeading(zoom: zoom, _, _),
let .trackingUserLocationWithCourse(zoom: zoom, _, _):
currentZoom = zoom
case .rect, .showcase:
break
}

let zoom = currentZoom ?? proxy?.zoomLevel ?? MapViewCamera.Defaults.zoom
let center = currentCenter ?? proxy?.centerCoordinate ?? MapViewCamera.Defaults.coordinate

// Adjust +5 for desired sensitivity
let sensitivity: Double = 5
// Pan distance decreases exponentially with zoom level
// At zoom 0: ~5.6 degrees, at zoom 10: ~0.0055 degrees, at zoom 20: ~0.000005 degrees
let basePanDistance = 360.0 / pow(2, zoom + sensitivity)
let latitudeDelta = basePanDistance
let longitudeDelta = basePanDistance / cos(center.latitude * .pi / 180)

let newCenter: CLLocationCoordinate2D? = switch direction {
case .down:
CLLocationCoordinate2D(latitude: center.latitude - latitudeDelta, longitude: center.longitude)
case .up:
CLLocationCoordinate2D(latitude: center.latitude + latitudeDelta, longitude: center.longitude)
case .left:
CLLocationCoordinate2D(latitude: center.latitude, longitude: center.longitude - longitudeDelta)
case .right:
CLLocationCoordinate2D(latitude: center.latitude, longitude: center.longitude + longitudeDelta)
default:
nil
}

guard let newCenter else {
return
}

self = .center(newCenter, zoom: zoom)
lastReasonForChange = .programmatic
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import MapLibre
import Mockable

// NOTE: We should eventually mark the entire protocol @MainActor, but Mockable generates some unsafe code at the moment

/// A reprentation of the MLNMapView
///
/// This is primarily used to abstract the MLNMapView for testing.
@Mockable
public protocol MLNMapViewCameraUpdating: AnyObject {
public protocol MLNMapViewRepresentable: AnyObject {
@MainActor var userTrackingMode: MLNUserTrackingMode { get set }
@MainActor func setUserTrackingMode(_ mode: MLNUserTrackingMode, animated: Bool, completionHandler: (() -> Void)?)

Expand All @@ -28,8 +32,11 @@ public protocol MLNMapViewCameraUpdating: AnyObject {
animated: Bool,
completionHandler: (() -> Void)?
)
@MainActor var visibleCoordinateBounds: MLNCoordinateBounds { get }
@MainActor var contentInset: UIEdgeInsets { get }
@MainActor func convert(_ coordinate: CLLocationCoordinate2D, toPointTo: UIView?) -> CGPoint
}

extension MLNMapView: MLNMapViewCameraUpdating {
extension MLNMapView: MLNMapViewRepresentable {
// No definition
}
4 changes: 2 additions & 2 deletions Sources/MapLibreSwiftUI/MapViewCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ MLNMapViewDelegate {
/// - camera: The new camera state
/// - animated: Whether the camera change should be animated. Defaults to `true`.
@MainActor func applyCameraChangeFromStateUpdate(
_ mapView: MLNMapViewCameraUpdating,
_ mapView: MLNMapViewRepresentable,
camera: MapViewCamera,
animated: Bool = true
) {
Expand Down Expand Up @@ -362,7 +362,7 @@ MLNMapViewDelegate {
/// - Parameters:
/// - mapView: The MapView that is being manipulated by a gesture.
/// - reason: The reason for the camera change.
@MainActor func applyCameraChangeFromGesture(_ mapView: MLNMapViewCameraUpdating, reason: CameraChangeReason) {
@MainActor func applyCameraChangeFromGesture(_ mapView: MLNMapViewRepresentable, reason: CameraChangeReason) {
guard cameraUpdateTask == nil else {
// Gestures emit many updates, so we only want to launch the first one and rely on idle to close the event.
return
Expand Down
3 changes: 2 additions & 1 deletion Sources/MapLibreSwiftUI/Models/MapCamera/MapViewCamera.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import MapLibre
/// The SwiftUI MapViewCamera.
///
/// This manages the camera state within the MapView.
public struct MapViewCamera: Hashable, Equatable, Sendable, CustomStringConvertible {
@MainActor
public struct MapViewCamera: Hashable, Equatable, Sendable, @preconcurrency CustomStringConvertible {
public enum Defaults {
public static let coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0)
public static let zoom: Double = 10
Expand Down
23 changes: 17 additions & 6 deletions Sources/MapLibreSwiftUI/Models/MapViewProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import MapLibre
/// For more information about the properties and functions, see
/// https://maplibre.org/maplibre-native/ios/latest/documentation/maplibre/mlnmapview
@MainActor
public struct MapViewProxy: Hashable, Equatable {
public struct MapViewProxy {
/// The current center coordinate of the MapView
public var centerCoordinate: CLLocationCoordinate2D {
mapView.centerCoordinate
Expand All @@ -32,32 +32,43 @@ public struct MapViewProxy: Hashable, Equatable {
mapView.direction
}

/// The visible coordinate bounds of the MapView
public var visibleCoordinateBounds: MLNCoordinateBounds {
mapView.visibleCoordinateBounds
}

/// The size of the MapView
public var mapViewSize: CGSize {
mapView.frame.size
}

/// The content inset of the MapView
public var contentInset: UIEdgeInsets {
mapView.contentInset
}

/// The reason the view port was changed.
public let lastReasonForChange: CameraChangeReason?

private let mapView: MLNMapView
/// The underlying MLNMapView (only used for functions that require it)
private let mapView: MLNMapViewRepresentable

/// Convert a coordinate to a point in the MapView
/// - Parameters:
/// - coordinate: The coordinate to convert
/// - toPointTo: The view to convert the point to (usually nil for the MapView itself)
/// - Returns: The CGPoint representation of the coordinate
public func convert(_ coordinate: CLLocationCoordinate2D, toPointTo: UIView?) -> CGPoint {
mapView.convert(coordinate, toPointTo: toPointTo)
}

public init(mapView: MLNMapView,
lastReasonForChange: CameraChangeReason?)
{
self.mapView = mapView
/// Initialize with an MLNMapView (captures current values)
/// - Parameters:
/// - mapView: The MLNMapView to capture values from
/// - lastReasonForChange: The reason for the last camera change
public init(mapView: MLNMapViewRepresentable, lastReasonForChange: CameraChangeReason?) {
self.lastReasonForChange = lastReasonForChange
self.mapView = mapView
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import XCTest
@testable import MapLibreSwiftUI

final class MapViewCoordinatorCameraTests: XCTestCase {
var maplibreMapView: MockMLNMapViewCameraUpdating!
var maplibreMapView: MockMLNMapViewRepresentable!
var mapView: MapView<MLNMapViewController>!
var coordinator: MapView<MLNMapViewController>.Coordinator!

@MainActor
override func setUp() async throws {
maplibreMapView = MockMLNMapViewCameraUpdating()
maplibreMapView = MockMLNMapViewRepresentable()
given(maplibreMapView).frame.willReturn(.zero)
mapView = MapView(styleURL: URL(string: "https://maplibre.org")!)
coordinator = MapView.Coordinator(
Expand Down
Loading
Loading