@craft-native/ios 0.0.160 → 0.0.162
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 +14 -1
- package/dist/cli.js +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +24 -0
- package/templates/CraftHealth.swift +112 -0
- package/templates/CraftNativeScreen.swift +105 -16
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ ordinary native children while preserving the shared flex layout contract.
|
|
|
120
120
|
Keyboard frame changes update the native controller's additional safe area, so
|
|
121
121
|
focused inputs and responsive `onLayout` measurements move with the keyboard.
|
|
122
122
|
|
|
123
|
-
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `Video`, `ScrollView`, and recycling `FlatList` nodes as UIKit controls. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `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 timestamps; `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. 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).
|
|
123
|
+
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `Video`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `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 timestamps; `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. 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).
|
|
124
124
|
|
|
125
125
|
```bash
|
|
126
126
|
craft ios init NativeDemo --renderer native --output ./ios-native
|
|
@@ -185,6 +185,7 @@ Edit `craft.config.json` in your iOS project:
|
|
|
185
185
|
"enableHealthKit": false,
|
|
186
186
|
"enableAppleMusic": false,
|
|
187
187
|
"enableHeartRate": false,
|
|
188
|
+
"enableBackgroundAudio": false,
|
|
188
189
|
"enableScreenCapture": true,
|
|
189
190
|
"iosVersion": "15.0",
|
|
190
191
|
"teamId": ""
|
|
@@ -195,6 +196,11 @@ The app name is shown as written (`"HQ.training"` is fine); the Xcode
|
|
|
195
196
|
project, target and Swift types use a PascalCase identifier made from it
|
|
196
197
|
(`HQTraining`). A key left out of the file keeps its default.
|
|
197
198
|
|
|
199
|
+
`enableBackgroundAudio` adds the `audio` background mode, so a soundscape or a
|
|
200
|
+
meditation's ambience (Web Audio, on the playback session) keeps playing with
|
|
201
|
+
the screen locked; a sleep timer then fades it out on the audio clock. Turn it
|
|
202
|
+
on only for an app that plays sound people listen to: App Review checks.
|
|
203
|
+
|
|
198
204
|
Purpose strings in your own words, by Info.plist key (each replaces Craft's
|
|
199
205
|
generic sentence; the keys for frameworks the shell links are always written,
|
|
200
206
|
since App Store Connect checks the binary):
|
|
@@ -399,6 +405,13 @@ const readings = await health.getHeartRateSamples({ startDate: Date.now() - 8640
|
|
|
399
405
|
const nights = await health.getSleepIntervals({ startDate: Date.now() - 86400000 })
|
|
400
406
|
// An app built before these reads existed has neither; check first.
|
|
401
407
|
if (health.supports('getHeartRateSamples')) { /* ... */ }
|
|
408
|
+
// Mindful minutes: ask for 'mindful' (with writing allowed), then save a
|
|
409
|
+
// meditation or breathing session. The id is kept as the sample's external
|
|
410
|
+
// UUID, so saving it again keeps the first sample ({ existing: true }).
|
|
411
|
+
await health.requestAuthorization(['mindful'], { write: true })
|
|
412
|
+
await health.saveMindfulSession({ id: 'session-42', startDate: started, endDate: Date.now() })
|
|
413
|
+
// Every app's mindful sessions, oldest first; ownApp marks the ones this app wrote.
|
|
414
|
+
const mindful = await health.getMindfulSessions({ startDate: Date.now() - 7 * 86400000 })
|
|
402
415
|
|
|
403
416
|
// Screen Capture
|
|
404
417
|
const screenshotBase64 = await window.craft.takeScreenshot();
|
package/dist/cli.js
CHANGED
|
@@ -361,6 +361,8 @@ function renderBackgroundModes(config) {
|
|
|
361
361
|
modes.add("remote-notification");
|
|
362
362
|
if (config.enableHeartRate)
|
|
363
363
|
modes.add("bluetooth-central");
|
|
364
|
+
if (config.enableBackgroundAudio)
|
|
365
|
+
modes.add("audio");
|
|
364
366
|
if (!modes.size)
|
|
365
367
|
return "";
|
|
366
368
|
return ` <key>UIBackgroundModes</key>
|
package/dist/index.d.ts
CHANGED
|
@@ -79,6 +79,15 @@ export interface CraftConfig {
|
|
|
79
79
|
* time the page scans or connects.
|
|
80
80
|
*/
|
|
81
81
|
enableHeartRate?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Keep playing sound with the screen locked or the app in the background:
|
|
84
|
+
* a sleep soundscape, a meditation timer's ambience. Adds the `audio`
|
|
85
|
+
* background mode. iOS keeps the app running only while it plays, and App
|
|
86
|
+
* Review expects audible content, so turn it on only for an app that plays
|
|
87
|
+
* some (Web Audio on native screens and in the web view, which use the
|
|
88
|
+
* playback session).
|
|
89
|
+
*/
|
|
90
|
+
enableBackgroundAudio?: boolean;
|
|
82
91
|
/**
|
|
83
92
|
* Purpose strings in the app's own words, by Info.plist key
|
|
84
93
|
* (`{ NSAppleMusicUsageDescription: 'Shows the song playing during a workout.' }`).
|
package/dist/index.js
CHANGED
|
@@ -490,6 +490,8 @@ function renderBackgroundModes(config) {
|
|
|
490
490
|
modes.add("remote-notification");
|
|
491
491
|
if (config.enableHeartRate)
|
|
492
492
|
modes.add("bluetooth-central");
|
|
493
|
+
if (config.enableBackgroundAudio)
|
|
494
|
+
modes.add("audio");
|
|
493
495
|
if (!modes.size)
|
|
494
496
|
return "";
|
|
495
497
|
return ` <key>UIBackgroundModes</key>
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -2548,6 +2548,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2548
2548
|
} else {
|
|
2549
2549
|
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2550
2550
|
}
|
|
2551
|
+
case "saveHealthMindfulSession":
|
|
2552
|
+
if config.enableHealthKit {
|
|
2553
|
+
CraftHealth.shared.saveMindfulSession(body: body) { [weak self] in self?.answer(callbackId, $0) }
|
|
2554
|
+
} else {
|
|
2555
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2556
|
+
}
|
|
2557
|
+
case "getHealthMindfulSessions":
|
|
2558
|
+
if config.enableHealthKit {
|
|
2559
|
+
CraftHealth.shared.getMindfulSessions(
|
|
2560
|
+
startDate: body["startDate"] as? Double,
|
|
2561
|
+
endDate: body["endDate"] as? Double,
|
|
2562
|
+
limit: body["limit"] as? Int
|
|
2563
|
+
) { [weak self] in self?.answer(callbackId, $0) }
|
|
2564
|
+
} else {
|
|
2565
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2566
|
+
}
|
|
2551
2567
|
|
|
2552
2568
|
// MARK: - Live Activities
|
|
2553
2569
|
case "startLiveActivity":
|
|
@@ -5056,6 +5072,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5056
5072
|
getSleepIntervals: function(options) {
|
|
5057
5073
|
options = options || {};
|
|
5058
5074
|
return craft._invoke('getHealthSleepIntervals', {startDate: options.startDate, endDate: options.endDate});
|
|
5075
|
+
},
|
|
5076
|
+
saveMindfulSession: function(session) {
|
|
5077
|
+
session = session || {};
|
|
5078
|
+
return craft._invoke('saveHealthMindfulSession', {id: session.id, startDate: session.startDate, endDate: session.endDate});
|
|
5079
|
+
},
|
|
5080
|
+
getMindfulSessions: function(options) {
|
|
5081
|
+
options = options || {};
|
|
5082
|
+
return craft._invoke('getHealthMindfulSessions', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
|
|
5059
5083
|
}
|
|
5060
5084
|
};
|
|
5061
5085
|
// The Music app's song and transport. Nothing is read until
|
|
@@ -43,6 +43,10 @@ final class CraftHealth {
|
|
|
43
43
|
getHeartRateSamples(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
|
|
44
44
|
case "getSleepIntervals":
|
|
45
45
|
getSleepIntervals(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, completion: completion)
|
|
46
|
+
case "saveMindfulSession":
|
|
47
|
+
saveMindfulSession(body: options(0), completion: completion)
|
|
48
|
+
case "getMindfulSessions":
|
|
49
|
+
getMindfulSessions(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
|
|
46
50
|
default:
|
|
47
51
|
completion(.failure(CraftHealthError(message: "Unsupported Health method", code: "UNKNOWN_ACTION")))
|
|
48
52
|
}
|
|
@@ -106,6 +110,14 @@ final class CraftHealth {
|
|
|
106
110
|
if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
|
|
107
111
|
readTypes.insert(sleepType)
|
|
108
112
|
}
|
|
113
|
+
case "mindful":
|
|
114
|
+
// Mindful minutes: a meditation or breathing session. Read
|
|
115
|
+
// to show them, and (unless read only) written by an app
|
|
116
|
+
// that times them.
|
|
117
|
+
if let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) {
|
|
118
|
+
readTypes.insert(mindfulType)
|
|
119
|
+
shareTypes.insert(mindfulType)
|
|
120
|
+
}
|
|
109
121
|
case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
|
|
110
122
|
// What a food log (MyFitnessPal, Lose It!, Cronometer...)
|
|
111
123
|
// writes to Apple Health: read only, never written back.
|
|
@@ -559,4 +571,104 @@ final class CraftHealth {
|
|
|
559
571
|
healthStore.execute(query)
|
|
560
572
|
}
|
|
561
573
|
|
|
574
|
+
/// Saves a mindful session (Mindful Minutes in the Health app): a
|
|
575
|
+
/// meditation or breathing session that ran from `startDate` to
|
|
576
|
+
/// `endDate`, epoch milliseconds. `id` is the app's own id for it, kept
|
|
577
|
+
/// as the sample's external UUID, so saving the same session twice (a
|
|
578
|
+
/// retry, a second device) keeps the first sample instead of adding
|
|
579
|
+
/// another: the answer then says `existing: true`.
|
|
580
|
+
func saveMindfulSession(body: [String: Any], completion: @escaping Completion) {
|
|
581
|
+
guard let healthStore = healthStore else {
|
|
582
|
+
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
|
|
586
|
+
completion(.failure(CraftHealthError(message: "Mindful sessions are unavailable")))
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
guard let id = body["id"] as? String, !id.isEmpty, id.count <= 128,
|
|
590
|
+
let startValue = body["startDate"] as? Double,
|
|
591
|
+
let endValue = body["endDate"] as? Double,
|
|
592
|
+
endValue > startValue,
|
|
593
|
+
endValue - startValue <= 24 * 3600 * 1000 else {
|
|
594
|
+
completion(.failure(CraftHealthError(message: "A valid id, startDate and endDate (at most a day apart) are required", code: "INVALID_ARGUMENT")))
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
let start = Date(timeIntervalSince1970: startValue / 1000)
|
|
599
|
+
let end = Date(timeIntervalSince1970: endValue / 1000)
|
|
600
|
+
// Only this app's own samples can be the same session.
|
|
601
|
+
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
|
602
|
+
HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, allowedValues: [id]),
|
|
603
|
+
HKQuery.predicateForObjects(from: HKSource.default()),
|
|
604
|
+
])
|
|
605
|
+
let lookup = HKSampleQuery(sampleType: mindfulType, predicate: predicate, limit: 1, sortDescriptors: nil) { _, samples, _ in
|
|
606
|
+
if let kept = samples?.first {
|
|
607
|
+
completion(.success(["id": kept.uuid.uuidString, "externalId": id, "existing": true]))
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
let sample = HKCategorySample(
|
|
611
|
+
type: mindfulType,
|
|
612
|
+
value: HKCategoryValue.notApplicable.rawValue,
|
|
613
|
+
start: start,
|
|
614
|
+
end: end,
|
|
615
|
+
metadata: [HKMetadataKeyExternalUUID: id]
|
|
616
|
+
)
|
|
617
|
+
healthStore.save(sample) { success, error in
|
|
618
|
+
if success {
|
|
619
|
+
completion(.success(["id": sample.uuid.uuidString, "externalId": id, "existing": false]))
|
|
620
|
+
} else {
|
|
621
|
+
completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Mindful session could not be saved")))
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
healthStore.execute(lookup)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/// Mindful sessions between two instants, oldest first, from every app
|
|
629
|
+
/// that writes them (the Mindfulness app, Calm, this one). Each says
|
|
630
|
+
/// whether this app wrote it (`ownApp`) and carries the external id it
|
|
631
|
+
/// was saved with, so a page can tell its own sessions from the rest.
|
|
632
|
+
func getMindfulSessions(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
|
|
633
|
+
guard let healthStore = healthStore else {
|
|
634
|
+
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
|
|
638
|
+
completion(.failure(CraftHealthError(message: "Mindful sessions are unavailable")))
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
|
|
643
|
+
let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -30, to: end)!
|
|
644
|
+
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
|
|
645
|
+
let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
|
646
|
+
let cap = max(1, min(limit ?? 1000, 5000))
|
|
647
|
+
let ownBundle = Bundle.main.bundleIdentifier
|
|
648
|
+
|
|
649
|
+
let query = HKSampleQuery(sampleType: mindfulType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { _, samples, error in
|
|
650
|
+
if let error = error, (error as? HKError)?.code != .errorNoData {
|
|
651
|
+
completion(.failure(CraftHealthError(message: error.localizedDescription)))
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
let rows: [[String: Any]] = (samples ?? []).map { sample in
|
|
655
|
+
let source = sample.sourceRevision.source
|
|
656
|
+
var item: [String: Any] = [
|
|
657
|
+
"id": sample.uuid.uuidString,
|
|
658
|
+
"startDate": sample.startDate.timeIntervalSince1970 * 1000,
|
|
659
|
+
"endDate": sample.endDate.timeIntervalSince1970 * 1000,
|
|
660
|
+
"durationSeconds": sample.endDate.timeIntervalSince(sample.startDate),
|
|
661
|
+
"sourceName": source.name,
|
|
662
|
+
"sourceBundleId": source.bundleIdentifier,
|
|
663
|
+
"ownApp": ownBundle != nil && source.bundleIdentifier == ownBundle,
|
|
664
|
+
]
|
|
665
|
+
if let external = sample.metadata?[HKMetadataKeyExternalUUID] as? String { item["externalId"] = external }
|
|
666
|
+
if let device = sample.device?.name { item["deviceName"] = device }
|
|
667
|
+
return item
|
|
668
|
+
}
|
|
669
|
+
completion(.success(rows))
|
|
670
|
+
}
|
|
671
|
+
healthStore.execute(query)
|
|
672
|
+
}
|
|
673
|
+
|
|
562
674
|
}
|
|
@@ -185,6 +185,12 @@ private enum CraftNativeGridBridge {
|
|
|
185
185
|
private typealias AutoRowsFunction = @convention(c) (
|
|
186
186
|
Float, Float, UInt32, Float, Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer
|
|
187
187
|
) -> Int32
|
|
188
|
+
private typealias AlignedFunction = @convention(c) (
|
|
189
|
+
Float, Float, UInt32, Float, Float, UInt32, UnsafeRawPointer, Int, UnsafeMutableRawPointer
|
|
190
|
+
) -> Int32
|
|
191
|
+
private typealias AutoRowsAlignedFunction = @convention(c) (
|
|
192
|
+
Float, Float, UInt32, Float, Float, UInt32, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer
|
|
193
|
+
) -> Int32
|
|
188
194
|
|
|
189
195
|
private static let function: Function? = {
|
|
190
196
|
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid") else { return nil }
|
|
@@ -194,6 +200,14 @@ private enum CraftNativeGridBridge {
|
|
|
194
200
|
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_auto_rows") else { return nil }
|
|
195
201
|
return unsafeBitCast(symbol, to: AutoRowsFunction.self)
|
|
196
202
|
}()
|
|
203
|
+
private static let alignedFunction: AlignedFunction? = {
|
|
204
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_aligned") else { return nil }
|
|
205
|
+
return unsafeBitCast(symbol, to: AlignedFunction.self)
|
|
206
|
+
}()
|
|
207
|
+
private static let autoRowsAlignedFunction: AutoRowsAlignedFunction? = {
|
|
208
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_auto_rows_aligned") else { return nil }
|
|
209
|
+
return unsafeBitCast(symbol, to: AutoRowsAlignedFunction.self)
|
|
210
|
+
}()
|
|
197
211
|
|
|
198
212
|
static func frames(
|
|
199
213
|
availableWidth: CGFloat,
|
|
@@ -202,7 +216,8 @@ private enum CraftNativeGridBridge {
|
|
|
202
216
|
columnGap: CGFloat,
|
|
203
217
|
rowGap: CGFloat,
|
|
204
218
|
items: [(intrinsicWidth: CGFloat, intrinsicHeight: CGFloat, explicitWidth: CGFloat?, explicitHeight: CGFloat?, minWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, maxHeight: CGFloat?, marginLeft: CGFloat, marginRight: CGFloat, marginTop: CGFloat, marginBottom: CGFloat, stretch: Bool, alignment: UInt32)],
|
|
205
|
-
autoRowHeight: CGFloat? = nil
|
|
219
|
+
autoRowHeight: CGFloat? = nil,
|
|
220
|
+
alignContent: UInt32 = 0
|
|
206
221
|
) -> [(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat)]? {
|
|
207
222
|
guard !items.isEmpty else { return nil }
|
|
208
223
|
if autoRowHeight != nil, autoRowsFunction == nil { return nil }
|
|
@@ -222,8 +237,14 @@ private enum CraftNativeGridBridge {
|
|
|
222
237
|
let status = input.withUnsafeBufferPointer { inputBuffer in
|
|
223
238
|
output.withUnsafeMutableBufferPointer { outputBuffer in
|
|
224
239
|
if let autoRowHeight, let autoRowsFunction {
|
|
240
|
+
if alignContent != 0, let autoRowsAlignedFunction {
|
|
241
|
+
return autoRowsAlignedFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), alignContent, Float(autoRowHeight), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
242
|
+
}
|
|
225
243
|
return autoRowsFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), Float(autoRowHeight), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
226
244
|
}
|
|
245
|
+
if alignContent != 0, let alignedFunction {
|
|
246
|
+
return alignedFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), alignContent, UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
247
|
+
}
|
|
227
248
|
guard let function else { return Int32(-1) }
|
|
228
249
|
return function(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
229
250
|
}
|
|
@@ -235,6 +256,18 @@ private enum CraftNativeGridBridge {
|
|
|
235
256
|
}
|
|
236
257
|
}
|
|
237
258
|
|
|
259
|
+
private func craftNativeAlignContentCode(_ value: String) -> UInt32 {
|
|
260
|
+
switch value {
|
|
261
|
+
case "center": return 1
|
|
262
|
+
case "flex-end": return 2
|
|
263
|
+
case "space-between": return 3
|
|
264
|
+
case "space-around": return 4
|
|
265
|
+
case "stretch": return 5
|
|
266
|
+
case "space-evenly": return 6
|
|
267
|
+
default: return 0
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
238
271
|
private final class CraftNativeVideoView: UIView {
|
|
239
272
|
private let playerController = AVPlayerViewController()
|
|
240
273
|
private var player: AVPlayer?
|
|
@@ -436,6 +469,7 @@ private enum CraftNativeLength: Equatable {
|
|
|
436
469
|
private struct CraftNativeLayoutStyle {
|
|
437
470
|
let width: CraftNativeLength?
|
|
438
471
|
let height: CraftNativeLength?
|
|
472
|
+
let aspectRatio: CGFloat?
|
|
439
473
|
let minWidth: CraftNativeLength?
|
|
440
474
|
let maxWidth: CraftNativeLength?
|
|
441
475
|
let minHeight: CraftNativeLength?
|
|
@@ -465,6 +499,8 @@ private struct CraftNativeLayoutStyle {
|
|
|
465
499
|
func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
466
500
|
width = CraftNativeLength(raw["width"])
|
|
467
501
|
height = CraftNativeLength(raw["height"])
|
|
502
|
+
let ratio = number(raw["aspectRatio"])
|
|
503
|
+
aspectRatio = ratio.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
|
|
468
504
|
minWidth = CraftNativeLength(raw["minWidth"])
|
|
469
505
|
maxWidth = CraftNativeLength(raw["maxWidth"])
|
|
470
506
|
minHeight = CraftNativeLength(raw["minHeight"])
|
|
@@ -870,12 +906,13 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
870
906
|
let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
|
|
871
907
|
for child in children where child.craftLayoutStyle.position == "absolute" {
|
|
872
908
|
let style = child.craftLayoutStyle
|
|
873
|
-
|
|
909
|
+
var fixedWidth = style.left != nil && style.right != nil
|
|
874
910
|
? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
|
|
875
911
|
: style.width?.resolve(content.width)
|
|
876
|
-
|
|
912
|
+
var fixedHeight = style.top != nil && style.bottom != nil
|
|
877
913
|
? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
|
|
878
914
|
: style.height?.resolve(content.height)
|
|
915
|
+
(fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
|
|
879
916
|
let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
|
|
880
917
|
let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
|
|
881
918
|
let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
|
|
@@ -885,6 +922,21 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
885
922
|
return (size, frames)
|
|
886
923
|
}
|
|
887
924
|
|
|
925
|
+
/// Resolves the missing side of an aspect-ratio box after its parent has
|
|
926
|
+
/// supplied any definite width or height. Keeping this at the host edge
|
|
927
|
+
/// lets grid and absolute children follow the same rule as flex items
|
|
928
|
+
/// without adding platform-specific measurement behavior to native views.
|
|
929
|
+
private func aspectDimensions(
|
|
930
|
+
_ style: CraftNativeLayoutStyle,
|
|
931
|
+
width: CGFloat?,
|
|
932
|
+
height: CGFloat?
|
|
933
|
+
) -> (width: CGFloat?, height: CGFloat?) {
|
|
934
|
+
guard let ratio = style.aspectRatio else { return (width, height) }
|
|
935
|
+
if let width, height == nil { return (width, width / ratio) }
|
|
936
|
+
if let height, width == nil { return (height * ratio, height) }
|
|
937
|
+
return (width, height)
|
|
938
|
+
}
|
|
939
|
+
|
|
888
940
|
private func layoutFlex(
|
|
889
941
|
_ children: [UIView],
|
|
890
942
|
horizontal: Bool,
|
|
@@ -964,8 +1016,14 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
964
1016
|
let crossLeading = horizontal ? style.marginTop : style.marginLeft
|
|
965
1017
|
let crossTrailing = horizontal ? style.marginBottom : style.marginRight
|
|
966
1018
|
let align = style.alignSelf ?? alignItems
|
|
967
|
-
let
|
|
968
|
-
let
|
|
1019
|
+
let authoredMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
|
|
1020
|
+
let authoredCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
|
|
1021
|
+
let explicitMain = authoredMain ?? style.aspectRatio.flatMap { ratio in
|
|
1022
|
+
authoredCross.map { horizontal ? $0 * ratio : $0 / ratio }
|
|
1023
|
+
}
|
|
1024
|
+
let explicitCross = authoredCross ?? style.aspectRatio.flatMap { ratio in
|
|
1025
|
+
authoredMain.map { horizontal ? $0 / ratio : $0 * ratio }
|
|
1026
|
+
}
|
|
969
1027
|
let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
|
|
970
1028
|
let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
|
|
971
1029
|
let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
|
|
@@ -1146,6 +1204,9 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1146
1204
|
case "space-around":
|
|
1147
1205
|
crossBetween = free / CGFloat(lineCrosses.count) + crossGap
|
|
1148
1206
|
crossLeading = free / CGFloat(lineCrosses.count * 2)
|
|
1207
|
+
case "space-evenly":
|
|
1208
|
+
crossBetween = free / CGFloat(lineCrosses.count + 1) + crossGap
|
|
1209
|
+
crossLeading = free / CGFloat(lineCrosses.count + 1)
|
|
1149
1210
|
case "stretch":
|
|
1150
1211
|
let extra = free / CGFloat(lineCrosses.count)
|
|
1151
1212
|
lineCrosses = lineCrosses.map { $0 + extra }
|
|
@@ -1242,11 +1303,14 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1242
1303
|
let style = child.craftLayoutStyle
|
|
1243
1304
|
let available = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1244
1305
|
let align = style.alignSelf ?? alignItems
|
|
1245
|
-
|
|
1306
|
+
var fixedWidth = style.width?.resolve(cellWidth)
|
|
1307
|
+
var fixedHeight = style.height?.resolve(nil)
|
|
1308
|
+
if fixedWidth == nil && fixedHeight == nil && align == "stretch" { fixedWidth = available }
|
|
1309
|
+
(fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
|
|
1246
1310
|
let size = CraftNativeFlexLayout.measure(
|
|
1247
1311
|
child,
|
|
1248
1312
|
width: fixedWidth.map { min($0, available) },
|
|
1249
|
-
height:
|
|
1313
|
+
height: fixedHeight,
|
|
1250
1314
|
maxWidth: available,
|
|
1251
1315
|
maxHeight: CraftNativeFlexLayout.unbounded
|
|
1252
1316
|
)
|
|
@@ -1270,16 +1334,23 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1270
1334
|
let align = style.alignSelf ?? alignItems
|
|
1271
1335
|
let size = sizes[index]
|
|
1272
1336
|
let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
|
|
1337
|
+
var explicitWidth = style.width?.resolve(cellWidth)
|
|
1338
|
+
var explicitHeight = style.height?.resolve(nil)
|
|
1339
|
+
if explicitWidth == nil && explicitHeight == nil && align == "stretch" {
|
|
1340
|
+
explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1341
|
+
}
|
|
1342
|
+
(explicitWidth, explicitHeight) = aspectDimensions(style, width: explicitWidth, height: explicitHeight)
|
|
1273
1343
|
return (
|
|
1274
1344
|
size.width, size.height,
|
|
1275
|
-
|
|
1345
|
+
explicitWidth, explicitHeight,
|
|
1276
1346
|
style.minWidth?.resolve(cellWidth), style.maxWidth?.resolve(cellWidth),
|
|
1277
1347
|
style.minHeight?.resolve(nil), style.maxHeight?.resolve(nil),
|
|
1278
1348
|
style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
|
|
1279
1349
|
align == "stretch", alignment
|
|
1280
1350
|
)
|
|
1281
1351
|
},
|
|
1282
|
-
autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 }
|
|
1352
|
+
autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 },
|
|
1353
|
+
alignContent: craftNativeAlignContentCode(alignContent)
|
|
1283
1354
|
)
|
|
1284
1355
|
if place {
|
|
1285
1356
|
if let sharedFrames {
|
|
@@ -1289,24 +1360,41 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1289
1360
|
}
|
|
1290
1361
|
return CGSize(width: width, height: innerHeight ?? height)
|
|
1291
1362
|
}
|
|
1292
|
-
|
|
1363
|
+
let gridHeight = innerHeight ?? height
|
|
1364
|
+
let gridGaps = CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
1365
|
+
let gridFree = max(0, gridHeight - rowHeights.reduce(0, +) - gridGaps)
|
|
1366
|
+
var rowLeading: CGFloat = 0
|
|
1367
|
+
var rowBetween = verticalGap
|
|
1368
|
+
var rowExtra: CGFloat = 0
|
|
1369
|
+
switch alignContent {
|
|
1370
|
+
case "center": rowLeading = gridFree / 2
|
|
1371
|
+
case "flex-end": rowLeading = gridFree
|
|
1372
|
+
case "space-between":
|
|
1373
|
+
if rowHeights.count > 1 { rowBetween += gridFree / CGFloat(rowHeights.count - 1) }
|
|
1374
|
+
case "space-around":
|
|
1375
|
+
rowLeading = gridFree / CGFloat(rowHeights.count * 2)
|
|
1376
|
+
rowBetween += gridFree / CGFloat(rowHeights.count)
|
|
1377
|
+
case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
|
|
1378
|
+
default: break
|
|
1379
|
+
}
|
|
1380
|
+
var rowTop = padding.top + rowLeading
|
|
1293
1381
|
for (index, child) in children.enumerated() {
|
|
1294
1382
|
let style = child.craftLayoutStyle
|
|
1295
1383
|
let row = index / columns
|
|
1296
1384
|
let column = index % columns
|
|
1297
|
-
let rowHeight = rowHeights[row]
|
|
1385
|
+
let rowHeight = rowHeights[row] + rowExtra
|
|
1298
1386
|
let size = sizes[index]
|
|
1299
1387
|
let align = style.alignSelf ?? alignItems
|
|
1300
1388
|
let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1301
1389
|
let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
|
|
1302
1390
|
let childWidth = min(availableWidth, size.width)
|
|
1303
|
-
let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
|
|
1391
|
+
let childHeight = align == "stretch" && style.height == nil && style.aspectRatio == nil ? availableHeight : min(availableHeight, size.height)
|
|
1304
1392
|
let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
|
|
1305
1393
|
+ (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
|
|
1306
1394
|
let y = rowTop + style.marginTop
|
|
1307
1395
|
+ (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
|
|
1308
1396
|
frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
|
|
1309
|
-
if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight +
|
|
1397
|
+
if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + rowBetween }
|
|
1310
1398
|
}
|
|
1311
1399
|
}
|
|
1312
1400
|
return CGSize(width: width, height: innerHeight ?? height)
|
|
@@ -3581,7 +3669,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3581
3669
|
current.protocolId = node["id"] as? String
|
|
3582
3670
|
current.style = style
|
|
3583
3671
|
result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
|
|
3584
|
-
if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
|
|
3672
|
+
if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "SectionList" && type != "FlatList" && type != "Modal" {
|
|
3585
3673
|
result.setContentHuggingPriority(.required, for: .vertical)
|
|
3586
3674
|
}
|
|
3587
3675
|
applyViewStyle(style, to: result, node: current)
|
|
@@ -3893,7 +3981,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3893
3981
|
controls: (props["controls"] as? Bool) == true,
|
|
3894
3982
|
resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
3895
3983
|
)
|
|
3896
|
-
case "ScrollView":
|
|
3984
|
+
case "ScrollView", "SectionList":
|
|
3897
3985
|
let scroll = result as! CraftNativeScrollView
|
|
3898
3986
|
let flexDirection = style["flexDirection"] as? String
|
|
3899
3987
|
let direction = (props["horizontal"] as? Bool) == true
|
|
@@ -4020,7 +4108,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4020
4108
|
let icon = CraftNativeIconView()
|
|
4021
4109
|
icon.contentMode = .scaleAspectFit
|
|
4022
4110
|
return icon
|
|
4023
|
-
case "ScrollView":
|
|
4111
|
+
case "ScrollView", "SectionList":
|
|
4024
4112
|
return CraftNativeScrollView()
|
|
4025
4113
|
case "FlatList":
|
|
4026
4114
|
return CraftNativeFlatList()
|
|
@@ -4055,6 +4143,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4055
4143
|
case "video": return "Video"
|
|
4056
4144
|
case "icon": return "Icon"
|
|
4057
4145
|
case "scrollview": return "ScrollView"
|
|
4146
|
+
case "sectionlist": return "SectionList"
|
|
4058
4147
|
case "flatlist": return "FlatList"
|
|
4059
4148
|
case "svg": return "Svg"
|
|
4060
4149
|
case "touchableopacity": return "TouchableOpacity"
|