@craft-native/ios 0.0.235 → 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/README.md +12 -0
- 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 +340 -6
package/README.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Build native iOS apps with web technologies using Craft.
|
|
4
4
|
|
|
5
|
+
## Native celebration
|
|
6
|
+
|
|
7
|
+
Any native `View` may opt into a finite card entrance, light sweep and confetti
|
|
8
|
+
burst with `:celebration="{ replay: replayCount, enabled: true }"`. The numeric
|
|
9
|
+
`replay` value identifies the request: unrelated renders keep the current run;
|
|
10
|
+
increment it to replay. `enabled: false` displays an existing card quietly.
|
|
11
|
+
The effect takes three seconds, uses native animation frames, never captures
|
|
12
|
+
touches, and keeps the view's authored transform. iOS Reduce Motion and Android's
|
|
13
|
+
disabled animations show the complete content without movement or confetti.
|
|
14
|
+
Leaving the screen, detaching the view or backgrounding the app cancels the
|
|
15
|
+
decoration. Resuming does not replay it. Available on both native renderers.
|
|
16
|
+
|
|
5
17
|
## Features
|
|
6
18
|
|
|
7
19
|
### Core
|
|
@@ -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
|
|
@@ -1834,7 +1982,172 @@ extension UIView {
|
|
|
1834
1982
|
}
|
|
1835
1983
|
}
|
|
1836
1984
|
|
|
1985
|
+
/// A finite, opt-in native celebration. Reconciliation with the same replay
|
|
1986
|
+
/// value never restarts it; Reduce Motion always leaves the complete card still.
|
|
1987
|
+
struct CraftNativeCelebrationSpec: Equatable {
|
|
1988
|
+
let replay: Double
|
|
1989
|
+
let enabled: Bool
|
|
1990
|
+
|
|
1991
|
+
init?(_ raw: Any?) {
|
|
1992
|
+
guard let value = raw as? [String: Any],
|
|
1993
|
+
let replay = value["replay"] as? NSNumber,
|
|
1994
|
+
CFGetTypeID(replay) != CFBooleanGetTypeID(),
|
|
1995
|
+
replay.doubleValue.isFinite, replay.doubleValue >= 0 else { return nil }
|
|
1996
|
+
self.replay = replay.doubleValue
|
|
1997
|
+
enabled = value["enabled"] as? Bool != false
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
final class CraftNativeCelebration {
|
|
2002
|
+
private weak var view: UIView?
|
|
2003
|
+
private var request: CraftNativeCelebrationSpec?
|
|
2004
|
+
private var pending = false
|
|
2005
|
+
private var overlay: CALayer?
|
|
2006
|
+
private var sheen: CALayer?
|
|
2007
|
+
private var cleanup: DispatchWorkItem?
|
|
2008
|
+
private var observers: [NSObjectProtocol] = []
|
|
2009
|
+
private(set) var starts = 0
|
|
2010
|
+
var isRunning: Bool { overlay != nil }
|
|
2011
|
+
|
|
2012
|
+
init(view: UIView) {
|
|
2013
|
+
self.view = view
|
|
2014
|
+
for name in [UIApplication.didEnterBackgroundNotification, UIAccessibility.reduceMotionStatusDidChangeNotification] {
|
|
2015
|
+
observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in self?.stop() })
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
deinit {
|
|
2020
|
+
cleanup?.cancel()
|
|
2021
|
+
overlay?.removeFromSuperlayer()
|
|
2022
|
+
sheen?.removeFromSuperlayer()
|
|
2023
|
+
observers.forEach(NotificationCenter.default.removeObserver)
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
func configure(_ raw: Any?) {
|
|
2027
|
+
let next = CraftNativeCelebrationSpec(raw)
|
|
2028
|
+
guard next != request else { return }
|
|
2029
|
+
stop()
|
|
2030
|
+
request = next
|
|
2031
|
+
pending = next?.enabled == true
|
|
2032
|
+
view?.setNeedsLayout()
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
func stop() {
|
|
2036
|
+
pending = false
|
|
2037
|
+
cleanup?.cancel()
|
|
2038
|
+
cleanup = nil
|
|
2039
|
+
view?.layer.removeAnimation(forKey: "craft.celebration.entrance")
|
|
2040
|
+
overlay?.removeFromSuperlayer()
|
|
2041
|
+
overlay = nil
|
|
2042
|
+
sheen?.removeFromSuperlayer()
|
|
2043
|
+
sheen = nil
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
func layout(reduceMotion: Bool = UIAccessibility.isReduceMotionEnabled) {
|
|
2047
|
+
guard pending, let view, let window = view.window,
|
|
2048
|
+
view.bounds.width > 0, view.bounds.height > 0, !view.isHidden else { return }
|
|
2049
|
+
var ancestor: UIView? = view.superview
|
|
2050
|
+
while let parent = ancestor {
|
|
2051
|
+
if parent.isHidden { return }
|
|
2052
|
+
ancestor = parent.superview
|
|
2053
|
+
}
|
|
2054
|
+
pending = false
|
|
2055
|
+
guard !reduceMotion else { return }
|
|
2056
|
+
starts += 1
|
|
2057
|
+
let frame = view.convert(view.bounds, to: window)
|
|
2058
|
+
let overlay = CALayer()
|
|
2059
|
+
overlay.name = "craft.celebration.confetti"
|
|
2060
|
+
overlay.frame = window.bounds
|
|
2061
|
+
window.layer.addSublayer(overlay)
|
|
2062
|
+
self.overlay = overlay
|
|
2063
|
+
|
|
2064
|
+
// Core Animation owns every frame. No script timers, per-frame bridge
|
|
2065
|
+
// messages, bitmap assets or touch-intercepting overlay views.
|
|
2066
|
+
let colors: [UIColor] = [.init(red: 0.23, green: 0.51, blue: 0.96, alpha: 1), .init(red: 0.6, green: 0.96, blue: 0.89, alpha: 1), .init(red: 0.9, green: 0.77, blue: 0.55, alpha: 1), .white]
|
|
2067
|
+
let now = CACurrentMediaTime()
|
|
2068
|
+
for index in 0..<52 {
|
|
2069
|
+
let side: CGFloat = index % 2 == 0 ? -1 : 1
|
|
2070
|
+
let spread = CGFloat((index * 37) % 101) / 100
|
|
2071
|
+
let start = CGPoint(x: frame.midX + side * frame.width * 0.42, y: frame.minY + 28)
|
|
2072
|
+
let end = CGPoint(x: frame.midX + side * (35 + spread * window.bounds.width * 0.7), y: window.bounds.height + 30)
|
|
2073
|
+
let piece = CALayer()
|
|
2074
|
+
piece.bounds = CGRect(x: 0, y: 0, width: index % 3 == 0 ? 4 : 7, height: 11)
|
|
2075
|
+
piece.cornerRadius = 1
|
|
2076
|
+
piece.backgroundColor = colors[index % colors.count].cgColor
|
|
2077
|
+
piece.position = end
|
|
2078
|
+
piece.opacity = 0
|
|
2079
|
+
overlay.addSublayer(piece)
|
|
2080
|
+
let path = CAKeyframeAnimation(keyPath: "position")
|
|
2081
|
+
path.values = [NSValue(cgPoint: start), NSValue(cgPoint: CGPoint(x: start.x + side * (45 + spread * 65), y: max(0, start.y - 70 - spread * 100))), NSValue(cgPoint: end)]
|
|
2082
|
+
path.keyTimes = [0, 0.28, 1]
|
|
2083
|
+
path.calculationMode = .cubic
|
|
2084
|
+
let tumble = CABasicAnimation(keyPath: "transform.rotation")
|
|
2085
|
+
tumble.fromValue = side * Double(index)
|
|
2086
|
+
tumble.toValue = side * (Double(index) + 7 + Double(spread) * 10)
|
|
2087
|
+
let fade = CAKeyframeAnimation(keyPath: "opacity")
|
|
2088
|
+
fade.values = [0, 1, 1, 0]
|
|
2089
|
+
fade.keyTimes = [0, 0.04, 0.7, 1]
|
|
2090
|
+
let group = CAAnimationGroup()
|
|
2091
|
+
group.animations = [path, tumble, fade]
|
|
2092
|
+
group.duration = 2.2 + Double(spread) * 0.45
|
|
2093
|
+
group.beginTime = now + Double(index % 7) * 0.018
|
|
2094
|
+
group.fillMode = .backwards
|
|
2095
|
+
piece.add(group, forKey: "craft.celebration.particle")
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
let entrance = CASpringAnimation(keyPath: "transform")
|
|
2099
|
+
var from = CATransform3DMakeTranslation(0, 34, 0)
|
|
2100
|
+
from = CATransform3DRotate(from, -0.045, 0, 0, 1)
|
|
2101
|
+
from = CATransform3DScale(from, 0.86, 0.86, 1)
|
|
2102
|
+
entrance.fromValue = NSValue(caTransform3D: CATransform3DConcat(view.layer.transform, from))
|
|
2103
|
+
entrance.toValue = NSValue(caTransform3D: view.layer.transform)
|
|
2104
|
+
entrance.damping = 19
|
|
2105
|
+
entrance.stiffness = 160
|
|
2106
|
+
entrance.mass = 1
|
|
2107
|
+
entrance.duration = 0.95
|
|
2108
|
+
view.layer.add(entrance, forKey: "craft.celebration.entrance")
|
|
2109
|
+
|
|
2110
|
+
let sheen = CAGradientLayer()
|
|
2111
|
+
sheen.name = "craft.celebration.sheen"
|
|
2112
|
+
sheen.frame = view.bounds
|
|
2113
|
+
sheen.colors = [UIColor.clear.cgColor, UIColor.white.withAlphaComponent(0.2).cgColor, UIColor.clear.cgColor]
|
|
2114
|
+
sheen.locations = [0.25, 0.5, 0.75]
|
|
2115
|
+
sheen.startPoint = CGPoint(x: 0, y: 0.3)
|
|
2116
|
+
sheen.endPoint = CGPoint(x: 1, y: 0.7)
|
|
2117
|
+
sheen.opacity = 0
|
|
2118
|
+
let mask = CAShapeLayer()
|
|
2119
|
+
mask.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: view.layer.cornerRadius).cgPath
|
|
2120
|
+
sheen.mask = mask
|
|
2121
|
+
view.layer.addSublayer(sheen)
|
|
2122
|
+
self.sheen = sheen
|
|
2123
|
+
let sweep = CABasicAnimation(keyPath: "transform.translation.x")
|
|
2124
|
+
sweep.fromValue = -view.bounds.width
|
|
2125
|
+
sweep.toValue = view.bounds.width
|
|
2126
|
+
let light = CAKeyframeAnimation(keyPath: "opacity")
|
|
2127
|
+
light.values = [0, 1, 1, 0]
|
|
2128
|
+
light.keyTimes = [0, 0.15, 0.8, 1]
|
|
2129
|
+
let shine = CAAnimationGroup()
|
|
2130
|
+
shine.animations = [sweep, light]
|
|
2131
|
+
shine.duration = 1.15
|
|
2132
|
+
shine.beginTime = now + 0.55
|
|
2133
|
+
sheen.add(shine, forKey: "craft.celebration.sweep")
|
|
2134
|
+
let cleanup = DispatchWorkItem { [weak self] in self?.stop() }
|
|
2135
|
+
self.cleanup = cleanup
|
|
2136
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: cleanup)
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
1837
2140
|
private class CraftNativeFlowView: UIStackView {
|
|
2141
|
+
var celebration: CraftNativeCelebration?
|
|
2142
|
+
func configureCelebration(_ raw: Any?) {
|
|
2143
|
+
if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
|
|
2144
|
+
celebration?.configure(raw)
|
|
2145
|
+
}
|
|
2146
|
+
override func didMoveToWindow() {
|
|
2147
|
+
super.didMoveToWindow()
|
|
2148
|
+
if window == nil { celebration?.stop() }
|
|
2149
|
+
else { setNeedsLayout() }
|
|
2150
|
+
}
|
|
1838
2151
|
var pressActiveOpacity: CGFloat?
|
|
1839
2152
|
var pressBaseOpacity: CGFloat = 1
|
|
1840
2153
|
var pressUnderlayColor: UIColor?
|
|
@@ -2051,6 +2364,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2051
2364
|
override func layoutSubviews() {
|
|
2052
2365
|
super.layoutSubviews()
|
|
2053
2366
|
craftApplyCornerRadius()
|
|
2367
|
+
celebration?.layout()
|
|
2054
2368
|
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
2055
2369
|
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
2056
2370
|
var moved = false
|
|
@@ -4134,6 +4448,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4134
4448
|
|
|
4135
4449
|
override func viewWillDisappear(_ animated: Bool) {
|
|
4136
4450
|
super.viewWillDisappear(animated)
|
|
4451
|
+
stopCelebrations(in: view)
|
|
4137
4452
|
// The accessory belongs to the visible native screen. Clear the
|
|
4138
4453
|
// shared shell state before a hybrid/web route appears, otherwise a
|
|
4139
4454
|
// destination from the outgoing screen remains tappable on top of it.
|
|
@@ -4188,6 +4503,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4188
4503
|
/// `SCREEN_FOCUS { focused: true }`, and going away is `focused: false`.
|
|
4189
4504
|
func screenVisibilityChanged(_ visible: Bool) {
|
|
4190
4505
|
guard !runtimeDisposed else { return }
|
|
4506
|
+
if !visible, isViewLoaded { stopCelebrations(in: view) }
|
|
4191
4507
|
// Hidden tab stacks receive no UIKit appearance callback. A cancelled
|
|
4192
4508
|
// preview also restores a screen that never lost focus, so restore its
|
|
4193
4509
|
// chrome even when no new SCREEN_FOCUS event is needed.
|
|
@@ -6130,6 +6446,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6130
6446
|
case "Map":
|
|
6131
6447
|
let map = result as! CraftNativeMapView
|
|
6132
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
|
+
}
|
|
6133
6461
|
map.configure(props: props, children: children) { [weak self, weak map] markerID in
|
|
6134
6462
|
guard let self, let map,
|
|
6135
6463
|
let handler = map.markerHandlers[markerID] ?? mapMarkerHandler else { return }
|
|
@@ -6217,9 +6545,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6217
6545
|
if disabled { result.isUserInteractionEnabled = false }
|
|
6218
6546
|
}
|
|
6219
6547
|
applyAccessibility(props, type: type, to: result)
|
|
6548
|
+
(result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
|
|
6220
6549
|
return current
|
|
6221
6550
|
}
|
|
6222
6551
|
|
|
6552
|
+
private func stopCelebrations(in root: UIView) {
|
|
6553
|
+
(root as? CraftNativeFlowView)?.celebration?.stop()
|
|
6554
|
+
root.subviews.forEach { stopCelebrations(in: $0) }
|
|
6555
|
+
}
|
|
6556
|
+
|
|
6223
6557
|
private func makeView(_ type: String, props: [String: Any]) -> UIView {
|
|
6224
6558
|
switch type {
|
|
6225
6559
|
case "Text":
|