Skip to content

Commit

Permalink
[MAPSIOS-1095] Expose missing MapView APIs in SwiftUI (#2074)
Browse files Browse the repository at this point in the history
  • Loading branch information
aleksproger authored Mar 14, 2024
1 parent 5430c84 commit f8075af
Show file tree
Hide file tree
Showing 13 changed files with 189 additions and 12 deletions.
4 changes: 4 additions & 0 deletions Apps/Examples/Examples.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
7036A19FCD2CCE85BDDF4E00 /* TrackingModeExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5E598A16FA446F583344CB /* TrackingModeExample.swift */; platformFilters = (ios, ); };
7365170E39A459EB4DFA198B /* ExamplesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE18E37A8652B4807D2459F1 /* ExamplesUITests.swift */; platformFilters = (ios, ); };
7686448F8648BECC75A912B6 /* DashboardCarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913B4773A82AD6357D6AAEA1 /* DashboardCarPlaySceneDelegate.swift */; platformFilters = (ios, ); };
79843B780E7C5DC68433B745 /* SnapshotMapExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5A8729C9AEA4711B56B5F0 /* SnapshotMapExample.swift */; };
79B889CF23A3C0A5EA7F6ADD /* ApplicationCarPlaySceneDelegage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3F37CAE7A0B66BE429525C /* ApplicationCarPlaySceneDelegage.swift */; platformFilters = (ios, ); };
7B9835E597E0B2655E181A48 /* ExampleTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A6063E57EC170F558A3F74 /* ExampleTableViewController.swift */; platformFilters = (ios, ); };
7E84D4D6459049E452808C91 /* AnnotationsExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = F890746B56E20150A053B41B /* AnnotationsExample.swift */; };
Expand Down Expand Up @@ -258,6 +259,7 @@
A8359ECB5024BEF3722C3CA1 /* PointAnnotationClusteringExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointAnnotationClusteringExample.swift; sourceTree = "<group>"; };
A8700CFE38DA4F1333F9E0F9 /* mapbox-maps-ios */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "mapbox-maps-ios"; path = ../..; sourceTree = SOURCE_ROOT; };
A9A26CBC58F3271DBFD2EE7D /* CustomLayerShaderTypes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CustomLayerShaderTypes.h; sourceTree = "<group>"; };
AC5A8729C9AEA4711B56B5F0 /* SnapshotMapExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotMapExample.swift; sourceTree = "<group>"; };
B05B410135D0B466B73C0765 /* annotations.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = annotations.json; sourceTree = "<group>"; };
B31A932A62B6142FE20C39DF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B33F64CDBA98B91EE819B2C4 /* AddMarkersSymbolExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMarkersSymbolExample.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -368,6 +370,7 @@
46CE3D9C2873C0767DD76D85 /* ClusteringExample.swift */,
A6B06A1D70F479D8DC5C375A /* FeaturesQueryExample.swift */,
62DA0608D44DEF6C4A82777C /* LocateMeExample.swift */,
AC5A8729C9AEA4711B56B5F0 /* SnapshotMapExample.swift */,
640198169EEDFC7CBEFCFCCF /* StandardStyleImportExample.swift */,
DD6F1212BB2453DBFECE12F2 /* StandardStyleLocationsExample.swift */,
90CEF3209781923E53974F20 /* SwiftUIRoot.swift */,
Expand Down Expand Up @@ -889,6 +892,7 @@
4791CACAC0846107E4B0955B /* SceneKitExample.swift in Sources */,
F613749DCDDDDC6F041032A0 /* SimpleMapExample.swift in Sources */,
1F860D5B445E75772C4C3B6C /* SkyLayerExample.swift in Sources */,
79843B780E7C5DC68433B745 /* SnapshotMapExample.swift in Sources */,
F2B385831A78B3EE16BFEA69 /* SnapshotterCoreGraphicsExample.swift in Sources */,
68FD9E1F4606B2729BA1E6DC /* SnapshotterExample.swift in Sources */,
442DB919BE75CE7B0A537757 /* SpinningGlobeExample.swift in Sources */,
Expand Down
48 changes: 48 additions & 0 deletions Apps/Examples/Examples/SwiftUI Examples/SnapshotMapExample.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import SwiftUI
@_spi(Experimental) import MapboxMaps

@available(iOS 13.0, *)
struct SnapshotMapExample: View {
@State var image: UIImage?

var body: some View {
GeometryReader { geometry in
VStack(spacing: 10) {
MapReader { proxy in
Map(initialViewport: .helsinkiOverview)
.mapStyle(.outdoors)
.onMapIdle { _ in image = proxy.captureSnapshot() }
.frame(height: geometry.size.height / 2)
}

SnapshotView(snapshot: image)
.frame(height: geometry.size.height / 2)
}
}
}
}

@available(iOS 13.0, *)
struct SnapshotView: View {
var snapshot: UIImage?

var body: some View {
if let snapshot {
Image(uiImage: snapshot)
} else {
EmptyView()
}
}
}

@available(iOS 13.0, *)
private extension Viewport {
static let helsinkiOverview = Self.overview(geometry: Polygon(center: .helsinki, radius: 10000, vertices: 30))
}

@available(iOS 13.0, *)
struct SnapshotMapExample_Preview: PreviewProvider {
static var previews: some View {
SnapshotMapExample()
}
}
4 changes: 3 additions & 1 deletion Apps/Examples/Examples/SwiftUI Examples/SwiftUIRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ struct SwiftUIRoot: View {
ExampleLink("Locate Me", note: "Use Viewport to create user location control.", destination: LocateMeExample())
ExampleLink("Locations", note: "New look of locations, configure standard style parameters.", destination: StandardStyleLocationsExample())
ExampleLink("Standard Style Import", note: "Import Mapbox Standard style into your custom style.", destination: StandardStyleImportExample())
ExampleLink("Simple Map", note: "Camera observing, automatic dark mode support.", destination: SimpleMapExample())
ExampleLink("Snapshot Map", note: "Make a snapshot of the map.", destination: SnapshotMapExample())
} header: { Text("Getting started") }

Section {
Expand All @@ -31,7 +33,7 @@ struct SwiftUIRoot: View {
ExampleLink("Viewport Playground", note: "Showcase of the possible viewport states.", destination: ViewportPlayground())
ExampleLink("Puck playground", note: "Display user location using puck.", destination: PuckPlayground())
ExampleLink("Annotation Order", destination: AnnotationsOrderTestExample())
ExampleLink("Simple Map", note: "Camera observing, automatic dark mode support.", destination: SimpleMapExample())

ExampleLink("Attribution url via callback", note: "Works on iOS 13+", destination: AttributionManualURLOpen())
if #available(iOS 15.0, *) {
ExampleLink("Attribution url open via environment", note: "Works on iOS 15+", destination: AttributionEnvironmentURLOpen())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI
@available(iOS 14.0, *)
struct SimpleMapExample: View {
@Environment(\.colorScheme) var colorScheme

var body: some View {
let polygon = Polygon(center: .helsinki, radius: 10000, vertices: 30)
Map(initialViewport: .overview(geometry: polygon))
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Mapbox welcomes participation and contributions from everyone.
* Fix taps propagation on `ViewAnnotation`.
* Fix view annotations positioning on `.ignoresSafeArea(.all)` in SwiftUI
* Bump core maps version to 11.2.0 and common sdk to 24.2.0.
* Expose `captureSnapshot` on `MapProxy` which allows to capture SwiftUI Map snapshot using `MapReader`
* Expose `opaque` and `frameRate` on SwiftUI Map
* Add `includeOverlays` parameter to `MapView.snapshot()`
* Added Attribution and Telemetry pop-up dialogs and compass view content description translations for Arabic, Belarusian, Bulgarian, Catalan, Chinese Simplified, Chinese Traditional, Czech, Danish, Dutch, French, Galician, German, Hebrew, Italian, Japanese, Korean, Lithuanian, Norwegian, Polish, Belarusian, Russian, Spanish, Swedish, Ukranian and Vietnamese.

## 11.2.0 - 28 February, 2024
Expand Down
23 changes: 16 additions & 7 deletions Sources/MapboxMaps/Foundation/MapView+Snapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import UIKit

extension MapView {

/// Errors related to rendered snapshots
public struct SnapshotError: Error, Equatable {
public let message: String
Expand All @@ -14,18 +13,28 @@ extension MapView {
public static let missingImageData = SnapshotError(message: "Missing image data")
}

/// Synchronously captures the rendered map as a `UIImage`. The image does not include the
/// ornaments (scale bar, compass, attribution, etc.) or any other custom subviews. Use
/// `drawHierarchy(in:afterScreenUpdates:)` directly to include the full hierarchy.
/// Synchronously captures the rendered map as a `UIImage`.
/// - Parameters:
/// - includeOverlays: Whether to show ornaments (scale bar, compass, attribution, etc.) or any other custom subviews on the resulting image.
/// - Returns: A `UIImage` of the rendered map
public func snapshot() throws -> UIImage {
public func snapshot(includeOverlays: Bool = false) throws -> UIImage {
guard !includeOverlays else {
return try image(for: self)
}

guard let metalView = metalView else {
Log.error(forMessage: "No metal view present.", category: "MapView.snapshot")
throw SnapshotError.noMetalView
}

return try image(for: metalView)
}

private func image(for view: UIView) throws -> UIImage {
var success = false
let image = UIGraphicsImageRenderer(bounds: metalView.bounds).image { _ in
success = metalView.drawHierarchy(in: metalView.bounds, afterScreenUpdates: true)

let image = UIGraphicsImageRenderer(bounds: view.bounds).image { _ in
success = view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
}
if !success {
throw SnapshotError.missingImageData
Expand Down
10 changes: 10 additions & 0 deletions Sources/MapboxMaps/SwiftUI/Deps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ struct MapDependencies {
var eventsSubscriptions = [AnyEventSubscription]()
var cameraChangeHandlers = [(CameraChanged) -> Void]()
var ornamentOptions = OrnamentOptions()
var frameRate = Map.FrameRate()
var debugOptions = MapViewDebugOptions()
var isOpaque = true
var presentsWithTransaction = false
var additionalSafeArea = SwiftUI.EdgeInsets()
var viewportOptions = ViewportOptions(transitionsToIdleUponUserInteraction: true, usesSafeAreaInsetsAsPadding: true)
Expand Down Expand Up @@ -47,3 +49,11 @@ extension Map {
var callback: (PerformanceStatistics) -> Void
}
}

@available(iOS 13.0, *)
extension Map {
struct FrameRate: Equatable {
var range: ClosedRange<Float>?
var preffered: Float?
}
}
30 changes: 26 additions & 4 deletions Sources/MapboxMaps/SwiftUI/Map.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import SwiftUI
/// ```
///
/// Check out the <doc:SwiftUI-User-Guide> for more information about ``Map`` capabilities, and the <doc:Map-Content-Gestures-User-Guide> for more information about gesture handling.
@_documentation(visibility: public)
@_documentation(visibility: public)
@_spi(Experimental)
@available(iOS 13.0, *)
public struct Map: UIViewControllerRepresentable {
Expand Down Expand Up @@ -95,7 +95,8 @@ public struct Map: UIViewControllerRepresentable {
) {
self.viewport = _viewport
self.urlOpenerProvider = urlOpenerProvider
content?().visit(mapContentVisitor)
content?()
.visit(mapContentVisitor)
}

public func makeCoordinator() -> Coordinator {
Expand Down Expand Up @@ -149,7 +150,7 @@ public struct Map: UIViewControllerRepresentable {
}
}

@_documentation(visibility: public)
@_documentation(visibility: public)
@available(iOS 13.0, *)
extension Map {

Expand Down Expand Up @@ -230,7 +231,7 @@ extension Map {
}
}

@_documentation(visibility: public)
@_documentation(visibility: public)
@available(iOS 13.0, *)
public extension Map {
/// Sets camera bounds.
Expand Down Expand Up @@ -278,6 +279,27 @@ public extension Map {
copyAssigned(self, \.mapDependencies.debugOptions, debugOptions)
}

/// A boolean value that determines whether the view is opaque. Default is true.
@_documentation(visibility: public)
func opaque(_ value: Bool) -> Self {
copyAssigned(self, \.mapDependencies.isOpaque, value)
}

/// The preferred frames per second used for map rendering.
/// The system can change the available range of frame rates because of factors in system policies and a person’s preferences.
/// Changing this setting maybe beneficial to get smoother experience on devices which support 120 FPS.
///
/// - Note: `range` values have effect only on iOS 15.0 and newer. Value`nil` in any of the parameters is interpreted as using system default value.
///
/// - Parameters:
/// - range: Allowed frame rate range. Negative and values less than 1 will be clamped to 1.
/// - preffered: Preffered frame rate. Negative and values less than 1 will be clamped to 1, while too large values will be clamped to Int.max.
@_documentation(visibility: public)
func frameRate(range: ClosedRange<Float>? = nil, preffered: Float? = nil) -> Self {
copyAssigned(
self, \.mapDependencies.frameRate, FrameRate(range: range, preffered: preffered))
}

/// A Boolean value that indicates whether the underlying `CAMetalLayer` of the `MapView`
/// presents its content using a CoreAnimation transaction
///
Expand Down
2 changes: 2 additions & 0 deletions Sources/MapboxMaps/SwiftUI/MapBasicCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ final class MapBasicCoordinator {
assign(&mapView, \.gestureManager.options, value: deps.gestureOptions)
assign(&mapView, \.ornaments.options, value: deps.ornamentOptions)
assign(&mapView, \.debugOptions, value: deps.debugOptions)
assign(&mapView, \.isOpaque, value: deps.isOpaque)
assign(&mapView, \.frameRate, value: deps.frameRate)
assign(&mapView, \.presentsWithTransaction, value: deps.presentsWithTransaction)
assign(&mapView, \.viewportManager.options, value: deps.viewportOptions)

Expand Down
14 changes: 14 additions & 0 deletions Sources/MapboxMaps/SwiftUI/MapProxy.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import SwiftUI

/// A proxy for access map interfaces on underlying Mapbox Map.
@_documentation(visibility: public)
@_spi(Experimental)
Expand All @@ -24,4 +26,16 @@ public struct MapProxy {
/// Handles location events on map.
@_documentation(visibility: public)
public var location: LocationManager? { provider.mapView?.location }

/// Captures the snapshot of the displayed Map.
///
/// - Parameters:
/// - includeOverlays: Whether to show ornaments (scale bar, compass, attribution, etc.) or any other custom subviews on the resulting image.
@_documentation(visibility: public)
public func captureSnapshot(includeOverlays: Bool = false) -> UIImage? {
guard let uiImage = try? provider.mapView?.snapshot(includeOverlays: includeOverlays) else {
return nil
}
return uiImage
}
}
57 changes: 57 additions & 0 deletions Sources/MapboxMaps/SwiftUI/MapViewFacade.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ struct MapViewFacade {
@MutableRef
var debugOptions: MapViewDebugOptions
@MutableRef
var isOpaque: Bool
@MutableRef
var presentsWithTransaction: Bool
@MutableRef
var frameRate: Map.FrameRate

var makeViewportTransition: (ViewportAnimation) -> ViewportTransition
var makeViewportState: (Viewport, LayoutDirection) -> ViewportState?
Expand All @@ -27,7 +31,9 @@ extension MapViewFacade {
viewportManager = mapView.viewport
ornaments = mapView.ornaments
_debugOptions = MutableRef(root: mapView, keyPath: \.debugOptions)
_isOpaque = MutableRef(root: mapView, keyPath: \.isOpaque)
_presentsWithTransaction = MutableRef(root: mapView, keyPath: \.presentsWithTransaction)
_frameRate = MutableRef(get: mapView.getFrameRate, set: mapView.set(frameRate:))

makeViewportTransition = { animation in
animation.makeViewportTransition(mapView)
Expand All @@ -37,3 +43,54 @@ extension MapViewFacade {
}
}
}

private extension MapView {
@available(iOS 13.0, *)
func set(frameRate: Map.FrameRate) {
if #available(iOS 15.0, *), let initialRange = frameRate.range {
let clampedRange = initialRange.clamped(to: 1...initialRange.upperBound)

if clampedRange != initialRange {
Log.warning(
forMessage: """
Provided frame rate range was clamped from \(initialRange) to \(clampedRange).
Negative or zero values are not allowed.
""",
category: "MapView"
)
}

preferredFrameRateRange = CAFrameRateRange(
minimum: clampedRange.lowerBound,
maximum: clampedRange.upperBound,
preferred: frameRate.preffered?.clamped(to: clampedRange)
)
} else if let preferred = frameRate.preffered {
let clampedValue = preferred.clamped(to: 1...Float(Int.max))

if clampedValue != preferred {
Log.warning(
forMessage: """
Preferred frame rate was clamped from \(preferred) to \(clampedValue).
Negative value, zero values and values larger then Int.max are not allowed.
""",
category: "MapView"
)
}

preferredFramesPerSecond = Int(clampedValue)
}
}

@available(iOS 13.0, *)
func getFrameRate() -> Map.FrameRate {
if #available(iOS 15.0, *) {
Map.FrameRate(
range: preferredFrameRateRange.minimum...preferredFrameRateRange.maximum,
preffered: preferredFrameRateRange.preferred
)
} else {
Map.FrameRate(range: nil, preffered: Float(preferredFramesPerSecond))
}
}
}
2 changes: 2 additions & 0 deletions Tests/MapboxMapsTests/SwiftUI/Mocks/MockMapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ struct MockMapView {
viewportManager: viewportManager,
ornaments: ornaments,
debugOptions: [],
isOpaque: false,
presentsWithTransaction: false,
frameRate: Map.FrameRate(),
makeViewportTransition: makeViewportTransitionStub.call(with:),
makeViewportState: { [makeViewportStateStub] viewport, layoutDirection in
makeViewportStateStub.call(with: MakeViewportParameters(viewport: viewport, layoutDirection: layoutDirection))
Expand Down
3 changes: 3 additions & 0 deletions scripts/api-compatibility-check/breakage_allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1332,3 +1332,6 @@ Constructor TilesetDescriptorOptions.init(styleURI:zoomRange:pixelRatio:tilesets

// Add onClusterTap/onClusterLongPress
Func AnnotationOrchestrator.makePointAnnotationManager(id:layerPosition:clusterOptions:) has been renamed to Func makePointAnnotationManager(id:layerPosition:clusterOptions:onClusterTap:onClusterLongPress:)

// Add `includeOverlays` parameter to `MapView.snapshot()`
Func MapView.snapshot() has been renamed to Func snapshot(includeOverlays:)

0 comments on commit f8075af

Please sign in to comment.