@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.236",
3
+ "version": "0.0.237",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- setRegion(region, animated: false)
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
- let inset = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
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 = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? MKMarkerAnnotationView)
1326
- ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1472
+ let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
1473
+ ?? MKAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1327
1474
  view.annotation = annotation
1328
- view.markerTintColor = .systemRed
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 }