From b61c87bb5b348842ac28c535c8d35b65a8bf83c7 Mon Sep 17 00:00:00 2001 From: Bart Louwers Date: Sun, 12 May 2024 23:44:44 +0200 Subject: [PATCH] Compile iOS Examples (#2388) --- .github/workflows/ios-ci.yml | 3 + platform/ios/BUILD.bazel | 7 + platform/ios/CONTRIBUTING.md | 30 ++++ platform/ios/MapLibre.docc/GettingStarted.md | 21 +-- platform/ios/MapLibre.docc/LineOnUserTap.md | 35 ++--- platform/ios/app-swift/BUILD.bazel | 32 +++++ platform/ios/app-swift/Info.plist | 39 +++++ .../app-swift/Sources/LineTapMapView.swift | 58 ++++++++ .../ios/app-swift/Sources/MapLibreApp.swift | 12 ++ .../ios/app-swift/Sources/SimpleMapView.swift | 21 +++ scripts/update-ios-examples.mjs | 136 ++++++++++++++++++ 11 files changed, 368 insertions(+), 26 deletions(-) create mode 100644 platform/ios/app-swift/BUILD.bazel create mode 100644 platform/ios/app-swift/Info.plist create mode 100644 platform/ios/app-swift/Sources/LineTapMapView.swift create mode 100644 platform/ios/app-swift/Sources/MapLibreApp.swift create mode 100644 platform/ios/app-swift/Sources/SimpleMapView.swift create mode 100644 scripts/update-ios-examples.mjs 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