@craft-native/ios 0.0.293 → 0.0.299
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 +43 -1
- package/dist/cli.js +30 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +31 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +153 -3
- package/templates/CraftHybrid.swift +1 -1
- package/templates/CraftNativeActions.swift +1 -1
- package/templates/CraftNativeScreen.swift +294 -23
- package/templates/CraftWatchApp.swift.template +4 -0
package/README.md
CHANGED
|
@@ -157,7 +157,7 @@ and linear gradients are applied by UIKit with the same style keys as Android;
|
|
|
157
157
|
`elevation` remains Android-specific. Gradient layers follow the native view's
|
|
158
158
|
bounds and corners through layout changes.
|
|
159
159
|
|
|
160
|
-
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
|
|
160
|
+
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `SegmentedControl`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
|
|
161
161
|
|
|
162
162
|
`FlatList` rows are authored as keyed `:for` child rows in native STX; the
|
|
163
163
|
maintained protocol does not serialize `data`/`renderItem` callbacks.
|
|
@@ -391,6 +391,29 @@ Appearance and navigation:
|
|
|
391
391
|
- `swipeNavigation`: an edge swipe goes back and forward through the page's
|
|
392
392
|
history, pushState entries included.
|
|
393
393
|
|
|
394
|
+
Native tab-bar selection styling is opt-in and lives in the generated config,
|
|
395
|
+
so the generated Swift never needs to be patched:
|
|
396
|
+
|
|
397
|
+
```json
|
|
398
|
+
{
|
|
399
|
+
"tabBar": {
|
|
400
|
+
"selection": { "cornerRadius": 8, "color": "#123456" }
|
|
401
|
+
},
|
|
402
|
+
"postGenerate": {
|
|
403
|
+
"command": "bun",
|
|
404
|
+
"args": ["run", "scripts/customize-native.ts"]
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
`cornerRadius` is measured in points and `color` is `#RRGGBB`; omit either to
|
|
410
|
+
keep its host default. `postGenerate` runs after `craft ios build` has written
|
|
411
|
+
the Xcode project, with the generated project as its working directory. Craft
|
|
412
|
+
does not invoke a shell: `command` and `args` are passed directly. The hook
|
|
413
|
+
receives `CRAFT_PROJECT_ROOT`, `CRAFT_PLATFORM=ios`, and
|
|
414
|
+
`CRAFT_GENERATE_PHASE=build`. A missing executable, non-zero exit, or invalid
|
|
415
|
+
configuration fails the build with the hook's stderr in the diagnostic.
|
|
416
|
+
|
|
394
417
|
Debug builds let Safari's Develop menu inspect the webview, and relay the
|
|
395
418
|
page's console, uncaught errors, rejected promises and failed script loads to
|
|
396
419
|
the device log as `[craft page] …`, so a page that only breaks inside the app
|
|
@@ -1282,3 +1305,22 @@ nativeFonts: {
|
|
|
1282
1305
|
|
|
1283
1306
|
The iOS project registers the files through `UIAppFonts`. Android loads bundled
|
|
1284
1307
|
typefaces from assets. Both work offline; a missing file fails the build.
|
|
1308
|
+
|
|
1309
|
+
### Platform selection, clear fields and sheets
|
|
1310
|
+
|
|
1311
|
+
`SegmentedControl` takes `items: [{value, label, disabled?}]` and a controlled
|
|
1312
|
+
`value`, and emits `onValueChange` with `{value, index}`. iOS uses UIKit's
|
|
1313
|
+
`UISegmentedControl`; Android uses a horizontal native `RadioGroup` with
|
|
1314
|
+
selected segments. Reconciliation does not emit a user selection.
|
|
1315
|
+
|
|
1316
|
+
`TextInput.clearButtonMode` is `never`, `while-editing`, `unless-editing` or
|
|
1317
|
+
`always`. The native clear affordance emits a single text edit carrying
|
|
1318
|
+
`{text: '', clear: true}`; Android also exposes a TalkBack clear action.
|
|
1319
|
+
|
|
1320
|
+
`Modal` accepts `presentationStyle: 'pageSheet' | 'formSheet'` with
|
|
1321
|
+
`sheetDetents: ['medium', 'large']`, `selectedDetent` and `grabberVisible`.
|
|
1322
|
+
iOS presents a real `UISheetPresentationController`, including system pan
|
|
1323
|
+
and detents; Android sizes its native bottom panel to the selected detent
|
|
1324
|
+
without an extra dependency. Omitting presentationStyle keeps the existing
|
|
1325
|
+
full-screen overlay. A native sheet's close request still belongs to its
|
|
1326
|
+
owner, which sets visible to false.
|
package/dist/cli.js
CHANGED
|
@@ -325,6 +325,30 @@ function installNativeBundle(source, output) {
|
|
|
325
325
|
function hasInstalledNativeBundle(output) {
|
|
326
326
|
return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
|
|
327
327
|
}
|
|
328
|
+
function runPostGenerateHook(hook, output, phase = "build") {
|
|
329
|
+
if (!hook)
|
|
330
|
+
return;
|
|
331
|
+
if (typeof hook.command !== "string" || !hook.command.trim())
|
|
332
|
+
throw new Error("iOS postGenerate.command must be a non-empty executable");
|
|
333
|
+
if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
|
|
334
|
+
throw new Error("iOS postGenerate.args must be an array of strings");
|
|
335
|
+
const command = [hook.command, ...hook.args ?? []];
|
|
336
|
+
let result;
|
|
337
|
+
try {
|
|
338
|
+
result = Bun.spawnSync(command, {
|
|
339
|
+
cwd: output,
|
|
340
|
+
env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "ios", CRAFT_GENERATE_PHASE: phase },
|
|
341
|
+
stdout: "inherit",
|
|
342
|
+
stderr: "pipe"
|
|
343
|
+
});
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw new Error(`iOS postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
|
|
346
|
+
}
|
|
347
|
+
if (result.exitCode !== 0) {
|
|
348
|
+
const stderr = result.stderr?.toString().trim();
|
|
349
|
+
throw new Error(`iOS postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
328
352
|
var DEFAULT_CONFIG = {
|
|
329
353
|
renderer: "web",
|
|
330
354
|
version: "1.0.0",
|
|
@@ -1030,6 +1054,11 @@ async function init(options) {
|
|
|
1030
1054
|
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
1031
1055
|
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
1032
1056
|
}
|
|
1057
|
+
const selection = config.tabBar?.selection;
|
|
1058
|
+
if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
|
|
1059
|
+
throw new Error("iOS tabBar.selection.cornerRadius must be a finite non-negative number");
|
|
1060
|
+
if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
|
|
1061
|
+
throw new Error("iOS tabBar.selection.color must be a #RRGGBB color");
|
|
1033
1062
|
if (config.enableBackgroundLocation)
|
|
1034
1063
|
config.enableGeolocation = true;
|
|
1035
1064
|
const problems = hybridConfigProblems(config);
|
|
@@ -1225,6 +1254,7 @@ async function build(options) {
|
|
|
1225
1254
|
cause: error
|
|
1226
1255
|
});
|
|
1227
1256
|
}
|
|
1257
|
+
runPostGenerateHook(config.postGenerate, output);
|
|
1228
1258
|
console.log("");
|
|
1229
1259
|
}
|
|
1230
1260
|
async function open(options) {
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,19 @@ export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], outpu
|
|
|
9
9
|
/** This package's version, which the app names in its user agent (`Craft/<version>`). */
|
|
10
10
|
export declare function resolveCraftVersion(startDir?: string): string;
|
|
11
11
|
export type CraftAppearance = 'light' | 'dark' | 'system';
|
|
12
|
+
/** A declared command Craft runs after the native project has been generated. */
|
|
13
|
+
export interface CraftPostGenerateHook {
|
|
14
|
+
command: string;
|
|
15
|
+
args?: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface CraftTabBarConfig {
|
|
18
|
+
selection?: {
|
|
19
|
+
/** The selected tab indicator's corner radius in points; omitted keeps a capsule. */
|
|
20
|
+
cornerRadius?: number;
|
|
21
|
+
/** The selected tab indicator fill as #RRGGBB. */
|
|
22
|
+
color?: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
12
25
|
export interface CraftConfig {
|
|
13
26
|
appShortcuts?: CraftAppShortcut[];
|
|
14
27
|
/** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
|
|
@@ -29,6 +42,10 @@ export interface CraftConfig {
|
|
|
29
42
|
* and `dark` pin one. Unset keeps the older `darkMode` switch.
|
|
30
43
|
*/
|
|
31
44
|
appearance?: CraftAppearance;
|
|
45
|
+
/** Static native tab-bar styling applied before the page's own tabBar.set call. */
|
|
46
|
+
tabBar?: CraftTabBarConfig;
|
|
47
|
+
/** Runs from the generated project root after `craft ios build` writes it. */
|
|
48
|
+
postGenerate?: CraftPostGenerateHook;
|
|
32
49
|
backgroundColor?: string;
|
|
33
50
|
/** The launch and webview background while the phone is in Dark Mode. */
|
|
34
51
|
backgroundColorDark?: string;
|
|
@@ -409,6 +426,13 @@ export interface BuildOptions {
|
|
|
409
426
|
*/
|
|
410
427
|
runtimeDir?: string | null;
|
|
411
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* Run the app's declared generator hook without invoking a shell.
|
|
431
|
+
*
|
|
432
|
+
* A hook gets the generated project as its cwd and explicit metadata so a
|
|
433
|
+
* patch can target the right platform and phase without guessing from paths.
|
|
434
|
+
*/
|
|
435
|
+
export declare function runPostGenerateHook(hook: CraftPostGenerateHook | undefined, output: string, phase?: 'build'): void;
|
|
412
436
|
export interface OpenOptions {
|
|
413
437
|
output: string;
|
|
414
438
|
}
|
package/dist/index.js
CHANGED
|
@@ -512,6 +512,30 @@ function installNativeBundle(source, output) {
|
|
|
512
512
|
function hasInstalledNativeBundle(output) {
|
|
513
513
|
return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
|
|
514
514
|
}
|
|
515
|
+
function runPostGenerateHook(hook, output, phase = "build") {
|
|
516
|
+
if (!hook)
|
|
517
|
+
return;
|
|
518
|
+
if (typeof hook.command !== "string" || !hook.command.trim())
|
|
519
|
+
throw new Error("iOS postGenerate.command must be a non-empty executable");
|
|
520
|
+
if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
|
|
521
|
+
throw new Error("iOS postGenerate.args must be an array of strings");
|
|
522
|
+
const command = [hook.command, ...hook.args ?? []];
|
|
523
|
+
let result;
|
|
524
|
+
try {
|
|
525
|
+
result = Bun.spawnSync(command, {
|
|
526
|
+
cwd: output,
|
|
527
|
+
env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "ios", CRAFT_GENERATE_PHASE: phase },
|
|
528
|
+
stdout: "inherit",
|
|
529
|
+
stderr: "pipe"
|
|
530
|
+
});
|
|
531
|
+
} catch (error) {
|
|
532
|
+
throw new Error(`iOS postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
|
|
533
|
+
}
|
|
534
|
+
if (result.exitCode !== 0) {
|
|
535
|
+
const stderr = result.stderr?.toString().trim();
|
|
536
|
+
throw new Error(`iOS postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
515
539
|
var DEFAULT_CONFIG = {
|
|
516
540
|
renderer: "web",
|
|
517
541
|
version: "1.0.0",
|
|
@@ -1230,6 +1254,11 @@ async function init(options) {
|
|
|
1230
1254
|
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
1231
1255
|
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
1232
1256
|
}
|
|
1257
|
+
const selection = config.tabBar?.selection;
|
|
1258
|
+
if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
|
|
1259
|
+
throw new Error("iOS tabBar.selection.cornerRadius must be a finite non-negative number");
|
|
1260
|
+
if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
|
|
1261
|
+
throw new Error("iOS tabBar.selection.color must be a #RRGGBB color");
|
|
1233
1262
|
if (config.enableBackgroundLocation)
|
|
1234
1263
|
config.enableGeolocation = true;
|
|
1235
1264
|
const problems = hybridConfigProblems(config);
|
|
@@ -1425,6 +1454,7 @@ async function build(options) {
|
|
|
1425
1454
|
cause: error
|
|
1426
1455
|
});
|
|
1427
1456
|
}
|
|
1457
|
+
runPostGenerateHook(config.postGenerate, output);
|
|
1428
1458
|
console.log("");
|
|
1429
1459
|
}
|
|
1430
1460
|
async function open(options) {
|
|
@@ -1590,6 +1620,7 @@ export {
|
|
|
1590
1620
|
resolveRuntimeDir,
|
|
1591
1621
|
restoreShell,
|
|
1592
1622
|
run,
|
|
1623
|
+
runPostGenerateHook,
|
|
1593
1624
|
serializeSimulatorSteps,
|
|
1594
1625
|
showSimulator,
|
|
1595
1626
|
syncNativeFonts,
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -102,6 +102,96 @@ enum CraftPageAppearance {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/// The app-wide UIKit appearance override. SwiftUI's `preferredColorScheme`
|
|
106
|
+
/// only controls the SwiftUI environment; native screen controllers need the
|
|
107
|
+
/// UIKit override as well so their trait collection, controls and navigation
|
|
108
|
+
/// chrome resolve the same way. A runtime choice is kept here so screens
|
|
109
|
+
/// created after the choice inherit it instead of reverting to the bundle
|
|
110
|
+
/// config.
|
|
111
|
+
enum CraftInterfaceStyleController {
|
|
112
|
+
/// The app's bundled choice is the default for newly-created screens.
|
|
113
|
+
/// A runtime choice is separate: it is the only value that should
|
|
114
|
+
/// override a screen's own generated config.
|
|
115
|
+
private static var configured: UIUserInterfaceStyle = .unspecified
|
|
116
|
+
private static var runtimeSelection: UIUserInterfaceStyle?
|
|
117
|
+
|
|
118
|
+
static func configuredStyle(for config: CraftConfig) -> UIUserInterfaceStyle {
|
|
119
|
+
switch config.appearance {
|
|
120
|
+
case "system": return .unspecified
|
|
121
|
+
case "light": return .light
|
|
122
|
+
case "dark": return .dark
|
|
123
|
+
default: return config.darkMode ? .dark : .light
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static func configure(_ config: CraftConfig) {
|
|
128
|
+
configured = configuredStyle(for: config)
|
|
129
|
+
runtimeSelection = nil
|
|
130
|
+
applyToWindows()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static func style(for config: CraftConfig) -> UIUserInterfaceStyle {
|
|
134
|
+
runtimeSelection ?? configuredStyle(for: config)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
static func colorScheme(for config: CraftConfig) -> ColorScheme? {
|
|
138
|
+
switch style(for: config) {
|
|
139
|
+
case .light: return .light
|
|
140
|
+
case .dark: return .dark
|
|
141
|
+
default: return nil
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// Returns false rather than silently accepting a typo from JavaScript.
|
|
146
|
+
@discardableResult
|
|
147
|
+
static func set(_ value: String) -> Bool {
|
|
148
|
+
let style: UIUserInterfaceStyle
|
|
149
|
+
switch value {
|
|
150
|
+
case "system": style = .unspecified
|
|
151
|
+
case "light": style = .light
|
|
152
|
+
case "dark": style = .dark
|
|
153
|
+
default: return false
|
|
154
|
+
}
|
|
155
|
+
runtimeSelection = style
|
|
156
|
+
applyToWindows()
|
|
157
|
+
NotificationCenter.default.post(name: .craftColorSchemeChanged, object: nil)
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static func currentName() -> String {
|
|
162
|
+
let requested = runtimeSelection ?? UIApplication.shared.connectedScenes
|
|
163
|
+
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
|
|
164
|
+
.first ?? .unspecified
|
|
165
|
+
let style = requested == .unspecified
|
|
166
|
+
? (UIApplication.shared.connectedScenes
|
|
167
|
+
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
|
|
168
|
+
.first { $0 != .unspecified } ?? UITraitCollection.current.userInterfaceStyle)
|
|
169
|
+
: requested
|
|
170
|
+
return style == .dark ? "dark" : "light"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
static func apply(to controller: UIViewController, config: CraftConfig) {
|
|
174
|
+
controller.overrideUserInterfaceStyle = style(for: config)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private static func applyToWindows() {
|
|
178
|
+
let style = runtimeSelection ?? configured
|
|
179
|
+
for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
|
|
180
|
+
for window in scene.windows {
|
|
181
|
+
window.overrideUserInterfaceStyle = style
|
|
182
|
+
apply(style, to: window.rootViewController)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private static func apply(_ style: UIUserInterfaceStyle, to controller: UIViewController?) {
|
|
188
|
+
guard let controller else { return }
|
|
189
|
+
controller.overrideUserInterfaceStyle = style
|
|
190
|
+
controller.children.forEach { apply(style, to: $0) }
|
|
191
|
+
if let presented = controller.presentedViewController { apply(style, to: presented) }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
105
195
|
/// Tells the shell when SwiftUI's colour scheme changes: the one appearance
|
|
106
196
|
/// setting with no notification of its own.
|
|
107
197
|
struct CraftColorSchemeObserver: View {
|
|
@@ -195,6 +285,7 @@ struct CraftRecoveryBudget {
|
|
|
195
285
|
final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
196
286
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
|
197
287
|
let config = CraftConfig.bundled()
|
|
288
|
+
CraftInterfaceStyleController.configure(config)
|
|
198
289
|
// First, so a crash anywhere in the rest of launch is reported too.
|
|
199
290
|
CraftCrashReporter.shared.start(config)
|
|
200
291
|
UNUserNotificationCenter.current().delegate = self
|
|
@@ -606,7 +697,7 @@ struct CraftApp: App {
|
|
|
606
697
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
607
698
|
}
|
|
608
699
|
}
|
|
609
|
-
.preferredColorScheme(appState.
|
|
700
|
+
.preferredColorScheme(appState.appearanceScheme)
|
|
610
701
|
.environmentObject(appState)
|
|
611
702
|
.onOpenURL { url in
|
|
612
703
|
// Handle deep links and universal links
|
|
@@ -689,6 +780,10 @@ final class CraftChrome: ObservableObject {
|
|
|
689
780
|
/// The selected tab highlight's corner radius. Nil preserves the default
|
|
690
781
|
/// capsule, while a finite non-negative value opts into a rounded rect.
|
|
691
782
|
private(set) var tabSelectionCornerRadius: CGFloat?
|
|
783
|
+
/// The configured static shape, retained when the page updates only its
|
|
784
|
+
/// tabs or selected item at runtime.
|
|
785
|
+
private var configuredTabSelectionCornerRadius: CGFloat?
|
|
786
|
+
private(set) var tabSelectionColor: UIColor?
|
|
692
787
|
|
|
693
788
|
weak var webView: WKWebView?
|
|
694
789
|
/// What the tab bar covers at the bottom of the screen, in points.
|
|
@@ -723,9 +818,13 @@ final class CraftChrome: ObservableObject {
|
|
|
723
818
|
/// The tab bar from `tabs` in craft.config.json, there before the page
|
|
724
819
|
/// loads; the page's own `tabBar` message replaces it later. `splash`
|
|
725
820
|
/// false when the first screen is native and draws itself.
|
|
726
|
-
func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
|
|
821
|
+
func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool, appearance: CraftConfig.TabBar? = nil) {
|
|
727
822
|
splashVisible = splash
|
|
728
823
|
tabSwipeEnabled = true
|
|
824
|
+
configuredTabSelectionCornerRadius = appearance?.selection?.cornerRadius
|
|
825
|
+
.flatMap { $0.isFinite && $0 >= 0 ? CGFloat($0) : nil }
|
|
826
|
+
tabSelectionCornerRadius = configuredTabSelectionCornerRadius
|
|
827
|
+
tabSelectionColor = appearance?.selection?.color.flatMap { UIColor(hex: $0) }
|
|
729
828
|
guard !configured.isEmpty else { return }
|
|
730
829
|
tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil, selectedSymbol: Tab.normalizedSelectedSymbol($0.selectedSymbol), selectedIconTint: $0.selectedIconTint, selectedIconPulse: $0.selectedIconPulse) }
|
|
731
830
|
self.selected = selected ?? configured.first?.id
|
|
@@ -856,6 +955,7 @@ final class CraftChrome: ObservableObject {
|
|
|
856
955
|
tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
|
|
857
956
|
}
|
|
858
957
|
if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled }
|
|
958
|
+
if !body.keys.contains("selection") { tabSelectionCornerRadius = configuredTabSelectionCornerRadius }
|
|
859
959
|
withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
|
|
860
960
|
publishLayout()
|
|
861
961
|
case "selectTab":
|
|
@@ -1312,6 +1412,7 @@ private struct CraftTabSelectionShape: Shape {
|
|
|
1312
1412
|
}
|
|
1313
1413
|
|
|
1314
1414
|
private struct CraftTabSelectionIndicator: View {
|
|
1415
|
+
@ObservedObject private var chrome = CraftChrome.shared
|
|
1315
1416
|
@ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
|
|
1316
1417
|
let selected: Int?
|
|
1317
1418
|
let count: Int
|
|
@@ -1323,7 +1424,7 @@ private struct CraftTabSelectionIndicator: View {
|
|
|
1323
1424
|
let width = geometry.size.width / CGFloat(max(1, count))
|
|
1324
1425
|
CraftTabSelectionShape(cornerRadius: cornerRadius)
|
|
1325
1426
|
.frame(width: width, height: geometry.size.height)
|
|
1326
|
-
.foregroundStyle(Color.primary.opacity(0.09))
|
|
1427
|
+
.foregroundStyle(chrome.tabSelectionColor.map { Color($0) } ?? Color.primary.opacity(0.09))
|
|
1327
1428
|
.offset(x: position * width)
|
|
1328
1429
|
}
|
|
1329
1430
|
}
|
|
@@ -1557,6 +1658,8 @@ class DeepLinkManager {
|
|
|
1557
1658
|
// MARK: - App State
|
|
1558
1659
|
class AppState: ObservableObject {
|
|
1559
1660
|
@Published var config: CraftConfig
|
|
1661
|
+
@Published private(set) var appearanceScheme: ColorScheme?
|
|
1662
|
+
private var appearanceObserver: NSObjectProtocol?
|
|
1560
1663
|
|
|
1561
1664
|
init() {
|
|
1562
1665
|
if let configURL = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
|
|
@@ -1565,11 +1668,26 @@ class AppState: ObservableObject {
|
|
|
1565
1668
|
} else {
|
|
1566
1669
|
self.config = CraftConfig()
|
|
1567
1670
|
}
|
|
1671
|
+
self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: config)
|
|
1672
|
+
appearanceObserver = NotificationCenter.default.addObserver(
|
|
1673
|
+
forName: .craftColorSchemeChanged,
|
|
1674
|
+
object: nil,
|
|
1675
|
+
queue: .main,
|
|
1676
|
+
) { [weak self] _ in
|
|
1677
|
+
guard let self else { return }
|
|
1678
|
+
self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: self.config)
|
|
1679
|
+
}
|
|
1568
1680
|
// Before the first frame: the hybrid start screen, and the tab bar
|
|
1569
1681
|
// from the config, are decided here, if the app delegate has not
|
|
1570
1682
|
// already (it does at launch; the first call decides).
|
|
1571
1683
|
CraftHybrid.shared.configure(config)
|
|
1572
1684
|
}
|
|
1685
|
+
|
|
1686
|
+
deinit {
|
|
1687
|
+
if let appearanceObserver {
|
|
1688
|
+
NotificationCenter.default.removeObserver(appearanceObserver)
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1573
1691
|
}
|
|
1574
1692
|
|
|
1575
1693
|
extension CraftConfig {
|
|
@@ -1706,6 +1824,9 @@ struct CraftConfig: Codable {
|
|
|
1706
1824
|
var nativeScreens: [String: String]? = nil
|
|
1707
1825
|
/// The tab bar to show before the page describes its own.
|
|
1708
1826
|
var tabs: [Tab]? = nil
|
|
1827
|
+
/// Static styling for the selected tab indicator; the page may still
|
|
1828
|
+
/// override its shape through `tabBar.set({ selection })`.
|
|
1829
|
+
var tabBar: TabBar? = nil
|
|
1709
1830
|
/// Hybrid: web-storage keys the page's own code writes, mirrored into the
|
|
1710
1831
|
/// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
|
|
1711
1832
|
var shareStorage: [String: String]? = nil
|
|
@@ -1733,6 +1854,15 @@ struct CraftConfig: Codable {
|
|
|
1733
1854
|
var selectedIconPulse: Bool? = nil
|
|
1734
1855
|
}
|
|
1735
1856
|
|
|
1857
|
+
struct TabBar: Codable {
|
|
1858
|
+
var selection: TabSelection? = nil
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
struct TabSelection: Codable {
|
|
1862
|
+
var cornerRadius: Double? = nil
|
|
1863
|
+
var color: String? = nil
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1736
1866
|
struct BackgroundRefresh: Codable {
|
|
1737
1867
|
var enabled: Bool = false
|
|
1738
1868
|
/// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
|
|
@@ -2535,6 +2665,18 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2535
2665
|
resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
|
|
2536
2666
|
case "statusBarSetStyle":
|
|
2537
2667
|
resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
|
|
2668
|
+
case "setAppearance":
|
|
2669
|
+
guard let appearance = body["appearance"] as? String else {
|
|
2670
|
+
rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
|
|
2671
|
+
return
|
|
2672
|
+
}
|
|
2673
|
+
guard CraftInterfaceStyleController.set(appearance) else {
|
|
2674
|
+
rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
|
|
2675
|
+
return
|
|
2676
|
+
}
|
|
2677
|
+
resolveCallback(callbackId, result: true)
|
|
2678
|
+
case "getAppearance":
|
|
2679
|
+
resolveCallback(callbackId, result: CraftInterfaceStyleController.currentName())
|
|
2538
2680
|
case "chromeSetUnderPageColor":
|
|
2539
2681
|
// What shows past the page's edges when it rubber-bands.
|
|
2540
2682
|
if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
|
|
@@ -5596,6 +5738,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5596
5738
|
getInfo: function() { return craft.getDeviceInfo(); },
|
|
5597
5739
|
getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
|
|
5598
5740
|
};
|
|
5741
|
+
craft.app = Object.assign(craft.app || {}, {
|
|
5742
|
+
setAppearance: function(appearance) {
|
|
5743
|
+
return craft._invoke('setAppearance', {appearance: appearance});
|
|
5744
|
+
},
|
|
5745
|
+
getAppearance: function() {
|
|
5746
|
+
return craft._invoke('getAppearance', {});
|
|
5747
|
+
}
|
|
5748
|
+
});
|
|
5599
5749
|
// Feedback, as on Android and the web, where there is no
|
|
5600
5750
|
// motor to fire: an app that left enableHaptics off gets
|
|
5601
5751
|
// nothing played and a settled promise, so `await
|
|
@@ -380,7 +380,7 @@ final class CraftHybrid: NSObject {
|
|
|
380
380
|
}
|
|
381
381
|
startsNative = isEnabled && routes.match(startPath) != nil
|
|
382
382
|
// The splash waits for a page; an all-native app's first screen draws itself.
|
|
383
|
-
CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative)
|
|
383
|
+
CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative, appearance: config.tabBar)
|
|
384
384
|
|
|
385
385
|
let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
|
|
386
386
|
appScriptRoute = script != nil && Self.hasBundle && (isEnabled || !hasWeb) ? script : nil
|
|
@@ -42,7 +42,7 @@ enum CraftNativeActions {
|
|
|
42
42
|
static func capabilities(config: CraftConfig) -> [String] {
|
|
43
43
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
44
44
|
// as it arrives) need nothing the app has to declare.
|
|
45
|
-
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation"]
|
|
45
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
|
|
46
46
|
values.append("storageCompareAndSet")
|
|
47
47
|
values.append("storageSetIfAbsent")
|
|
48
48
|
values.append("viewScroll")
|
|
@@ -2578,6 +2578,7 @@ extension UIView {
|
|
|
2578
2578
|
var responder: UIResponder? = next
|
|
2579
2579
|
while let current = responder {
|
|
2580
2580
|
if let screen = current as? CraftNativeScreenController { return screen }
|
|
2581
|
+
if let sheet = current as? CraftNativeSheetController { return sheet.screen }
|
|
2581
2582
|
responder = current.next
|
|
2582
2583
|
}
|
|
2583
2584
|
return nil
|
|
@@ -2983,7 +2984,10 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2983
2984
|
// The screen reports onLayout from its own layout pass, which a
|
|
2984
2985
|
// change deep in a scroll view's content never runs: a view that
|
|
2985
2986
|
// went from 240 to 180 wide kept saying 240.
|
|
2986
|
-
if moved {
|
|
2987
|
+
if moved {
|
|
2988
|
+
craftScreenController?.scheduleLayoutEvents()
|
|
2989
|
+
craftScreenController?.scheduleKeyboardInsets()
|
|
2990
|
+
}
|
|
2987
2991
|
}
|
|
2988
2992
|
for (child, frame) in result.frames {
|
|
2989
2993
|
var viewportFrame = frame
|
|
@@ -3973,13 +3977,141 @@ private final class CraftNativeImageBackgroundView: UIImageView {
|
|
|
3973
3977
|
}
|
|
3974
3978
|
}
|
|
3975
3979
|
|
|
3976
|
-
private final class
|
|
3980
|
+
private final class CraftNativeSegmentElement: UIAccessibilityElement {
|
|
3981
|
+
weak var control: CraftNativeSegmentedControl?
|
|
3982
|
+
let index: Int
|
|
3983
|
+
init(control: CraftNativeSegmentedControl, index: Int) {
|
|
3984
|
+
self.control = control
|
|
3985
|
+
self.index = index
|
|
3986
|
+
super.init(accessibilityContainer: control)
|
|
3987
|
+
}
|
|
3988
|
+
override func accessibilityActivate() -> Bool {
|
|
3989
|
+
guard let control, control.isEnabled, control.values.indices.contains(index), control.isEnabledForSegment(at: index) else { return false }
|
|
3990
|
+
if control.selectedSegmentIndex != index {
|
|
3991
|
+
control.selectedSegmentIndex = index
|
|
3992
|
+
control.sendActions(for: .valueChanged)
|
|
3993
|
+
}
|
|
3994
|
+
return true
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
private final class CraftNativeSegmentedControl: UISegmentedControl {
|
|
3999
|
+
var values: [String] = []
|
|
4000
|
+
private var segments: [CraftNativeSegmentElement] = []
|
|
4001
|
+
override var accessibilityElements: [Any]? {
|
|
4002
|
+
get { segments }
|
|
4003
|
+
set { /* The native segments, not the labelled group, own activation. */ }
|
|
4004
|
+
}
|
|
4005
|
+
func configureAccessibility(items: [[String: Any]]) {
|
|
4006
|
+
if segments.count != items.count { segments = items.indices.map { CraftNativeSegmentElement(control: self, index: $0) } }
|
|
4007
|
+
for (index, item) in items.enumerated() {
|
|
4008
|
+
segments[index].accessibilityLabel = item["spoken"] as? String ?? item["label"] as? String ?? values[index]
|
|
4009
|
+
}
|
|
4010
|
+
refreshAccessibilityState()
|
|
4011
|
+
setNeedsLayout()
|
|
4012
|
+
}
|
|
4013
|
+
func refreshAccessibilityState() {
|
|
4014
|
+
for (index, segment) in segments.enumerated() {
|
|
4015
|
+
segment.accessibilityTraits = [.button]
|
|
4016
|
+
if selectedSegmentIndex == index { segment.accessibilityTraits.insert(.selected) }
|
|
4017
|
+
if !isEnabled || !isEnabledForSegment(at: index) { segment.accessibilityTraits.insert(.notEnabled) }
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
override func layoutSubviews() {
|
|
4021
|
+
super.layoutSubviews()
|
|
4022
|
+
guard !segments.isEmpty else { return }
|
|
4023
|
+
let width = bounds.width / CGFloat(segments.count)
|
|
4024
|
+
for (index, segment) in segments.enumerated() {
|
|
4025
|
+
let visualIndex = effectiveUserInterfaceLayoutDirection == .rightToLeft ? segments.count - index - 1 : index
|
|
4026
|
+
segment.accessibilityFrameInContainerSpace = CGRect(x: CGFloat(visualIndex) * width, y: 0, width: width, height: bounds.height)
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
private final class CraftNativeSheetController: UIViewController {
|
|
4032
|
+
weak var screen: CraftNativeScreenController?
|
|
4033
|
+
weak var content: CraftNativeFlowView?
|
|
4034
|
+
override func viewDidLayoutSubviews() {
|
|
4035
|
+
super.viewDidLayoutSubviews()
|
|
4036
|
+
content?.frame = view.bounds.inset(by: view.safeAreaInsets)
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, UISheetPresentationControllerDelegate {
|
|
3977
4041
|
let contentStack = CraftNativeFlowView()
|
|
3978
4042
|
private let blocker = UIView()
|
|
3979
4043
|
private let backdropTap = UITapGestureRecognizer()
|
|
3980
4044
|
private var renderedVisible = false
|
|
3981
4045
|
private var animationGeneration = 0
|
|
3982
4046
|
var onRequestClose: ((String) -> Void)?
|
|
4047
|
+
weak var sheetPresenter: CraftNativeScreenController?
|
|
4048
|
+
var presentationStyle = "overFullScreen"
|
|
4049
|
+
var sheetDetents: [String] = ["medium", "large"]
|
|
4050
|
+
var selectedDetent: String?
|
|
4051
|
+
var grabberVisible = true
|
|
4052
|
+
private var sheetController: CraftNativeSheetController?
|
|
4053
|
+
var isPresentedAsSheet: Bool { sheetController != nil }
|
|
4054
|
+
|
|
4055
|
+
private func configureSheet(_ controller: CraftNativeSheetController) {
|
|
4056
|
+
controller.isModalInPresentation = onRequestClose == nil
|
|
4057
|
+
guard let sheet = controller.sheetPresentationController else { return }
|
|
4058
|
+
let choices = sheetDetents.filter { $0 == "medium" || $0 == "large" }
|
|
4059
|
+
let values = choices.isEmpty ? ["large"] : Array(Set(choices)).sorted { $0 == "medium" && $1 == "large" }
|
|
4060
|
+
sheet.detents = values.map { $0 == "medium" ? .medium() : .large() }
|
|
4061
|
+
sheet.prefersGrabberVisible = grabberVisible
|
|
4062
|
+
if let selectedDetent, values.contains(selectedDetent) {
|
|
4063
|
+
sheet.selectedDetentIdentifier = selectedDetent == "medium" ? .medium : .large
|
|
4064
|
+
}
|
|
4065
|
+
sheet.delegate = self
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
private func restoreSheetContent(_ controller: CraftNativeSheetController) {
|
|
4069
|
+
guard sheetController === controller else { return }
|
|
4070
|
+
contentStack.removeFromSuperview()
|
|
4071
|
+
addSubview(contentStack)
|
|
4072
|
+
sheetController = nil
|
|
4073
|
+
setNeedsLayout()
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
private func setSheetVisible(_ visible: Bool, animated: Bool) {
|
|
4077
|
+
renderedVisible = visible
|
|
4078
|
+
isHidden = true // UIKit owns the scrim, safe area, grabber and pan.
|
|
4079
|
+
guard visible else {
|
|
4080
|
+
guard let controller = sheetController else { return }
|
|
4081
|
+
controller.view.endEditing(true)
|
|
4082
|
+
controller.dismiss(animated: animated) { [weak self, weak controller] in
|
|
4083
|
+
guard let self, let controller else { return }
|
|
4084
|
+
self.restoreSheetContent(controller)
|
|
4085
|
+
if self.renderedVisible { self.setSheetVisible(true, animated: animated) }
|
|
4086
|
+
}
|
|
4087
|
+
return
|
|
4088
|
+
}
|
|
4089
|
+
if let controller = sheetController { configureSheet(controller); return }
|
|
4090
|
+
// A newly rendered modal is attached to its screen after this pass.
|
|
4091
|
+
DispatchQueue.main.async { [weak self] in
|
|
4092
|
+
guard let self, self.renderedVisible, self.sheetController == nil,
|
|
4093
|
+
let presenter = self.sheetPresenter, presenter.view.window != nil else { return }
|
|
4094
|
+
let controller = CraftNativeSheetController()
|
|
4095
|
+
controller.screen = presenter
|
|
4096
|
+
controller.modalPresentationStyle = self.presentationStyle == "formSheet" ? .formSheet : .pageSheet
|
|
4097
|
+
controller.view.backgroundColor = .systemBackground
|
|
4098
|
+
self.contentStack.removeFromSuperview()
|
|
4099
|
+
controller.view.addSubview(self.contentStack)
|
|
4100
|
+
controller.content = self.contentStack
|
|
4101
|
+
self.sheetController = controller
|
|
4102
|
+
self.configureSheet(controller)
|
|
4103
|
+
var top: UIViewController = presenter
|
|
4104
|
+
while let presented = top.presentedViewController { top = presented }
|
|
4105
|
+
top.present(controller, animated: animated)
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
4110
|
+
guard let controller = presentationController.presentedViewController as? CraftNativeSheetController else { return }
|
|
4111
|
+
restoreSheetContent(controller)
|
|
4112
|
+
renderedVisible = false
|
|
4113
|
+
onRequestClose?("swipe")
|
|
4114
|
+
}
|
|
3983
4115
|
|
|
3984
4116
|
var transparent = false {
|
|
3985
4117
|
didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
|
|
@@ -4017,6 +4149,10 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
4017
4149
|
/// press bookkeeping once turned that off for a view with no handler of
|
|
4018
4150
|
/// its own, and a shown modal let every tap through to the screen behind.
|
|
4019
4151
|
func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
|
|
4152
|
+
if blocksBackground && (presentationStyle == "pageSheet" || presentationStyle == "formSheet") {
|
|
4153
|
+
setSheetVisible(visible, animated: animationType != "none")
|
|
4154
|
+
return
|
|
4155
|
+
}
|
|
4020
4156
|
if visible { isUserInteractionEnabled = true }
|
|
4021
4157
|
if renderedVisible == visible && isHidden == !visible {
|
|
4022
4158
|
if visible, alpha != opacity { alpha = opacity }
|
|
@@ -4069,6 +4205,12 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
4069
4205
|
}
|
|
4070
4206
|
|
|
4071
4207
|
func resetForTeardown() {
|
|
4208
|
+
renderedVisible = false
|
|
4209
|
+
sheetPresenter = nil
|
|
4210
|
+
if let controller = sheetController {
|
|
4211
|
+
controller.dismiss(animated: false)
|
|
4212
|
+
restoreSheetContent(controller)
|
|
4213
|
+
}
|
|
4072
4214
|
craftScreenController?.forgetFocusedInput(in: self)
|
|
4073
4215
|
endEditing(true)
|
|
4074
4216
|
animationGeneration += 1
|
|
@@ -4130,6 +4272,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
4130
4272
|
override func layoutSubviews() {
|
|
4131
4273
|
super.layoutSubviews()
|
|
4132
4274
|
blocker.frame = bounds
|
|
4275
|
+
guard sheetController == nil else { return }
|
|
4133
4276
|
// The scrim covers the whole screen; what the modal holds stays
|
|
4134
4277
|
// clear of the bars and the home indicator it is drawn over.
|
|
4135
4278
|
contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
|
|
@@ -5112,6 +5255,11 @@ private final class CraftNativeTextView: UITextView {
|
|
|
5112
5255
|
isScrollEnabled = overflows
|
|
5113
5256
|
if overflows, isFirstResponder { scrollRangeToVisible(selectedRange) }
|
|
5114
5257
|
}
|
|
5258
|
+
// Showing all its text, it has nothing to scroll. An offset left from
|
|
5259
|
+
// UIKit keeping the caret in view while the view was a layout short
|
|
5260
|
+
// of its text moved every line down inside it, and the caret of a
|
|
5261
|
+
// growing comment sat below the part of the screen it was kept in.
|
|
5262
|
+
if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
|
|
5115
5263
|
let inset = textContainerInset
|
|
5116
5264
|
let padding = textContainer.lineFragmentPadding
|
|
5117
5265
|
placeholderLabel.frame = CGRect(
|
|
@@ -5492,6 +5640,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5492
5640
|
private var keyboardSafeAreaInset: CGFloat = 0
|
|
5493
5641
|
/// Where the keyboard is on the screen while it is up, in screen coordinates.
|
|
5494
5642
|
private var keyboardScreenFrame: CGRect?
|
|
5643
|
+
private var keyboardInsetsScheduled = false
|
|
5495
5644
|
/// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
|
|
5496
5645
|
/// app's native screens: told of the first frame, of a first frame that
|
|
5497
5646
|
/// could not be drawn, and of `craft.navigation.open(path)`.
|
|
@@ -5536,6 +5685,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5536
5685
|
|
|
5537
5686
|
override func viewDidLoad() {
|
|
5538
5687
|
super.viewDidLoad()
|
|
5688
|
+
CraftInterfaceStyleController.apply(to: self, config: config)
|
|
5539
5689
|
navigationItem.title = routeName ?? config.appName
|
|
5540
5690
|
view.backgroundColor = config.resolvedBackgroundColor
|
|
5541
5691
|
rootStack.axis = .vertical
|
|
@@ -5802,6 +5952,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5802
5952
|
positionModals()
|
|
5803
5953
|
emitLayoutEvents()
|
|
5804
5954
|
sendViewportIfChanged()
|
|
5955
|
+
scheduleKeyboardInsets()
|
|
5805
5956
|
}
|
|
5806
5957
|
|
|
5807
5958
|
/// The width the bundle last heard, so a layout pass at the same size says nothing.
|
|
@@ -5884,7 +6035,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5884
6035
|
@discardableResult
|
|
5885
6036
|
func handleBackForModal() -> Bool {
|
|
5886
6037
|
let modal = view.subviews.reversed().compactMap { $0 as? CraftNativeModalView }.first {
|
|
5887
|
-
!$0.isHidden && $0.alpha > 0.01 && ($0.blocksBackground || $0.dismissOnOutsideTap)
|
|
6038
|
+
($0.isPresentedAsSheet || (!$0.isHidden && $0.alpha > 0.01)) && ($0.blocksBackground || $0.dismissOnOutsideTap)
|
|
5888
6039
|
}
|
|
5889
6040
|
guard let modal else { return false }
|
|
5890
6041
|
let hasHandler = modal.onRequestClose != nil
|
|
@@ -6240,6 +6391,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6240
6391
|
let secureGet: @convention(block) (String) -> String? = { key in
|
|
6241
6392
|
config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
|
|
6242
6393
|
}
|
|
6394
|
+
let setAppearance: @convention(block) (String) -> Bool = { value in
|
|
6395
|
+
if Thread.isMainThread { return CraftInterfaceStyleController.set(value) }
|
|
6396
|
+
var accepted = false
|
|
6397
|
+
DispatchQueue.main.sync { accepted = CraftInterfaceStyleController.set(value) }
|
|
6398
|
+
return accepted
|
|
6399
|
+
}
|
|
6400
|
+
let getAppearance: @convention(block) () -> String = {
|
|
6401
|
+
if Thread.isMainThread { return CraftInterfaceStyleController.currentName() }
|
|
6402
|
+
var value = "light"
|
|
6403
|
+
DispatchQueue.main.sync { value = CraftInterfaceStyleController.currentName() }
|
|
6404
|
+
return value
|
|
6405
|
+
}
|
|
6243
6406
|
let setOptions: @convention(block) (String) -> Void = { [weak self] json in
|
|
6244
6407
|
guard let data = json.data(using: .utf8),
|
|
6245
6408
|
let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
@@ -6252,6 +6415,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6252
6415
|
jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
|
|
6253
6416
|
jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
|
|
6254
6417
|
jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
|
|
6418
|
+
jsContext.setObject(setAppearance, forKeyedSubscript: "craftNativeSetAppearance" as NSString)
|
|
6419
|
+
jsContext.setObject(getAppearance, forKeyedSubscript: "craftNativeGetAppearance" as NSString)
|
|
6255
6420
|
jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
|
|
6256
6421
|
// Older native bundles call the page-shaped `craft.dialog` API
|
|
6257
6422
|
// directly. Keep that API backed by the same capability protocol as
|
|
@@ -6494,6 +6659,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6494
6659
|
return value === undefined ? null : value;
|
|
6495
6660
|
}
|
|
6496
6661
|
});
|
|
6662
|
+
keep('app', {
|
|
6663
|
+
setAppearance: function(value) {
|
|
6664
|
+
value = String(value);
|
|
6665
|
+
if (value !== 'light' && value !== 'dark' && value !== 'system') {
|
|
6666
|
+
return Promise.reject(new TypeError('craft.app.setAppearance needs light, dark or system'));
|
|
6667
|
+
}
|
|
6668
|
+
if (!craftNativeSetAppearance(value)) {
|
|
6669
|
+
return Promise.reject(new Error('craft.app.setAppearance was rejected'));
|
|
6670
|
+
}
|
|
6671
|
+
return Promise.resolve();
|
|
6672
|
+
},
|
|
6673
|
+
getAppearance: function() { return Promise.resolve(craftNativeGetAppearance()); }
|
|
6674
|
+
});
|
|
6497
6675
|
// The signed-in account (CraftSession): the token under
|
|
6498
6676
|
// `sessionTokenKey`, and each change of it, which the host
|
|
6499
6677
|
// reports through `__craftSessionChanged` after the Keychain
|
|
@@ -7148,6 +7326,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7148
7326
|
}
|
|
7149
7327
|
}
|
|
7150
7328
|
|
|
7329
|
+
/// A layout moved a view while the keyboard is up: once that pass is
|
|
7330
|
+
/// over, each scroll view's keyboard inset is measured again where the
|
|
7331
|
+
/// scroll view now ends. Measured only when the keyboard moved, a form
|
|
7332
|
+
/// that grew down under the keyboard (a footer below it shrank, a
|
|
7333
|
+
/// banner above it went away) kept the inset of where it used to end,
|
|
7334
|
+
/// and its last lines stayed under the keyboard.
|
|
7335
|
+
fileprivate func scheduleKeyboardInsets() {
|
|
7336
|
+
guard keyboardScreenFrame != nil, !keyboardInsetsScheduled else { return }
|
|
7337
|
+
keyboardInsetsScheduled = true
|
|
7338
|
+
DispatchQueue.main.async { [weak self] in
|
|
7339
|
+
guard let self else { return }
|
|
7340
|
+
self.keyboardInsetsScheduled = false
|
|
7341
|
+
if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
|
|
7342
|
+
}
|
|
7343
|
+
}
|
|
7344
|
+
|
|
7151
7345
|
/// The focused field, scrolled into the part of its scroll view the
|
|
7152
7346
|
/// keyboard leaves, with a little room around it.
|
|
7153
7347
|
func scrollFocusedInputIntoView(animated: Bool) {
|
|
@@ -7159,6 +7353,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7159
7353
|
/// Internal for XCTest.
|
|
7160
7354
|
func scrollIntoView(_ input: UIView, animated: Bool) {
|
|
7161
7355
|
guard let scroll = enclosingVerticalScroll(of: input) else { return }
|
|
7356
|
+
// Measured against the keyboard where the scroll view is now.
|
|
7357
|
+
if keyboardScreenFrame != nil { insetScrollViewsForKeyboard() }
|
|
7162
7358
|
var rect = input.convert(input.bounds, to: scroll).insetBy(dx: 0, dy: -16)
|
|
7163
7359
|
// A field taller than the part the keyboard leaves (a long comment
|
|
7164
7360
|
// with no max height) shows its caret rather than its top: scrolled
|
|
@@ -7901,6 +8097,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7901
8097
|
textMaxLengths.removeValue(forKey: fieldId)
|
|
7902
8098
|
}
|
|
7903
8099
|
field.delegate = self
|
|
8100
|
+
switch props["clearButtonMode"] as? String {
|
|
8101
|
+
case "while-editing": field.clearButtonMode = .whileEditing
|
|
8102
|
+
case "unless-editing": field.clearButtonMode = .unlessEditing
|
|
8103
|
+
case "always": field.clearButtonMode = .always
|
|
8104
|
+
default: field.clearButtonMode = .never
|
|
8105
|
+
}
|
|
7904
8106
|
(field as? CraftNativeTextField)?.padding = paddingInsets(style)
|
|
7905
8107
|
field.textColor = color(style["color"]) ?? .label
|
|
7906
8108
|
field.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
@@ -7921,7 +8123,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7921
8123
|
if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
|
|
7922
8124
|
applyAutofill(to: field, props: props)
|
|
7923
8125
|
if let value = props["value"] as? String {
|
|
7924
|
-
updateField(field, value: value)
|
|
8126
|
+
updateField(field, value: value, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
|
|
7925
8127
|
} else if previous?.view !== field {
|
|
7926
8128
|
updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
|
|
7927
8129
|
}
|
|
@@ -7933,6 +8135,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7933
8135
|
if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
|
|
7934
8136
|
DispatchQueue.main.async { _ = field.becomeFirstResponder() }
|
|
7935
8137
|
}
|
|
8138
|
+
case "SegmentedControl":
|
|
8139
|
+
let control = result as! CraftNativeSegmentedControl
|
|
8140
|
+
let items = props["items"] as? [[String: Any]] ?? []
|
|
8141
|
+
let labels = items.map { $0["label"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
|
|
8142
|
+
// UIKit owns the segments; only rebuild when the choices change.
|
|
8143
|
+
// Setting a controlled value never dispatches valueChanged.
|
|
8144
|
+
if control.numberOfSegments != labels.count || labels.enumerated().contains(where: { control.titleForSegment(at: $0.offset) != $0.element }) {
|
|
8145
|
+
control.removeAllSegments()
|
|
8146
|
+
for (index, label) in labels.enumerated() { control.insertSegment(withTitle: label, at: index, animated: false) }
|
|
8147
|
+
}
|
|
8148
|
+
control.values = items.map { $0["value"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
|
|
8149
|
+
for (index, item) in items.enumerated() { control.setEnabled(item["disabled"] as? Bool != true, forSegmentAt: index) }
|
|
8150
|
+
let selected = props["value"] as? String ?? craftNativePickerValue(props["value"]) ?? props["selectedValue"] as? String ?? craftNativePickerValue(props["selectedValue"])
|
|
8151
|
+
control.selectedSegmentIndex = control.values.firstIndex(where: { $0 == selected }) ?? UISegmentedControl.noSegment
|
|
8152
|
+
control.isEnabled = props["disabled"] as? Bool != true
|
|
8153
|
+
control.selectedSegmentTintColor = color(props["tintColor"])
|
|
8154
|
+
let titleAttributes: [NSAttributedString.Key: Any]? = color(style["color"]).map { [.foregroundColor: $0] }
|
|
8155
|
+
control.setTitleTextAttributes(titleAttributes, for: .normal)
|
|
8156
|
+
control.setTitleTextAttributes(titleAttributes, for: .selected)
|
|
8157
|
+
control.configureAccessibility(items: items)
|
|
8158
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
|
|
7936
8159
|
case "Picker":
|
|
7937
8160
|
let picker = result as! UIPickerView
|
|
7938
8161
|
let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
|
|
@@ -7995,6 +8218,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7995
8218
|
case "Modal":
|
|
7996
8219
|
let modal = result as! CraftNativeModalView
|
|
7997
8220
|
let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
8221
|
+
modal.sheetPresenter = self
|
|
8222
|
+
modal.presentationStyle = props["presentationStyle"] as? String ?? "overFullScreen"
|
|
8223
|
+
modal.sheetDetents = props["sheetDetents"] as? [String] ?? ["medium", "large"]
|
|
8224
|
+
modal.selectedDetent = props["selectedDetent"] as? String
|
|
8225
|
+
modal.grabberVisible = props["grabberVisible"] as? Bool != false
|
|
8226
|
+
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
8227
|
+
{ [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
|
|
8228
|
+
}
|
|
7998
8229
|
modal.transparent = props["transparent"] as? Bool == true
|
|
7999
8230
|
modal.blocksBackground = props["nonModal"] as? Bool != true
|
|
8000
8231
|
modal.dismissOnOutsideTap = props["dismissOnOutsideTap"] as? Bool == true
|
|
@@ -8068,7 +8299,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8068
8299
|
toggle.onTintColor = color(track["true"])
|
|
8069
8300
|
toggle.tintColor = color(track["false"])
|
|
8070
8301
|
} else {
|
|
8071
|
-
toggle.onTintColor =
|
|
8302
|
+
toggle.onTintColor = color(style["color"])
|
|
8072
8303
|
toggle.tintColor = nil
|
|
8073
8304
|
}
|
|
8074
8305
|
toggle.thumbTintColor = color(props["thumbColor"])
|
|
@@ -8106,7 +8337,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8106
8337
|
}
|
|
8107
8338
|
indicator.indicatorScale = scale
|
|
8108
8339
|
applyTransform(style["transform"], to: indicator)
|
|
8109
|
-
indicator.color = color(props["color"]) ?? .tintColor
|
|
8340
|
+
indicator.color = color(props["color"]) ?? color(style["color"]) ?? .tintColor
|
|
8110
8341
|
indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
|
|
8111
8342
|
if props["animating"] as? Bool == false {
|
|
8112
8343
|
indicator.stopAnimating()
|
|
@@ -8279,7 +8510,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8279
8510
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
8280
8511
|
}
|
|
8281
8512
|
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
8282
|
-
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
|
|
8513
|
+
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
|
|
8283
8514
|
let disabled = props["disabled"] as? Bool == true
|
|
8284
8515
|
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
8285
8516
|
updatePressFeedback(
|
|
@@ -8335,6 +8566,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8335
8566
|
field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
|
|
8336
8567
|
field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
|
|
8337
8568
|
return field
|
|
8569
|
+
case "SegmentedControl":
|
|
8570
|
+
let control = CraftNativeSegmentedControl()
|
|
8571
|
+
control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
|
|
8572
|
+
return control
|
|
8338
8573
|
case "Picker":
|
|
8339
8574
|
let picker = UIPickerView()
|
|
8340
8575
|
picker.dataSource = self
|
|
@@ -8401,6 +8636,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8401
8636
|
case "link": return "Link"
|
|
8402
8637
|
case "checkbox": return "Checkbox"
|
|
8403
8638
|
case "textinput", "input": return "TextInput"
|
|
8639
|
+
case "segmentedcontrol", "segmented-control": return "SegmentedControl"
|
|
8404
8640
|
case "picker", "select": return "Picker"
|
|
8405
8641
|
case "datepicker", "date-picker", "date": return "DatePicker"
|
|
8406
8642
|
case "modal": return "Modal"
|
|
@@ -8750,6 +8986,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8750
8986
|
submitHandlers.removeValue(forKey: id)
|
|
8751
8987
|
inputIdentities.removeValue(forKey: id)
|
|
8752
8988
|
unechoedText.removeValue(forKey: id)
|
|
8989
|
+
inputEventCounts.removeValue(forKey: id)
|
|
8753
8990
|
if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
|
|
8754
8991
|
longPressHandlers.removeValue(forKey: id)
|
|
8755
8992
|
textMaxLengths.removeValue(forKey: id)
|
|
@@ -8967,19 +9204,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8967
9204
|
}
|
|
8968
9205
|
}
|
|
8969
9206
|
|
|
8970
|
-
///
|
|
8971
|
-
///
|
|
8972
|
-
/// one is the script catching up, not a new value, and applying it would
|
|
8973
|
-
/// undo what was typed since.
|
|
9207
|
+
/// Legacy runtimes echo values without a revision. Keep their bounded
|
|
9208
|
+
/// history on either script thread; bridge delivery may still be delayed.
|
|
8974
9209
|
private var unechoedText: [ObjectIdentifier: [String]] = [:]
|
|
9210
|
+
private var inputEventCounts: [ObjectIdentifier: Int] = [:]
|
|
8975
9211
|
|
|
8976
|
-
|
|
8977
|
-
|
|
9212
|
+
@discardableResult
|
|
9213
|
+
private func noteSentText(_ text: String, for view: UIView) -> Int {
|
|
8978
9214
|
let id = ObjectIdentifier(view)
|
|
9215
|
+
let count = (inputEventCounts[id] ?? 0) + 1
|
|
9216
|
+
inputEventCounts[id] = count
|
|
8979
9217
|
var sent = unechoedText[id] ?? []
|
|
8980
9218
|
sent.append(text)
|
|
8981
9219
|
if sent.count > 32 { sent.removeFirst(sent.count - 32) }
|
|
8982
9220
|
unechoedText[id] = sent
|
|
9221
|
+
return count
|
|
8983
9222
|
}
|
|
8984
9223
|
|
|
8985
9224
|
/// Whether `value` is a stale echo of text the view has moved past: it
|
|
@@ -8997,9 +9236,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8997
9236
|
return later > 0
|
|
8998
9237
|
}
|
|
8999
9238
|
|
|
9000
|
-
private func
|
|
9239
|
+
private func acceptsText(_ value: String, for view: UIView, acknowledged: Int?) -> Bool {
|
|
9240
|
+
if let acknowledged, acknowledged >= 0 {
|
|
9241
|
+
guard acknowledged >= (inputEventCounts[ObjectIdentifier(view)] ?? 0) else { return false }
|
|
9242
|
+
unechoedText.removeValue(forKey: ObjectIdentifier(view))
|
|
9243
|
+
return true // A current explicit reset may equal an older edit.
|
|
9244
|
+
}
|
|
9245
|
+
return !isStaleEcho(value, for: view)
|
|
9246
|
+
}
|
|
9247
|
+
|
|
9248
|
+
private func updateField(_ field: UITextField, value: String?, acknowledged: Int? = nil) {
|
|
9001
9249
|
guard let value = value, field.markedTextRange == nil else { return }
|
|
9002
|
-
if
|
|
9250
|
+
if !acceptsText(value, for: field, acknowledged: acknowledged) || field.text == value { return }
|
|
9003
9251
|
let selection = field.selectedTextRange
|
|
9004
9252
|
let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
|
|
9005
9253
|
let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
|
|
@@ -9011,9 +9259,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9011
9259
|
}
|
|
9012
9260
|
}
|
|
9013
9261
|
|
|
9014
|
-
private func updateTextView(_ textView: UITextView, value: String?) {
|
|
9262
|
+
private func updateTextView(_ textView: UITextView, value: String?, acknowledged: Int? = nil) {
|
|
9015
9263
|
guard let value = value, textView.markedTextRange == nil else { return }
|
|
9016
|
-
if
|
|
9264
|
+
if !acceptsText(value, for: textView, acknowledged: acknowledged) || textView.text == value { return }
|
|
9017
9265
|
let selection = textView.selectedTextRange
|
|
9018
9266
|
let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
|
|
9019
9267
|
let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
|
|
@@ -9076,7 +9324,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9076
9324
|
craftTextView.placeholderColor = color(props["placeholderTextColor"])
|
|
9077
9325
|
craftTextView.placeholderFont = textView.font
|
|
9078
9326
|
}
|
|
9079
|
-
updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
|
|
9327
|
+
updateTextView(textView, value: (props["value"] as? String) ?? defaultValue, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
|
|
9080
9328
|
if textView.markedTextRange == nil {
|
|
9081
9329
|
let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
|
|
9082
9330
|
let styled = NSAttributedString(string: textView.text ?? "", attributes: attributes)
|
|
@@ -9780,7 +10028,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9780
10028
|
let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
|
|
9781
10029
|
// UIPickerView exposes adjustable component wheels. Making the
|
|
9782
10030
|
// picker itself a leaf hides those wheels and their selected values.
|
|
9783
|
-
let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
|
|
10031
|
+
let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
|
|
9784
10032
|
|| ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
|
|
9785
10033
|
|| controlledVideo
|
|
9786
10034
|
view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
|
|
@@ -9883,6 +10131,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9883
10131
|
/// Return on a `returnKeyType="next"` field the screen does not handle
|
|
9884
10132
|
/// itself (no `@submit`) goes to the next field down the screen, keeping
|
|
9885
10133
|
/// the keyboard up. Every other Return ends editing and fires `@submit`.
|
|
10134
|
+
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
|
10135
|
+
guard textField.isEnabled else { return false }
|
|
10136
|
+
textField.text = ""
|
|
10137
|
+
let id = ObjectIdentifier(textField)
|
|
10138
|
+
inputIdentities[id].map { inputDrafts[$0] = "" }
|
|
10139
|
+
let eventCount = noteSentText("", for: textField)
|
|
10140
|
+
if let handler = handlers[id] {
|
|
10141
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": "", "clear": true, "eventCount": eventCount]])
|
|
10142
|
+
}
|
|
10143
|
+
return false // The model receives exactly one edit, including lazy models.
|
|
10144
|
+
}
|
|
10145
|
+
|
|
9886
10146
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
9887
10147
|
guard textField.returnKeyType == .next, submitHandlers[ObjectIdentifier(textField)] == nil,
|
|
9888
10148
|
let next = inputAfter(textField) else { return true }
|
|
@@ -9929,8 +10189,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9929
10189
|
(textView as? CraftNativeTextView)?.refreshPlaceholder()
|
|
9930
10190
|
keepCaretVisible(in: textView)
|
|
9931
10191
|
guard let handler = handlers[id] else { return }
|
|
9932
|
-
noteSentText(textView.text ?? "", for: textView)
|
|
9933
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
10192
|
+
let eventCount = noteSentText(textView.text ?? "", for: textView)
|
|
10193
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? "", "eventCount": eventCount]])
|
|
9934
10194
|
}
|
|
9935
10195
|
|
|
9936
10196
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
@@ -9951,6 +10211,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9951
10211
|
textView.invalidateIntrinsicContentSize()
|
|
9952
10212
|
CraftNativeFlexLayout.invalidateAncestors(of: textView)
|
|
9953
10213
|
self.view.layoutIfNeeded()
|
|
10214
|
+
// That layout can move the scroll view: its keyboard inset is
|
|
10215
|
+
// measured where it now ends before the caret is.
|
|
10216
|
+
if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
|
|
9954
10217
|
guard let scroll = self.enclosingVerticalScroll(of: textView),
|
|
9955
10218
|
let rect = self.caretRect(of: textView, in: scroll) else { return }
|
|
9956
10219
|
let visible = scroll.bounds.inset(by: scroll.adjustedContentInset)
|
|
@@ -9983,14 +10246,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9983
10246
|
inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
|
|
9984
10247
|
let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
|
|
9985
10248
|
guard let handler = handlers[id] else { return }
|
|
9986
|
-
noteSentText(sender.text ?? "", for: sender)
|
|
9987
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
10249
|
+
let eventCount = noteSentText(sender.text ?? "", for: sender)
|
|
10250
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? "", "eventCount": eventCount]])
|
|
9988
10251
|
restoreFocus(focused)
|
|
9989
10252
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
|
|
9990
10253
|
self?.restoreFocus(focused)
|
|
9991
10254
|
}
|
|
9992
10255
|
}
|
|
9993
10256
|
|
|
10257
|
+
@objc private func segmentChanged(_ sender: CraftNativeSegmentedControl) {
|
|
10258
|
+
let index = sender.selectedSegmentIndex
|
|
10259
|
+
guard sender.isEnabled, sender.values.indices.contains(index), sender.isEnabledForSegment(at: index),
|
|
10260
|
+
let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
10261
|
+
sender.refreshAccessibilityState()
|
|
10262
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.values[index], "index": index]])
|
|
10263
|
+
}
|
|
10264
|
+
|
|
9994
10265
|
@objc private func switchChanged(_ sender: UISwitch) {
|
|
9995
10266
|
let id = ObjectIdentifier(sender)
|
|
9996
10267
|
switchUserValues[id] = sender.isOn
|
|
@@ -117,6 +117,10 @@ final class CraftWatchSession: NSObject, ObservableObject {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
private func apply(_ payload: [String: Any]) {
|
|
120
|
+
if let incomingVersion = payload["stateVersion"] as? Int {
|
|
121
|
+
guard incomingVersion >= stateVersion else { return }
|
|
122
|
+
stateVersion = incomingVersion
|
|
123
|
+
}
|
|
120
124
|
if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
|
|
121
125
|
plannedSession = plan
|
|
122
126
|
status = "Plan ready"
|