diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml
index 1fcc19e3e..d78f95d5d 100644
--- a/.github/workflows/ios-ci.yml
+++ b/.github/workflows/ios-ci.yml
@@ -77,6 +77,9 @@ jobs:
${{ runner.os }}-bazel-
path: ~/.cache/bazel
+ - name: Build Example (Swift) App
+ run: bazel build //platform/ios/app-swift:MapLibreApp --//:renderer=metal
+
- name: Check debug symbols
run: bazel run //platform:check-public-symbols --//:renderer=metal
diff --git a/platform/ios/BUILD.bazel b/platform/ios/BUILD.bazel
index d033f6c12..7bb7506cc 100644
--- a/platform/ios/BUILD.bazel
+++ b/platform/ios/BUILD.bazel
@@ -267,6 +267,13 @@ xcodeproj(
"device",
],
),
+ top_level_target(
+ "//platform/ios/app-swift:MapLibreApp",
+ target_environments = [
+ "simulator",
+ "device",
+ ],
+ ),
top_level_target(
"BenchmarkApp",
target_environments = [
diff --git a/platform/ios/CONTRIBUTING.md b/platform/ios/CONTRIBUTING.md
index 64f93712c..55ac9c744 100644
--- a/platform/ios/CONTRIBUTING.md
+++ b/platform/ios/CONTRIBUTING.md
@@ -27,3 +27,33 @@ resources=(
# add here
)
```
+
+## Examples
+
+The code samples in the documentation should ideally be compiled on CI so they do not go out of date.
+
+Fence your example code with
+
+```swift
+// #-example-code(LineTapMapView)
+...
+// #-end-example-code
+```
+
+Prefix your documentation code block with
+
+````md
+
+
+```swift
+...
+```
+````
+
+Then the code block will be updated when you run:
+
+```sh
+node scripts/update-ios-examples.mjs
+```
+
+You need to add the relevant example paths and documentation paths to that file.
\ No newline at end of file
diff --git a/platform/ios/MapLibre.docc/GettingStarted.md b/platform/ios/MapLibre.docc/GettingStarted.md
index be2665a6f..be5a8e1ac 100644
--- a/platform/ios/MapLibre.docc/GettingStarted.md
+++ b/platform/ios/MapLibre.docc/GettingStarted.md
@@ -30,25 +30,28 @@ 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 MapLibreMapView: UIViewRepresentable {
+
- func makeUIView(context: Context) -> MLNMapView {
+```swift
+struct SimpleMapView: UIViewRepresentable {
+ func makeUIView(context _: Context) -> MLNMapView {
let mapView = MLNMapView()
return mapView
}
-
- func updateUIView(_ mapView: MLNMapView, context: Context) {
- }
+
+ func updateUIView(_: MLNMapView, context _: Context) {}
}
```
You can use this view directly in a SwiftUI View hierarcy, for example:
```swift
-struct ContentView: View {
- var body: some View {
- MapLibreMapView()
+struct MyApp: App {
+
+ var body: some Scene {
+ WindowGroup {
+ MapLibreMapView().edgesIgnoringSafeArea(.all)
+ }
}
}
```
diff --git a/platform/ios/MapLibre.docc/LineOnUserTap.md b/platform/ios/MapLibre.docc/LineOnUserTap.md
index 776762b0b..e7d3dbe83 100644
--- a/platform/ios/MapLibre.docc/LineOnUserTap.md
+++ b/platform/ios/MapLibre.docc/LineOnUserTap.md
@@ -6,53 +6,54 @@ 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 MapLibreMapView: UIViewRepresentable {
-
+struct LineTapMapView: UIViewRepresentable {
func makeUIView(context: Context) -> MLNMapView {
let mapView = MLNMapView()
-
+
// Add a single tap gesture recognizer
let singleTap = UITapGestureRecognizer(
target: context.coordinator,
- action: #selector(Coordinator.handleMapTap(sender:)))
+ action: #selector(Coordinator.handleMapTap(sender:))
+ )
for recognizer in mapView.gestureRecognizers! where recognizer is UITapGestureRecognizer {
singleTap.require(toFail: recognizer)
}
mapView.addGestureRecognizer(singleTap)
return mapView
}
-
- func updateUIView(_ mapView: MLNMapView, context: Context) {
- }
-
+
+ func updateUIView(_: MLNMapView, context _: Context) {}
+
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
-
+
class Coordinator: NSObject {
- var parent: MapLibreMapView
-
- init(_ parent: MapLibreMapView) {
+ var parent: LineTapMapView
+
+ init(_ parent: LineTapMapView) {
self.parent = parent
}
-
+
@objc func handleMapTap(sender: UITapGestureRecognizer) {
guard let mapView = sender.view as? MLNMapView else { return }
-
+
// Convert tap location (CGPoint) to geographic coordinate (CLLocationCoordinate2D).
let tapPoint: CGPoint = sender.location(in: mapView)
let tapCoordinate: CLLocationCoordinate2D = mapView.convert(tapPoint, toCoordinateFrom: nil)
print("You tapped at: \(tapCoordinate.latitude), \(tapCoordinate.longitude)")
-
+
// Create an array of coordinates for our polyline, starting at the center of the map and ending at the tap coordinate.
var coordinates: [CLLocationCoordinate2D] = [mapView.centerCoordinate, tapCoordinate]
-
+
// Remove any existing polyline(s) from the map.
if let existingAnnotations = mapView.annotations {
mapView.removeAnnotations(existingAnnotations)
}
-
+
// Add a polyline with the new coordinates.
let polyline = MLNPolyline(coordinates: &coordinates, count: UInt(coordinates.count))
mapView.addAnnotation(polyline)
diff --git a/platform/ios/app-swift/BUILD.bazel b/platform/ios/app-swift/BUILD.bazel
new file mode 100644
index 000000000..9ecadbda9
--- /dev/null
+++ b/platform/ios/app-swift/BUILD.bazel
@@ -0,0 +1,32 @@
+load("@build_bazel_rules_apple//apple:ios.bzl", "ios_application")
+load(
+ "@build_bazel_rules_swift//swift:swift.bzl",
+ "swift_library",
+)
+load("//platform/ios/bazel:provisioning.bzl", "configure_device_profiles")
+
+configure_device_profiles()
+
+swift_library(
+ name = "Sources",
+ srcs = glob(["Sources/*.swift"]),
+ module_name = "Sources",
+ tags = ["manual"],
+ deps = [
+ "//platform:ios-sdk",
+ ],
+)
+
+ios_application(
+ name = "MapLibreApp",
+ bundle_id = "org.maplibre.swiftapp",
+ families = [
+ "iphone",
+ "ipad",
+ ],
+ infoplists = [":Info.plist"],
+ minimum_os_version = "15.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
new file mode 100644
index 000000000..95fdf06b7
--- /dev/null
+++ b/platform/ios/app-swift/Info.plist
@@ -0,0 +1,39 @@
+
+
+
+
+ UILaunchScreen
+
+ UILaunchScreen
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ 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
new file mode 100644
index 000000000..c53df936a
--- /dev/null
+++ b/platform/ios/app-swift/Sources/LineTapMapView.swift
@@ -0,0 +1,58 @@
+import MapLibre
+import SwiftUI
+import UIKit
+
+// #-example-code(LineTapMapView)
+struct LineTapMapView: UIViewRepresentable {
+ func makeUIView(context: Context) -> MLNMapView {
+ let mapView = MLNMapView()
+
+ // Add a single tap gesture recognizer
+ let singleTap = UITapGestureRecognizer(
+ target: context.coordinator,
+ action: #selector(Coordinator.handleMapTap(sender:))
+ )
+ for recognizer in mapView.gestureRecognizers! where recognizer is UITapGestureRecognizer {
+ singleTap.require(toFail: recognizer)
+ }
+ mapView.addGestureRecognizer(singleTap)
+ return mapView
+ }
+
+ func updateUIView(_: MLNMapView, context _: Context) {}
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ class Coordinator: NSObject {
+ var parent: LineTapMapView
+
+ init(_ parent: LineTapMapView) {
+ self.parent = parent
+ }
+
+ @objc func handleMapTap(sender: UITapGestureRecognizer) {
+ guard let mapView = sender.view as? MLNMapView else { return }
+
+ // Convert tap location (CGPoint) to geographic coordinate (CLLocationCoordinate2D).
+ let tapPoint: CGPoint = sender.location(in: mapView)
+ let tapCoordinate: CLLocationCoordinate2D = mapView.convert(tapPoint, toCoordinateFrom: nil)
+ print("You tapped at: \(tapCoordinate.latitude), \(tapCoordinate.longitude)")
+
+ // Create an array of coordinates for our polyline, starting at the center of the map and ending at the tap coordinate.
+ var coordinates: [CLLocationCoordinate2D] = [mapView.centerCoordinate, tapCoordinate]
+
+ // Remove any existing polyline(s) from the map.
+ if let existingAnnotations = mapView.annotations {
+ mapView.removeAnnotations(existingAnnotations)
+ }
+
+ // Add a polyline with the new coordinates.
+ let polyline = MLNPolyline(coordinates: &coordinates, count: UInt(coordinates.count))
+ mapView.addAnnotation(polyline)
+ }
+ }
+}
+
+// #-end-example-code
diff --git a/platform/ios/app-swift/Sources/MapLibreApp.swift b/platform/ios/app-swift/Sources/MapLibreApp.swift
new file mode 100644
index 000000000..69bd591de
--- /dev/null
+++ b/platform/ios/app-swift/Sources/MapLibreApp.swift
@@ -0,0 +1,12 @@
+import MapLibre
+import SwiftUI
+import UIKit
+
+@main
+struct MapLibreApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/platform/ios/app-swift/Sources/SimpleMapView.swift b/platform/ios/app-swift/Sources/SimpleMapView.swift
new file mode 100644
index 000000000..111df59a0
--- /dev/null
+++ b/platform/ios/app-swift/Sources/SimpleMapView.swift
@@ -0,0 +1,21 @@
+import MapLibre
+import SwiftUI
+import UIKit
+
+// #-example-code(SimpleMapView)
+struct SimpleMapView: UIViewRepresentable {
+ func makeUIView(context _: Context) -> MLNMapView {
+ let mapView = MLNMapView()
+ return mapView
+ }
+
+ func updateUIView(_: MLNMapView, context _: Context) {}
+}
+
+// #-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
new file mode 100644
index 000000000..105fdb8a4
--- /dev/null
+++ b/scripts/update-ios-examples.mjs
@@ -0,0 +1,136 @@
+/**
+ * This script updates the iOS examples part of the DocC documentation.
+ * See platform/ios/CONTRIBUTING.md for the details.
+ */
+
+import fs from "fs/promises";
+
+/** documentation files that will be updated */
+const documentationPaths = [
+ "platform/ios/MapLibre.docc/GettingStarted.md",
+ "platform/ios/MapLibre.docc/LineOnUserTap.md",
+];
+
+/** source files that contain examples */
+const examplePaths = [
+ "platform/ios/app-swift/Sources/SimpleMapView.swift",
+ "platform/ios/app-swift/Sources/LineTapMapView.swift"
+];
+
+/**
+ * Parses a source file and looks for examples fenced in
+ * ```
+ * // #-example-code(ExampleId)
+ * ...
+ * // #-end-example-code
+ * ```
+ * @param {string} examplePath
+ * @returns Mapping from example id to example, e.g. {"ExampleId": "..."}
+ */
+async function parseExampleCode(examplePath) {
+ const content = await fs.readFile(examplePath, "utf8");
+ const lines = content.split("\n");
+ /** @type {Record} */
+ const exampleCodeMap = {};
+
+ let capturing = false;
+ let currentId = "";
+
+ for (const line of lines) {
+ if (capturing) {
+ if (line.includes("// #-end-example-code")) {
+ capturing = false;
+ } else {
+ if (!exampleCodeMap[currentId]) {
+ exampleCodeMap[currentId] = "";
+ }
+ exampleCodeMap[currentId] += line + "\n";
+ }
+ } else {
+ const match = line.match(/\/\/ #\-example-code\(([^)]+)\)/);
+ if (match) {
+ currentId = match[1];
+ capturing = true;
+ }
+ }
+ }
+
+ return exampleCodeMap;
+}
+
+/**
+ * @returns Record of all example id to example code
+ */
+async function loadExamples() {
+ /** @type {Record} */
+ let examples = {};
+ 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`);
+ }
+ examples = {...newExamples, ...examples};
+ }
+ return examples;
+}
+
+/**
+ * Replaces code block in examples with actual example in source code.
+ *
+ * @param {string} documentationPath
+ * @param {Record} examples
+ */
+async function updateDocumentation(documentationPath, examples) {
+ const content = await fs.readFile(documentationPath, 'utf8');
+ const lines = content.split('\n');
+
+ // This status implements little state machine.
+ /** @type {{tag: 'find-example'} | {tag: 'find-codeblock', id: string} | {tag: 'skip-to-codeblock-end'}} */
+ let status = {tag: 'find-example'};
+ let currentId = '';
+ let outputLines = [];
+
+ for (const line of lines) {
+ if (status.tag === 'find-example') { // look for the start of an example
+ const match = line.match(//);
+ if (match) { // found example
+ currentId = match[1];
+ status = {tag: 'find-codeblock', id: currentId};
+ }
+ outputLines.push(line); // otherwise include the line as-is
+ } else if (status.tag === 'find-codeblock') { // look for a code block to replace
+ if (line === '```swift') { // found it!
+ outputLines.push(line);
+ if (!examples[status.id]) throw Error(`Expected example with id='${status.id}'`);
+ outputLines.push(examples[status.id].trim());
+ status = {tag: 'skip-to-codeblock-end'};
+ } else {
+ outputLines.push(line);
+ }
+ } else if (status.tag === 'skip-to-codeblock-end') {
+ if (line === '```') {
+ outputLines.push(line);
+ status = {tag: 'find-example'}; // look for the next example
+ } else {
+ continue;
+ }
+ }
+ }
+
+ if (status.tag === 'find-codeblock') {
+ throw Error(`Ended evalution while looking for codeblock with id '${status.id}'`);
+ }
+
+ if (status.tag === 'skip-to-codeblock-end') {
+ throw Error("Could not find end of codeblock");
+ }
+
+ await fs.writeFile(documentationPath, outputLines.join('\n'));
+}
+
+const examples = await loadExamples();
+console.log(`Found examples: ${Object.keys(examples).join(", ")}`);
+
+for (const documentationPath of documentationPaths) {
+ await updateDocumentation(documentationPath, examples);
+}
\ No newline at end of file