diff --git a/platform/ios/CONTRIBUTING.md b/platform/ios/CONTRIBUTING.md index 55ac9c744..1f4d1bafa 100644 --- a/platform/ios/CONTRIBUTING.md +++ b/platform/ios/CONTRIBUTING.md @@ -35,7 +35,7 @@ The code samples in the documentation should ideally be compiled on CI so they d Fence your example code with ```swift -// #-example-code(LineTapMapView) +// #-example-code(LineTapMap) ... // #-end-example-code ``` @@ -43,7 +43,7 @@ Fence your example code with Prefix your documentation code block with ````md - + ```swift ... diff --git a/platform/ios/MapLibre.docc/GettingStarted.md b/platform/ios/MapLibre.docc/GettingStarted.md index be5a8e1ac..72538f892 100644 --- a/platform/ios/MapLibre.docc/GettingStarted.md +++ b/platform/ios/MapLibre.docc/GettingStarted.md @@ -30,10 +30,10 @@ import MapLibre To use MapLibre with SwiftUI we need to create a wrapper for the UIKit view that MapLibre provides (using UIViewRepresentable. The simplest way to implement this protocol is as follows: - + ```swift -struct SimpleMapView: UIViewRepresentable { +struct SimpleMap: UIViewRepresentable { func makeUIView(context _: Context) -> MLNMapView { let mapView = MLNMapView() return mapView diff --git a/platform/ios/MapLibre.docc/LineOnUserTap.md b/platform/ios/MapLibre.docc/LineOnUserTap.md index e7d3dbe83..5dca589a9 100644 --- a/platform/ios/MapLibre.docc/LineOnUserTap.md +++ b/platform/ios/MapLibre.docc/LineOnUserTap.md @@ -6,10 +6,10 @@ Demonstrating adding ``MLNPolyline`` annotations and responding to user input. This example draws a line from the tapped location to the center of the map. Handling the tap is done by the `Coordinator` class. It converts the location on the view to a geographic coordinate. It removes existing annotations before adding the new line. - + ```swift -struct LineTapMapView: UIViewRepresentable { +struct LineTapMap: UIViewRepresentable { func makeUIView(context: Context) -> MLNMapView { let mapView = MLNMapView() @@ -32,9 +32,9 @@ struct LineTapMapView: UIViewRepresentable { } class Coordinator: NSObject { - var parent: LineTapMapView + var parent: LineTapMap - init(_ parent: LineTapMapView) { + init(_ parent: LineTapMap) { self.parent = parent } diff --git a/platform/ios/MapLibre.docc/LocationPrivacyExample.md b/platform/ios/MapLibre.docc/LocationPrivacyExample.md new file mode 100644 index 000000000..65711794e --- /dev/null +++ b/platform/ios/MapLibre.docc/LocationPrivacyExample.md @@ -0,0 +1,132 @@ +# User Location & Location Privacy + +Requesting precise location with ``MLNLocationManager``. + +## Overview + +This example shows how to request a precise location with ``MLNLocationManager``. + +First of all you need to prepare your `Info.plist`. You need to provide a description why your app needs to access location: + +```plist +NSLocationWhenInUseUsageDescription +Dummy Location When In Use Description +``` + +As well as a description why your app needs precise location. + +```plist +NSLocationTemporaryUsageDescriptionDictionary + + MLNAccuracyAuthorizationDescription + Dummy Precise Location Description + +``` + +The `Coordinator` defined below is also the ``MLNMapViewDelegate``. When the location manager authorization changes it will call the relevant method. If precise location has not been granted, a button is shown at the bottom of the map. + +![](ImpreciseLocation.png) + +When the button is pressed a pop-up with the description we set in `Info.plist` will be shown: + +![](PreciseLocationRequestPopup.png) + + + +```swift +enum LocationAccuracyState { + case unknown + case reducedAccuracy + case fullAccuracy +} + +class Coordinator: NSObject, MLNMapViewDelegate { + @Binding var mapView: MLNMapView + @Binding var locationAccuracy: LocationAccuracyState + var pannedToUserLocation = false + + init(mapView: Binding, locationAccuracy: Binding) { + _mapView = mapView + _locationAccuracy = locationAccuracy + } + + func mapView(_: MLNMapView, didChangeLocationManagerAuthorization manager: MLNLocationManager) { + guard let accuracySetting = manager.accuracyAuthorization else { + return + } + + switch accuracySetting() { + case .fullAccuracy: + locationAccuracy = .fullAccuracy + case .reducedAccuracy: + locationAccuracy = .reducedAccuracy + @unknown default: + locationAccuracy = .unknown + } + } + + // when a location is available for the first time, we fly to it + func mapView(_ mapView: MLNMapView, didUpdate _: MLNUserLocation?) { + if pannedToUserLocation { + return + } + guard let userLocation = mapView.userLocation else { + print("User location is currently not available.") + return + } + mapView.fly(to: MLNMapCamera(lookingAtCenter: userLocation.coordinate, altitude: 100_000, pitch: 0, heading: 0)) + } +} + +struct LocationPrivacyExample: UIViewRepresentable { + @Binding var mapView: MLNMapView + @Binding var locationAccuracy: LocationAccuracyState + + func makeCoordinator() -> Coordinator { + Coordinator(mapView: $mapView, locationAccuracy: $locationAccuracy) + } + + func makeUIView(context: Context) -> MLNMapView { + let mapView = MLNMapView() + mapView.showsUserLocation = true + mapView.delegate = context.coordinator + + return mapView + } + + func updateUIView(_: MLNMapView, context _: Context) {} +} + +struct LocationPrivacyExampleView: View { + @State private var mapView = MLNMapView() + @State var locationAccuracy: LocationAccuracyState = .unknown + + var body: some View { + VStack { + LocationPrivacyExample(mapView: $mapView, locationAccuracy: $locationAccuracy) + .edgesIgnoringSafeArea(.all) + + if locationAccuracy == LocationAccuracyState.reducedAccuracy { + Button("Request Precise Location") { + handleButtonPress(mapView: mapView) + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + + private func handleButtonPress(mapView: MLNMapView) { + print("Requesting precice location") + switch locationAccuracy { + case .reducedAccuracy: + let purposeKey = "MLNAccuracyAuthorizationDescription" + mapView.locationManager.requestTemporaryFullAccuracyAuthorization!(withPurposeKey: purposeKey) + default: + break + } + } +} +``` \ No newline at end of file diff --git a/platform/ios/MapLibre.docc/MapLibre.md b/platform/ios/MapLibre.docc/MapLibre.md index 51a797419..c1595de3f 100644 --- a/platform/ios/MapLibre.docc/MapLibre.md +++ b/platform/ios/MapLibre.docc/MapLibre.md @@ -15,6 +15,7 @@ Powerful, free and open-source mapping toolkit with full control over data sourc - - +- ## Topics diff --git a/platform/ios/app-swift/BUILD.bazel b/platform/ios/app-swift/BUILD.bazel index 9ecadbda9..92c4999c2 100644 --- a/platform/ios/app-swift/BUILD.bazel +++ b/platform/ios/app-swift/BUILD.bazel @@ -25,7 +25,7 @@ ios_application( "ipad", ], infoplists = [":Info.plist"], - minimum_os_version = "15.0", + minimum_os_version = "16.0", provisioning_profile = "xcode_profile", visibility = ["@rules_xcodeproj//xcodeproj:generated"], deps = [":Sources"], diff --git a/platform/ios/app-swift/Info.plist b/platform/ios/app-swift/Info.plist index 95fdf06b7..9ece1a40c 100644 --- a/platform/ios/app-swift/Info.plist +++ b/platform/ios/app-swift/Info.plist @@ -2,6 +2,15 @@ + NSLocationTemporaryUsageDescriptionDictionary + + MLNAccuracyAuthorizationDescription + Dummy Precise Location Description + + LSApplicationCategoryType + + NSLocationWhenInUseUsageDescription + Dummy Location When In Use Description UILaunchScreen UILaunchScreen @@ -36,4 +45,4 @@ UIInterfaceOrientationLandscapeRight - \ No newline at end of file + diff --git a/platform/ios/app-swift/Sources/LineTapMapView.swift b/platform/ios/app-swift/Sources/LineTapMapView.swift index c53df936a..daaeb0d80 100644 --- a/platform/ios/app-swift/Sources/LineTapMapView.swift +++ b/platform/ios/app-swift/Sources/LineTapMapView.swift @@ -2,8 +2,8 @@ import MapLibre import SwiftUI import UIKit -// #-example-code(LineTapMapView) -struct LineTapMapView: UIViewRepresentable { +// #-example-code(LineTapMap) +struct LineTapMap: UIViewRepresentable { func makeUIView(context: Context) -> MLNMapView { let mapView = MLNMapView() @@ -26,9 +26,9 @@ struct LineTapMapView: UIViewRepresentable { } class Coordinator: NSObject { - var parent: LineTapMapView + var parent: LineTapMap - init(_ parent: LineTapMapView) { + init(_ parent: LineTapMap) { self.parent = parent } diff --git a/platform/ios/app-swift/Sources/LocationPrivacyExample.swift b/platform/ios/app-swift/Sources/LocationPrivacyExample.swift new file mode 100644 index 000000000..8b65a5c05 --- /dev/null +++ b/platform/ios/app-swift/Sources/LocationPrivacyExample.swift @@ -0,0 +1,102 @@ +import MapLibre +import SwiftUI +import UIKit + +// #-example-code(LocationPrivacyExample) +enum LocationAccuracyState { + case unknown + case reducedAccuracy + case fullAccuracy +} + +class Coordinator: NSObject, MLNMapViewDelegate { + @Binding var mapView: MLNMapView + @Binding var locationAccuracy: LocationAccuracyState + var pannedToUserLocation = false + + init(mapView: Binding, locationAccuracy: Binding) { + _mapView = mapView + _locationAccuracy = locationAccuracy + } + + func mapView(_: MLNMapView, didChangeLocationManagerAuthorization manager: MLNLocationManager) { + guard let accuracySetting = manager.accuracyAuthorization else { + return + } + + switch accuracySetting() { + case .fullAccuracy: + locationAccuracy = .fullAccuracy + case .reducedAccuracy: + locationAccuracy = .reducedAccuracy + @unknown default: + locationAccuracy = .unknown + } + } + + // when a location is available for the first time, we fly to it + func mapView(_ mapView: MLNMapView, didUpdate _: MLNUserLocation?) { + if pannedToUserLocation { + return + } + guard let userLocation = mapView.userLocation else { + print("User location is currently not available.") + return + } + mapView.fly(to: MLNMapCamera(lookingAtCenter: userLocation.coordinate, altitude: 100_000, pitch: 0, heading: 0)) + } +} + +struct LocationPrivacyExample: UIViewRepresentable { + @Binding var mapView: MLNMapView + @Binding var locationAccuracy: LocationAccuracyState + + func makeCoordinator() -> Coordinator { + Coordinator(mapView: $mapView, locationAccuracy: $locationAccuracy) + } + + func makeUIView(context: Context) -> MLNMapView { + let mapView = MLNMapView() + mapView.showsUserLocation = true + mapView.delegate = context.coordinator + + return mapView + } + + func updateUIView(_: MLNMapView, context _: Context) {} +} + +struct LocationPrivacyExampleView: View { + @State private var mapView = MLNMapView() + @State var locationAccuracy: LocationAccuracyState = .unknown + + var body: some View { + VStack { + LocationPrivacyExample(mapView: $mapView, locationAccuracy: $locationAccuracy) + .edgesIgnoringSafeArea(.all) + + if locationAccuracy == LocationAccuracyState.reducedAccuracy { + Button("Request Precise Location") { + handleButtonPress(mapView: mapView) + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + + private func handleButtonPress(mapView: MLNMapView) { + print("Requesting precice location") + switch locationAccuracy { + case .reducedAccuracy: + let purposeKey = "MLNAccuracyAuthorizationDescription" + mapView.locationManager.requestTemporaryFullAccuracyAuthorization!(withPurposeKey: purposeKey) + default: + break + } + } +} + +// #-end-example-code diff --git a/platform/ios/app-swift/Sources/MapLibreApp.swift b/platform/ios/app-swift/Sources/MapLibreApp.swift index 69bd591de..85c8caa63 100644 --- a/platform/ios/app-swift/Sources/MapLibreApp.swift +++ b/platform/ios/app-swift/Sources/MapLibreApp.swift @@ -6,7 +6,7 @@ import UIKit struct MapLibreApp: App { var body: some Scene { WindowGroup { - ContentView() + MapLibreNavigationView() } } } diff --git a/platform/ios/app-swift/Sources/MapLibreNavigationView.swift b/platform/ios/app-swift/Sources/MapLibreNavigationView.swift new file mode 100644 index 000000000..8559de588 --- /dev/null +++ b/platform/ios/app-swift/Sources/MapLibreNavigationView.swift @@ -0,0 +1,20 @@ +import MapLibre +import SwiftUI + +struct MapLibreNavigationView: View { + var body: some View { + NavigationStack { + List { + NavigationLink("SimpleMap") { + SimpleMap().edgesIgnoringSafeArea(.all) + } + NavigationLink("LineTapMap") { + LineTapMap().edgesIgnoringSafeArea(.all) + } + NavigationLink("LocationPrivacyExample") { + LocationPrivacyExampleView() + } + } + } + } +} diff --git a/platform/ios/app-swift/Sources/SimpleMapView.swift b/platform/ios/app-swift/Sources/SimpleMapView.swift index 111df59a0..893157a8e 100644 --- a/platform/ios/app-swift/Sources/SimpleMapView.swift +++ b/platform/ios/app-swift/Sources/SimpleMapView.swift @@ -2,8 +2,8 @@ import MapLibre import SwiftUI import UIKit -// #-example-code(SimpleMapView) -struct SimpleMapView: UIViewRepresentable { +// #-example-code(SimpleMap) +struct SimpleMap: UIViewRepresentable { func makeUIView(context _: Context) -> MLNMapView { let mapView = MLNMapView() return mapView @@ -13,9 +13,3 @@ struct SimpleMapView: UIViewRepresentable { } // #-end-example-code - -struct ContentView: View { - var body: some View { - SimpleMapView().edgesIgnoringSafeArea(.all) - } -} diff --git a/scripts/update-ios-examples.mjs b/scripts/update-ios-examples.mjs index 105fdb8a4..26dbb43c3 100644 --- a/scripts/update-ios-examples.mjs +++ b/scripts/update-ios-examples.mjs @@ -4,18 +4,23 @@ */ import fs from "fs/promises"; +import { join } from "path"; -/** documentation files that will be updated */ -const documentationPaths = [ - "platform/ios/MapLibre.docc/GettingStarted.md", - "platform/ios/MapLibre.docc/LineOnUserTap.md", -]; +/** + * @param {string} baseDir + * @param {(path: string) => boolean} predicate + * @returns paths + */ +async function getFilePaths(baseDir, predicate) { + const entries = (await fs.readdir(baseDir, { withFileTypes: true })) + return entries.map((entry) => join(entry.path, entry.name)).filter(predicate); +} -/** source files that contain examples */ -const examplePaths = [ - "platform/ios/app-swift/Sources/SimpleMapView.swift", - "platform/ios/app-swift/Sources/LineTapMapView.swift" -]; +// documentation files that will be updated and source files that contain examples +const [documentationPaths, examplePaths] = await Promise.all([ + getFilePaths("platform/ios/MapLibre.docc", path => path.endsWith(".md")), + getFilePaths("platform/ios/app-swift/Sources", path => path.endsWith(".swift")) +]); /** * Parses a source file and looks for examples fenced in @@ -67,7 +72,8 @@ async function loadExamples() { for (const examplePath of examplePaths) { const newExamples = await parseExampleCode(examplePath); if (Object.keys(newExamples).length === 0) { - throw new Error(`File '${examplePath}' does not contain any examples`); + // no examples found + continue; } examples = {...newExamples, ...examples}; }