@craft-native/ios 0.0.236 → 0.0.237
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-shortcuts.d.ts +11 -0
- package/dist/cli.js +56 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +56 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +16 -0
- package/templates/CraftNativeScreen.swift +166 -6
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface CraftAppShortcut {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
subtitle?: string;
|
|
5
|
+
symbol: string;
|
|
6
|
+
url: string;
|
|
7
|
+
/** Each phrase includes exactly one `{app}` token for Siri's app name. */
|
|
8
|
+
phrases: string[];
|
|
9
|
+
}
|
|
10
|
+
/** Build-owned App Intents survive shell restoration and need no app Swift. */
|
|
11
|
+
export declare function appShortcutsSwift(shortcuts?: CraftAppShortcut[], schemes?: string[]): string;
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,60 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync as readdirSync
|
|
|
10
10
|
import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
|
|
11
11
|
var {$ } = globalThis.Bun;
|
|
12
12
|
|
|
13
|
+
// src/app-shortcuts.ts
|
|
14
|
+
function literal(value) {
|
|
15
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
|
|
16
|
+
}
|
|
17
|
+
function appShortcutsSwift(shortcuts = [], schemes = []) {
|
|
18
|
+
if (shortcuts.length > 10)
|
|
19
|
+
throw new Error("iOS supports at most ten app shortcuts");
|
|
20
|
+
const ids = new Set;
|
|
21
|
+
for (const shortcut of shortcuts) {
|
|
22
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(shortcut.id) || ids.has(shortcut.id))
|
|
23
|
+
throw new Error(`Invalid or duplicate app shortcut id: ${shortcut.id}`);
|
|
24
|
+
ids.add(shortcut.id);
|
|
25
|
+
const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(shortcut.url)?.[1];
|
|
26
|
+
if (!scheme || !schemes.includes(scheme) || /[\s\\]/.test(shortcut.url))
|
|
27
|
+
throw new Error(`App shortcut ${shortcut.id} needs a registered app URL scheme`);
|
|
28
|
+
if (!shortcut.title?.trim() || !shortcut.symbol?.trim() || !shortcut.phrases?.length || shortcut.phrases.some((phrase) => phrase.split("{app}").length !== 2))
|
|
29
|
+
throw new Error(`App shortcut ${shortcut.id} needs a title, symbol and phrases with exactly one {app}`);
|
|
30
|
+
}
|
|
31
|
+
const name = (id) => `CraftShortcut${id.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("")}Intent`;
|
|
32
|
+
const intents = shortcuts.map((shortcut) => `@available(iOS 16.4, *)
|
|
33
|
+
struct ${name(shortcut.id)}: AppIntent {
|
|
34
|
+
static var title: LocalizedStringResource = ${literal(shortcut.title)}
|
|
35
|
+
static var description = IntentDescription(${literal(shortcut.subtitle ?? shortcut.title)})
|
|
36
|
+
static var openAppWhenRun: Bool = true
|
|
37
|
+
@MainActor
|
|
38
|
+
func perform() async throws -> some IntentResult {
|
|
39
|
+
if let url = URL(string: ${literal(shortcut.url)}) { await UIApplication.shared.open(url) }
|
|
40
|
+
return .result()
|
|
41
|
+
}
|
|
42
|
+
}`).join(`
|
|
43
|
+
|
|
44
|
+
`);
|
|
45
|
+
const entries = shortcuts.map((shortcut) => ` AppShortcut(
|
|
46
|
+
intent: ${name(shortcut.id)}(),
|
|
47
|
+
phrases: [${shortcut.phrases.map((phrase) => literal(phrase).replace("{app}", "\\(.applicationName)")).join(", ")}],
|
|
48
|
+
shortTitle: ${literal(shortcut.title)},
|
|
49
|
+
systemImageName: ${literal(shortcut.symbol)}
|
|
50
|
+
)`).join(`
|
|
51
|
+
`);
|
|
52
|
+
return `// Generated by Craft from appShortcuts. Restored from the package at build time.
|
|
53
|
+
import AppIntents
|
|
54
|
+
import UIKit
|
|
55
|
+
|
|
56
|
+
${intents}
|
|
57
|
+
${shortcuts.length ? `
|
|
58
|
+
@available(iOS 16.4, *)
|
|
59
|
+
struct CraftConfiguredAppShortcuts: AppShortcutsProvider {
|
|
60
|
+
static var appShortcuts: [AppShortcut] {
|
|
61
|
+
${entries}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
` : ""}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
13
67
|
// src/source-audit.ts
|
|
14
68
|
import { readdirSync } from "fs";
|
|
15
69
|
import { join, relative, sep } from "path";
|
|
@@ -890,11 +944,13 @@ function writeShell(output, config) {
|
|
|
890
944
|
if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
|
|
891
945
|
throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
|
|
892
946
|
const name = productName(config.appName);
|
|
947
|
+
const shortcutSource = appShortcutsSwift(config.appShortcuts, config.urlSchemes);
|
|
893
948
|
mkdirSync(join3(output, "Sources"), { recursive: true });
|
|
894
949
|
mkdirSync(join3(output, "Shared"), { recursive: true });
|
|
895
950
|
const swiftTemplate = readFileSync(join3(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
|
|
896
951
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, config.bundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
|
|
897
952
|
writeFileSync(join3(output, "Sources", `${name}App.swift`), swiftSource);
|
|
953
|
+
writeFileSync(join3(output, "Sources", "CraftAppShortcuts.swift"), shortcutSource);
|
|
898
954
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeScreen.swift"), join3(output, "Sources", "CraftNativeScreen.swift"));
|
|
899
955
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join3(output, "Sources", "CraftNativeFlatList.swift"));
|
|
900
956
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
|
package/dist/index.d.ts
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Generates native iOS apps from web content using WKWebView.
|
|
5
5
|
*/
|
|
6
|
+
import { type CraftAppShortcut } from './app-shortcuts.js';
|
|
6
7
|
/** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
|
|
7
8
|
export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], output: string): string[];
|
|
8
9
|
/** This package's version, which the app names in its user agent (`Craft/<version>`). */
|
|
9
10
|
export declare function resolveCraftVersion(startDir?: string): string;
|
|
10
11
|
export type CraftAppearance = 'light' | 'dark' | 'system';
|
|
11
12
|
export interface CraftConfig {
|
|
13
|
+
appShortcuts?: CraftAppShortcut[];
|
|
12
14
|
/** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
|
|
13
15
|
nativeFonts?: Record<string, {
|
|
14
16
|
src: string;
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,60 @@ import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSy
|
|
|
4
4
|
import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
|
|
5
5
|
var {$ } = globalThis.Bun;
|
|
6
6
|
|
|
7
|
+
// src/app-shortcuts.ts
|
|
8
|
+
function literal(value) {
|
|
9
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
|
|
10
|
+
}
|
|
11
|
+
function appShortcutsSwift(shortcuts = [], schemes = []) {
|
|
12
|
+
if (shortcuts.length > 10)
|
|
13
|
+
throw new Error("iOS supports at most ten app shortcuts");
|
|
14
|
+
const ids = new Set;
|
|
15
|
+
for (const shortcut of shortcuts) {
|
|
16
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(shortcut.id) || ids.has(shortcut.id))
|
|
17
|
+
throw new Error(`Invalid or duplicate app shortcut id: ${shortcut.id}`);
|
|
18
|
+
ids.add(shortcut.id);
|
|
19
|
+
const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(shortcut.url)?.[1];
|
|
20
|
+
if (!scheme || !schemes.includes(scheme) || /[\s\\]/.test(shortcut.url))
|
|
21
|
+
throw new Error(`App shortcut ${shortcut.id} needs a registered app URL scheme`);
|
|
22
|
+
if (!shortcut.title?.trim() || !shortcut.symbol?.trim() || !shortcut.phrases?.length || shortcut.phrases.some((phrase) => phrase.split("{app}").length !== 2))
|
|
23
|
+
throw new Error(`App shortcut ${shortcut.id} needs a title, symbol and phrases with exactly one {app}`);
|
|
24
|
+
}
|
|
25
|
+
const name = (id) => `CraftShortcut${id.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("")}Intent`;
|
|
26
|
+
const intents = shortcuts.map((shortcut) => `@available(iOS 16.4, *)
|
|
27
|
+
struct ${name(shortcut.id)}: AppIntent {
|
|
28
|
+
static var title: LocalizedStringResource = ${literal(shortcut.title)}
|
|
29
|
+
static var description = IntentDescription(${literal(shortcut.subtitle ?? shortcut.title)})
|
|
30
|
+
static var openAppWhenRun: Bool = true
|
|
31
|
+
@MainActor
|
|
32
|
+
func perform() async throws -> some IntentResult {
|
|
33
|
+
if let url = URL(string: ${literal(shortcut.url)}) { await UIApplication.shared.open(url) }
|
|
34
|
+
return .result()
|
|
35
|
+
}
|
|
36
|
+
}`).join(`
|
|
37
|
+
|
|
38
|
+
`);
|
|
39
|
+
const entries = shortcuts.map((shortcut) => ` AppShortcut(
|
|
40
|
+
intent: ${name(shortcut.id)}(),
|
|
41
|
+
phrases: [${shortcut.phrases.map((phrase) => literal(phrase).replace("{app}", "\\(.applicationName)")).join(", ")}],
|
|
42
|
+
shortTitle: ${literal(shortcut.title)},
|
|
43
|
+
systemImageName: ${literal(shortcut.symbol)}
|
|
44
|
+
)`).join(`
|
|
45
|
+
`);
|
|
46
|
+
return `// Generated by Craft from appShortcuts. Restored from the package at build time.
|
|
47
|
+
import AppIntents
|
|
48
|
+
import UIKit
|
|
49
|
+
|
|
50
|
+
${intents}
|
|
51
|
+
${shortcuts.length ? `
|
|
52
|
+
@available(iOS 16.4, *)
|
|
53
|
+
struct CraftConfiguredAppShortcuts: AppShortcutsProvider {
|
|
54
|
+
static var appShortcuts: [AppShortcut] {
|
|
55
|
+
${entries}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
` : ""}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
7
61
|
// src/source-audit.ts
|
|
8
62
|
import { readdirSync } from "fs";
|
|
9
63
|
import { join, relative, sep } from "path";
|
|
@@ -1073,11 +1127,13 @@ function writeShell(output, config) {
|
|
|
1073
1127
|
if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
|
|
1074
1128
|
throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
|
|
1075
1129
|
const name = productName(config.appName);
|
|
1130
|
+
const shortcutSource = appShortcutsSwift(config.appShortcuts, config.urlSchemes);
|
|
1076
1131
|
mkdirSync2(join3(output, "Sources"), { recursive: true });
|
|
1077
1132
|
mkdirSync2(join3(output, "Shared"), { recursive: true });
|
|
1078
1133
|
const swiftTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
|
|
1079
1134
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, config.bundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
|
|
1080
1135
|
writeFileSync2(join3(output, "Sources", `${name}App.swift`), swiftSource);
|
|
1136
|
+
writeFileSync2(join3(output, "Sources", "CraftAppShortcuts.swift"), shortcutSource);
|
|
1081
1137
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeScreen.swift"), join3(output, "Sources", "CraftNativeScreen.swift"));
|
|
1082
1138
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join3(output, "Sources", "CraftNativeFlatList.swift"));
|
|
1083
1139
|
cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -205,6 +205,12 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
|
|
|
205
205
|
// push, a refresh, a route recording's location) draws nothing, and
|
|
206
206
|
// its events still need someone to hear them.
|
|
207
207
|
CraftHybrid.shared.configure(config)
|
|
208
|
+
if let shortcuts = config.appShortcuts {
|
|
209
|
+
application.shortcutItems = shortcuts.prefix(4).map { shortcut in
|
|
210
|
+
UIApplicationShortcutItem(type: shortcut.id, localizedTitle: shortcut.title, localizedSubtitle: shortcut.subtitle,
|
|
211
|
+
icon: UIApplicationShortcutIcon(systemImageName: shortcut.symbol), userInfo: ["url": shortcut.url as NSString])
|
|
212
|
+
}
|
|
213
|
+
}
|
|
208
214
|
if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
|
209
215
|
CraftEventManager.shared.handleShortcut(shortcut)
|
|
210
216
|
// Returning false tells UIKit the launch-time item was handled and
|
|
@@ -1399,7 +1405,17 @@ extension CraftConfig {
|
|
|
1399
1405
|
}
|
|
1400
1406
|
|
|
1401
1407
|
// MARK: - Configuration
|
|
1408
|
+
struct CraftConfiguredShortcut: Codable {
|
|
1409
|
+
var id: String
|
|
1410
|
+
var title: String
|
|
1411
|
+
var subtitle: String?
|
|
1412
|
+
var symbol: String
|
|
1413
|
+
var url: String
|
|
1414
|
+
var phrases: [String]
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1402
1417
|
struct CraftConfig: Codable {
|
|
1418
|
+
var appShortcuts: [CraftConfiguredShortcut]? = nil
|
|
1403
1419
|
var appName: String = "Craft App"
|
|
1404
1420
|
var bundleId: String = "com.craft.app"
|
|
1405
1421
|
/// Opt-in UIKit + JavaScriptCore screen; existing apps keep WKWebView.
|
|
@@ -1161,8 +1161,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1161
1161
|
private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
|
|
1162
1162
|
private var initialCameraKey: String?
|
|
1163
1163
|
private var pendingFitKey: String?
|
|
1164
|
+
private var pendingRegion: MKCoordinateRegion?
|
|
1164
1165
|
private(set) var markerHandlers: [String: String] = [:]
|
|
1165
1166
|
var onMarkerTap: ((String) -> Void)?
|
|
1167
|
+
var onMapPress: ((CLLocationCoordinate2D) -> Void)?
|
|
1168
|
+
var onRegionChangeComplete: ((MKCoordinateRegion, Double, Double) -> Void)?
|
|
1169
|
+
private var userChangingCamera = false
|
|
1170
|
+
private var authoredPitch: Double?
|
|
1171
|
+
private var authoredHeading: Double?
|
|
1172
|
+
private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
|
|
1173
|
+
private var markerBearings: [String: Double] = [:]
|
|
1174
|
+
private var tileOverlay: MKTileOverlay?
|
|
1175
|
+
private var tileTemplate: String?
|
|
1176
|
+
private lazy var tileAttribution: UILabel = {
|
|
1177
|
+
let label = UILabel()
|
|
1178
|
+
label.font = .systemFont(ofSize: 9)
|
|
1179
|
+
label.textColor = .darkGray
|
|
1180
|
+
label.backgroundColor = UIColor.white.withAlphaComponent(0.85)
|
|
1181
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
1182
|
+
label.numberOfLines = 2
|
|
1183
|
+
addSubview(label)
|
|
1184
|
+
NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
|
|
1185
|
+
return label
|
|
1186
|
+
}()
|
|
1166
1187
|
|
|
1167
1188
|
override init(frame: CGRect) {
|
|
1168
1189
|
super.init(frame: frame)
|
|
@@ -1170,6 +1191,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1170
1191
|
showsCompass = false
|
|
1171
1192
|
showsScale = false
|
|
1172
1193
|
isRotateEnabled = false
|
|
1194
|
+
let tap = UITapGestureRecognizer(target: self, action: #selector(pressedMap(_:)))
|
|
1195
|
+
tap.cancelsTouchesInView = false
|
|
1196
|
+
addGestureRecognizer(tap)
|
|
1173
1197
|
}
|
|
1174
1198
|
|
|
1175
1199
|
required init?(coder: NSCoder) {
|
|
@@ -1183,6 +1207,18 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1183
1207
|
isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
|
|
1184
1208
|
isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
|
|
1185
1209
|
mapType = Self.mapType(props["mapType"] as? String)
|
|
1210
|
+
configureTiles(props["tileOverlay"] as? [String: Any])
|
|
1211
|
+
showsCompass = props["showsCompass"] as? Bool ?? false
|
|
1212
|
+
showsScale = props["showsScale"] as? Bool ?? false
|
|
1213
|
+
authoredPitch = Self.numeric(props["cameraPitch"])
|
|
1214
|
+
authoredHeading = Self.numeric(props["cameraHeading"])
|
|
1215
|
+
let padding = props["edgePadding"] as? [String: Any]
|
|
1216
|
+
func inset(_ name: String) -> CGFloat {
|
|
1217
|
+
guard let value = Self.numeric(padding?[name]), value.isFinite else { return 24 }
|
|
1218
|
+
return CGFloat(min(10_000, max(0, value)))
|
|
1219
|
+
}
|
|
1220
|
+
edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
|
|
1221
|
+
var nextBearings: [String: Double] = [:]
|
|
1186
1222
|
// Showing the user dot must not trigger a permission prompt from a
|
|
1187
1223
|
// renderer pass. The app's existing location capability owns that
|
|
1188
1224
|
// prompt; a map only opts in after authorization is already granted.
|
|
@@ -1203,6 +1239,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1203
1239
|
if type == "mapmarker" || type == "marker" {
|
|
1204
1240
|
guard let point = points.first else { continue }
|
|
1205
1241
|
nextMarkers[id] = (point, childProps["title"] as? String)
|
|
1242
|
+
if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
|
|
1243
|
+
nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
|
|
1244
|
+
}
|
|
1206
1245
|
if let handler = nonEmptyHandler(childEvents["onPress"])
|
|
1207
1246
|
?? nonEmptyHandler(childEvents["onMarkerPress"]) {
|
|
1208
1247
|
nextHandlers[id] = handler
|
|
@@ -1216,12 +1255,14 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1216
1255
|
}
|
|
1217
1256
|
}
|
|
1218
1257
|
|
|
1258
|
+
markerBearings = nextBearings
|
|
1219
1259
|
let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
|
|
1220
1260
|
removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
|
|
1221
1261
|
for (id, value) in nextMarkers {
|
|
1222
1262
|
if let annotation = annotationsByID[id] {
|
|
1223
1263
|
annotation.coordinate = value.coordinate
|
|
1224
1264
|
annotation.title = value.title
|
|
1265
|
+
if let view = view(for: annotation) { configureMarkerView(view, id: id) }
|
|
1225
1266
|
} else {
|
|
1226
1267
|
let annotation = MKPointAnnotation()
|
|
1227
1268
|
annotation.coordinate = value.coordinate
|
|
@@ -1266,17 +1307,24 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1266
1307
|
// `region` is controlled: an authored change is allowed to move
|
|
1267
1308
|
// the camera, even when the map itself was already mounted.
|
|
1268
1309
|
pendingFitKey = nil
|
|
1269
|
-
|
|
1310
|
+
pendingRegion = bounds.width > 0 && bounds.height > 0 ? nil : region
|
|
1311
|
+
if abs(self.region.center.latitude - region.center.latitude) > 0.00000001
|
|
1312
|
+
|| abs(self.region.center.longitude - region.center.longitude) > 0.00000001
|
|
1313
|
+
|| abs(self.region.span.latitudeDelta - region.span.latitudeDelta) > 0.00000001
|
|
1314
|
+
|| abs(self.region.span.longitudeDelta - region.span.longitudeDelta) > 0.00000001 {
|
|
1315
|
+
setRegion(region, animated: false)
|
|
1316
|
+
}
|
|
1270
1317
|
initialCameraKey = nil
|
|
1271
1318
|
} else if let initial = Self.region(props["initialRegion"]) {
|
|
1272
1319
|
pendingFitKey = nil
|
|
1273
1320
|
let key = "region:\(Self.regionKey(initial))"
|
|
1274
1321
|
if initialCameraKey != key {
|
|
1322
|
+
pendingRegion = bounds.width > 0 && bounds.height > 0 ? nil : initial
|
|
1275
1323
|
setRegion(initial, animated: false)
|
|
1276
1324
|
initialCameraKey = key
|
|
1277
1325
|
}
|
|
1278
1326
|
} else if props["fitToBounds"] as? Bool == true {
|
|
1279
|
-
let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates }))"
|
|
1327
|
+
let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates })):\(edgePadding)"
|
|
1280
1328
|
if initialCameraKey != key {
|
|
1281
1329
|
// A native screen can reconcile before UIKit assigns the map
|
|
1282
1330
|
// its final safe-area-constrained bounds. Keep the fit
|
|
@@ -1292,10 +1340,96 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1292
1340
|
initialCameraKey = nil
|
|
1293
1341
|
pendingFitKey = nil
|
|
1294
1342
|
}
|
|
1343
|
+
applyCameraOrientation()
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
private func configureTiles(_ options: [String: Any]?) {
|
|
1347
|
+
let template = options?["urlTemplate"] as? String
|
|
1348
|
+
let credit = options?["attribution"] as? String ?? ""
|
|
1349
|
+
let valid = template.map { value in value.hasPrefix("https://") && ["{z}", "{x}", "{y}"].allSatisfy { value.contains($0) } } ?? false
|
|
1350
|
+
let next = valid && !credit.isEmpty ? template : nil
|
|
1351
|
+
if tileTemplate != next {
|
|
1352
|
+
if let overlay = tileOverlay { removeOverlay(overlay) }
|
|
1353
|
+
tileOverlay = nil
|
|
1354
|
+
tileTemplate = next
|
|
1355
|
+
if let next {
|
|
1356
|
+
let overlay = MKTileOverlay(urlTemplate: next)
|
|
1357
|
+
overlay.canReplaceMapContent = true
|
|
1358
|
+
tileOverlay = overlay
|
|
1359
|
+
insertOverlay(overlay, at: 0, level: .aboveRoads)
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
tileAttribution.text = next == nil ? nil : credit
|
|
1363
|
+
tileAttribution.isHidden = next == nil
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
private func applyCameraOrientation() {
|
|
1367
|
+
// Region-to-altitude conversion needs a measured viewport. Applying
|
|
1368
|
+
// pitch to the zero-altitude camera before layout clamps it to zero.
|
|
1369
|
+
guard bounds.width > 0, bounds.height > 0 else { return }
|
|
1370
|
+
let next = camera.copy() as! MKMapCamera
|
|
1371
|
+
var changed = false
|
|
1372
|
+
if let pitch = authoredPitch, pitch.isFinite {
|
|
1373
|
+
let value = CGFloat(min(60, max(0, pitch)))
|
|
1374
|
+
if abs(next.pitch - value) > 0.01 { next.pitch = value; changed = true }
|
|
1375
|
+
}
|
|
1376
|
+
if let heading = authoredHeading, heading.isFinite {
|
|
1377
|
+
let value = heading.truncatingRemainder(dividingBy: 360)
|
|
1378
|
+
if abs(next.heading - value) > 0.01 { next.heading = value; changed = true }
|
|
1379
|
+
}
|
|
1380
|
+
if changed { setCamera(next, animated: false) }
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
@objc private func pressedMap(_ gesture: UITapGestureRecognizer) {
|
|
1384
|
+
guard gesture.state == .ended, onMapPress != nil else { return }
|
|
1385
|
+
let point = gesture.location(in: self)
|
|
1386
|
+
var touched = hitTest(point, with: nil)
|
|
1387
|
+
while let view = touched, view !== self {
|
|
1388
|
+
if view is MKAnnotationView { return }
|
|
1389
|
+
touched = view.superview
|
|
1390
|
+
}
|
|
1391
|
+
onMapPress?(convert(point, toCoordinateFrom: self))
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
|
|
1395
|
+
func interacting(_ view: UIView) -> Bool {
|
|
1396
|
+
if view.gestureRecognizers?.contains(where: { [.began, .changed, .ended].contains($0.state) }) == true { return true }
|
|
1397
|
+
return view.subviews.contains(where: interacting)
|
|
1398
|
+
}
|
|
1399
|
+
userChangingCamera = userChangingCamera || interacting(self)
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
|
|
1403
|
+
for (id, annotation) in annotationsByID {
|
|
1404
|
+
if let view = view(for: annotation) { configureMarkerView(view, id: id) }
|
|
1405
|
+
}
|
|
1406
|
+
if userChangingCamera {
|
|
1407
|
+
userChangingCamera = false
|
|
1408
|
+
onRegionChangeComplete?(region, camera.heading, Double(camera.pitch))
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
private func configureMarkerView(_ view: MKAnnotationView, id: String) {
|
|
1413
|
+
if let bearing = markerBearings[id] {
|
|
1414
|
+
let configuration = UIImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
|
|
1415
|
+
.applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
|
|
1416
|
+
view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)
|
|
1417
|
+
view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
|
|
1418
|
+
} else {
|
|
1419
|
+
view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
|
|
1420
|
+
.withTintColor(.systemRed, renderingMode: .alwaysOriginal)
|
|
1421
|
+
view.transform = .identity
|
|
1422
|
+
}
|
|
1295
1423
|
}
|
|
1296
1424
|
|
|
1297
1425
|
override func layoutSubviews() {
|
|
1298
1426
|
super.layoutSubviews()
|
|
1427
|
+
if tileTemplate != nil { bringSubviewToFront(tileAttribution) }
|
|
1428
|
+
if let region = pendingRegion, bounds.width > 0, bounds.height > 0 {
|
|
1429
|
+
pendingRegion = nil
|
|
1430
|
+
setRegion(region, animated: false)
|
|
1431
|
+
applyCameraOrientation()
|
|
1432
|
+
}
|
|
1299
1433
|
guard let key = pendingFitKey else { return }
|
|
1300
1434
|
pendingFitKey = nil
|
|
1301
1435
|
guard fitToOverlays() else {
|
|
@@ -1303,6 +1437,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1303
1437
|
return
|
|
1304
1438
|
}
|
|
1305
1439
|
initialCameraKey = key
|
|
1440
|
+
applyCameraOrientation()
|
|
1306
1441
|
}
|
|
1307
1442
|
|
|
1308
1443
|
@discardableResult
|
|
@@ -1315,17 +1450,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1315
1450
|
rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
|
|
1316
1451
|
}
|
|
1317
1452
|
guard !rect.isNull else { return false }
|
|
1318
|
-
|
|
1453
|
+
var inset = edgePadding
|
|
1454
|
+
let horizontal = inset.left + inset.right
|
|
1455
|
+
let vertical = inset.top + inset.bottom
|
|
1456
|
+
let availableWidth = max(0, bounds.width - 40)
|
|
1457
|
+
let availableHeight = max(0, bounds.height - 40)
|
|
1458
|
+
if horizontal > availableWidth && horizontal > 0 {
|
|
1459
|
+
inset.left *= availableWidth / horizontal
|
|
1460
|
+
inset.right *= availableWidth / horizontal
|
|
1461
|
+
}
|
|
1462
|
+
if vertical > availableHeight && vertical > 0 {
|
|
1463
|
+
inset.top *= availableHeight / vertical
|
|
1464
|
+
inset.bottom *= availableHeight / vertical
|
|
1465
|
+
}
|
|
1319
1466
|
setVisibleMapRect(rect, edgePadding: inset, animated: false)
|
|
1320
1467
|
return true
|
|
1321
1468
|
}
|
|
1322
1469
|
|
|
1323
1470
|
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
|
|
1324
1471
|
guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
|
|
1325
|
-
let view =
|
|
1326
|
-
??
|
|
1472
|
+
let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
|
|
1473
|
+
?? MKAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
|
|
1327
1474
|
view.annotation = annotation
|
|
1328
|
-
view
|
|
1475
|
+
configureMarkerView(view, id: id)
|
|
1329
1476
|
view.accessibilityIdentifier = id
|
|
1330
1477
|
return view
|
|
1331
1478
|
}
|
|
@@ -1337,6 +1484,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1337
1484
|
}
|
|
1338
1485
|
|
|
1339
1486
|
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
|
|
1487
|
+
if let tiles = overlay as? MKTileOverlay { return MKTileOverlayRenderer(tileOverlay: tiles) }
|
|
1340
1488
|
let renderer = MKPolylineRenderer(overlay: overlay)
|
|
1341
1489
|
renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
|
|
1342
1490
|
renderer.lineWidth = overlayWidths[ObjectIdentifier(overlay)] ?? 3
|
|
@@ -6298,6 +6446,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6298
6446
|
case "Map":
|
|
6299
6447
|
let map = result as! CraftNativeMapView
|
|
6300
6448
|
let mapMarkerHandler = nonEmptyHandler(events["onMarkerPress"])
|
|
6449
|
+
map.onMapPress = { [weak self] coordinate in
|
|
6450
|
+
guard let self, let handler = self.nonEmptyHandler(events["onPress"]) else { return }
|
|
6451
|
+
self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["latitude": coordinate.latitude, "longitude": coordinate.longitude]])
|
|
6452
|
+
}
|
|
6453
|
+
map.onRegionChangeComplete = { [weak self] region, heading, pitch in
|
|
6454
|
+
guard let self, let handler = self.nonEmptyHandler(events["onRegionChangeComplete"]) else { return }
|
|
6455
|
+
self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [
|
|
6456
|
+
"center": ["latitude": region.center.latitude, "longitude": region.center.longitude],
|
|
6457
|
+
"span": ["latitudeDelta": region.span.latitudeDelta, "longitudeDelta": region.span.longitudeDelta],
|
|
6458
|
+
"heading": heading, "pitch": pitch,
|
|
6459
|
+
]])
|
|
6460
|
+
}
|
|
6301
6461
|
map.configure(props: props, children: children) { [weak self, weak map] markerID in
|
|
6302
6462
|
guard let self, let map,
|
|
6303
6463
|
let handler = map.markerHandlers[markerID] ?? mapMarkerHandler else { return }
|