Compile iOS Examples (#2388)
This commit is contained in:
3
.github/workflows/ios-ci.yml
vendored
3
.github/workflows/ios-ci.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -267,6 +267,13 @@ xcodeproj(
|
||||
"device",
|
||||
],
|
||||
),
|
||||
top_level_target(
|
||||
"//platform/ios/app-swift:MapLibreApp",
|
||||
target_environments = [
|
||||
"simulator",
|
||||
"device",
|
||||
],
|
||||
),
|
||||
top_level_target(
|
||||
"BenchmarkApp",
|
||||
target_environments = [
|
||||
|
||||
@@ -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
|
||||
<!-- include-example(LineTapMapView) -->
|
||||
|
||||
```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.
|
||||
@@ -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 {
|
||||
<!-- include-example(SimpleMapView) -->
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- include-example(LineTapMapView) -->
|
||||
|
||||
```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)
|
||||
|
||||
32
platform/ios/app-swift/BUILD.bazel
Normal file
32
platform/ios/app-swift/BUILD.bazel
Normal file
@@ -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"],
|
||||
)
|
||||
39
platform/ios/app-swift/Info.plist
Normal file
39
platform/ios/app-swift/Info.plist
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
58
platform/ios/app-swift/Sources/LineTapMapView.swift
Normal file
58
platform/ios/app-swift/Sources/LineTapMapView.swift
Normal file
@@ -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
|
||||
12
platform/ios/app-swift/Sources/MapLibreApp.swift
Normal file
12
platform/ios/app-swift/Sources/MapLibreApp.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import MapLibre
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct MapLibreApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
21
platform/ios/app-swift/Sources/SimpleMapView.swift
Normal file
21
platform/ios/app-swift/Sources/SimpleMapView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
136
scripts/update-ios-examples.mjs
Normal file
136
scripts/update-ios-examples.mjs
Normal file
@@ -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<string, string>} */
|
||||
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<string, string>} */
|
||||
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<string, string>} 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(/<!-- include-example\(([^)]+)\) -->/);
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user