@craft-native/ios 0.0.160 → 0.0.161
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 +8 -1
- package/package.json +1 -1
- package/templates/CraftApp.swift +24 -0
- package/templates/CraftHealth.swift +112 -0
- package/templates/CraftNativeScreen.swift +101 -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
|
|
@@ -399,6 +399,13 @@ const readings = await health.getHeartRateSamples({ startDate: Date.now() - 8640
|
|
|
399
399
|
const nights = await health.getSleepIntervals({ startDate: Date.now() - 86400000 })
|
|
400
400
|
// An app built before these reads existed has neither; check first.
|
|
401
401
|
if (health.supports('getHeartRateSamples')) { /* ... */ }
|
|
402
|
+
// Mindful minutes: ask for 'mindful' (with writing allowed), then save a
|
|
403
|
+
// meditation or breathing session. The id is kept as the sample's external
|
|
404
|
+
// UUID, so saving it again keeps the first sample ({ existing: true }).
|
|
405
|
+
await health.requestAuthorization(['mindful'], { write: true })
|
|
406
|
+
await health.saveMindfulSession({ id: 'session-42', startDate: started, endDate: Date.now() })
|
|
407
|
+
// Every app's mindful sessions, oldest first; ownApp marks the ones this app wrote.
|
|
408
|
+
const mindful = await health.getMindfulSessions({ startDate: Date.now() - 7 * 86400000 })
|
|
402
409
|
|
|
403
410
|
// Screen Capture
|
|
404
411
|
const screenshotBase64 = await window.craft.takeScreenshot();
|
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,17 @@ 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
|
+
default: return 0
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
238
270
|
private final class CraftNativeVideoView: UIView {
|
|
239
271
|
private let playerController = AVPlayerViewController()
|
|
240
272
|
private var player: AVPlayer?
|
|
@@ -436,6 +468,7 @@ private enum CraftNativeLength: Equatable {
|
|
|
436
468
|
private struct CraftNativeLayoutStyle {
|
|
437
469
|
let width: CraftNativeLength?
|
|
438
470
|
let height: CraftNativeLength?
|
|
471
|
+
let aspectRatio: CGFloat?
|
|
439
472
|
let minWidth: CraftNativeLength?
|
|
440
473
|
let maxWidth: CraftNativeLength?
|
|
441
474
|
let minHeight: CraftNativeLength?
|
|
@@ -465,6 +498,8 @@ private struct CraftNativeLayoutStyle {
|
|
|
465
498
|
func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
466
499
|
width = CraftNativeLength(raw["width"])
|
|
467
500
|
height = CraftNativeLength(raw["height"])
|
|
501
|
+
let ratio = number(raw["aspectRatio"])
|
|
502
|
+
aspectRatio = ratio.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
|
|
468
503
|
minWidth = CraftNativeLength(raw["minWidth"])
|
|
469
504
|
maxWidth = CraftNativeLength(raw["maxWidth"])
|
|
470
505
|
minHeight = CraftNativeLength(raw["minHeight"])
|
|
@@ -870,12 +905,13 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
870
905
|
let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
|
|
871
906
|
for child in children where child.craftLayoutStyle.position == "absolute" {
|
|
872
907
|
let style = child.craftLayoutStyle
|
|
873
|
-
|
|
908
|
+
var fixedWidth = style.left != nil && style.right != nil
|
|
874
909
|
? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
|
|
875
910
|
: style.width?.resolve(content.width)
|
|
876
|
-
|
|
911
|
+
var fixedHeight = style.top != nil && style.bottom != nil
|
|
877
912
|
? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
|
|
878
913
|
: style.height?.resolve(content.height)
|
|
914
|
+
(fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
|
|
879
915
|
let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
|
|
880
916
|
let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
|
|
881
917
|
let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
|
|
@@ -885,6 +921,21 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
885
921
|
return (size, frames)
|
|
886
922
|
}
|
|
887
923
|
|
|
924
|
+
/// Resolves the missing side of an aspect-ratio box after its parent has
|
|
925
|
+
/// supplied any definite width or height. Keeping this at the host edge
|
|
926
|
+
/// lets grid and absolute children follow the same rule as flex items
|
|
927
|
+
/// without adding platform-specific measurement behavior to native views.
|
|
928
|
+
private func aspectDimensions(
|
|
929
|
+
_ style: CraftNativeLayoutStyle,
|
|
930
|
+
width: CGFloat?,
|
|
931
|
+
height: CGFloat?
|
|
932
|
+
) -> (width: CGFloat?, height: CGFloat?) {
|
|
933
|
+
guard let ratio = style.aspectRatio else { return (width, height) }
|
|
934
|
+
if let width, height == nil { return (width, width / ratio) }
|
|
935
|
+
if let height, width == nil { return (height * ratio, height) }
|
|
936
|
+
return (width, height)
|
|
937
|
+
}
|
|
938
|
+
|
|
888
939
|
private func layoutFlex(
|
|
889
940
|
_ children: [UIView],
|
|
890
941
|
horizontal: Bool,
|
|
@@ -964,8 +1015,14 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
964
1015
|
let crossLeading = horizontal ? style.marginTop : style.marginLeft
|
|
965
1016
|
let crossTrailing = horizontal ? style.marginBottom : style.marginRight
|
|
966
1017
|
let align = style.alignSelf ?? alignItems
|
|
967
|
-
let
|
|
968
|
-
let
|
|
1018
|
+
let authoredMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
|
|
1019
|
+
let authoredCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
|
|
1020
|
+
let explicitMain = authoredMain ?? style.aspectRatio.flatMap { ratio in
|
|
1021
|
+
authoredCross.map { horizontal ? $0 * ratio : $0 / ratio }
|
|
1022
|
+
}
|
|
1023
|
+
let explicitCross = authoredCross ?? style.aspectRatio.flatMap { ratio in
|
|
1024
|
+
authoredMain.map { horizontal ? $0 / ratio : $0 * ratio }
|
|
1025
|
+
}
|
|
969
1026
|
let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
|
|
970
1027
|
let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
|
|
971
1028
|
let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
|
|
@@ -1242,11 +1299,14 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1242
1299
|
let style = child.craftLayoutStyle
|
|
1243
1300
|
let available = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1244
1301
|
let align = style.alignSelf ?? alignItems
|
|
1245
|
-
|
|
1302
|
+
var fixedWidth = style.width?.resolve(cellWidth)
|
|
1303
|
+
var fixedHeight = style.height?.resolve(nil)
|
|
1304
|
+
if fixedWidth == nil && fixedHeight == nil && align == "stretch" { fixedWidth = available }
|
|
1305
|
+
(fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
|
|
1246
1306
|
let size = CraftNativeFlexLayout.measure(
|
|
1247
1307
|
child,
|
|
1248
1308
|
width: fixedWidth.map { min($0, available) },
|
|
1249
|
-
height:
|
|
1309
|
+
height: fixedHeight,
|
|
1250
1310
|
maxWidth: available,
|
|
1251
1311
|
maxHeight: CraftNativeFlexLayout.unbounded
|
|
1252
1312
|
)
|
|
@@ -1270,16 +1330,23 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1270
1330
|
let align = style.alignSelf ?? alignItems
|
|
1271
1331
|
let size = sizes[index]
|
|
1272
1332
|
let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
|
|
1333
|
+
var explicitWidth = style.width?.resolve(cellWidth)
|
|
1334
|
+
var explicitHeight = style.height?.resolve(nil)
|
|
1335
|
+
if explicitWidth == nil && explicitHeight == nil && align == "stretch" {
|
|
1336
|
+
explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1337
|
+
}
|
|
1338
|
+
(explicitWidth, explicitHeight) = aspectDimensions(style, width: explicitWidth, height: explicitHeight)
|
|
1273
1339
|
return (
|
|
1274
1340
|
size.width, size.height,
|
|
1275
|
-
|
|
1341
|
+
explicitWidth, explicitHeight,
|
|
1276
1342
|
style.minWidth?.resolve(cellWidth), style.maxWidth?.resolve(cellWidth),
|
|
1277
1343
|
style.minHeight?.resolve(nil), style.maxHeight?.resolve(nil),
|
|
1278
1344
|
style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
|
|
1279
1345
|
align == "stretch", alignment
|
|
1280
1346
|
)
|
|
1281
1347
|
},
|
|
1282
|
-
autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 }
|
|
1348
|
+
autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 },
|
|
1349
|
+
alignContent: craftNativeAlignContentCode(alignContent)
|
|
1283
1350
|
)
|
|
1284
1351
|
if place {
|
|
1285
1352
|
if let sharedFrames {
|
|
@@ -1289,24 +1356,41 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1289
1356
|
}
|
|
1290
1357
|
return CGSize(width: width, height: innerHeight ?? height)
|
|
1291
1358
|
}
|
|
1292
|
-
|
|
1359
|
+
let gridHeight = innerHeight ?? height
|
|
1360
|
+
let gridGaps = CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
1361
|
+
let gridFree = max(0, gridHeight - rowHeights.reduce(0, +) - gridGaps)
|
|
1362
|
+
var rowLeading: CGFloat = 0
|
|
1363
|
+
var rowBetween = verticalGap
|
|
1364
|
+
var rowExtra: CGFloat = 0
|
|
1365
|
+
switch alignContent {
|
|
1366
|
+
case "center": rowLeading = gridFree / 2
|
|
1367
|
+
case "flex-end": rowLeading = gridFree
|
|
1368
|
+
case "space-between":
|
|
1369
|
+
if rowHeights.count > 1 { rowBetween += gridFree / CGFloat(rowHeights.count - 1) }
|
|
1370
|
+
case "space-around":
|
|
1371
|
+
rowLeading = gridFree / CGFloat(rowHeights.count * 2)
|
|
1372
|
+
rowBetween += gridFree / CGFloat(rowHeights.count)
|
|
1373
|
+
case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
|
|
1374
|
+
default: break
|
|
1375
|
+
}
|
|
1376
|
+
var rowTop = padding.top + rowLeading
|
|
1293
1377
|
for (index, child) in children.enumerated() {
|
|
1294
1378
|
let style = child.craftLayoutStyle
|
|
1295
1379
|
let row = index / columns
|
|
1296
1380
|
let column = index % columns
|
|
1297
|
-
let rowHeight = rowHeights[row]
|
|
1381
|
+
let rowHeight = rowHeights[row] + rowExtra
|
|
1298
1382
|
let size = sizes[index]
|
|
1299
1383
|
let align = style.alignSelf ?? alignItems
|
|
1300
1384
|
let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1301
1385
|
let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
|
|
1302
1386
|
let childWidth = min(availableWidth, size.width)
|
|
1303
|
-
let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
|
|
1387
|
+
let childHeight = align == "stretch" && style.height == nil && style.aspectRatio == nil ? availableHeight : min(availableHeight, size.height)
|
|
1304
1388
|
let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
|
|
1305
1389
|
+ (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
|
|
1306
1390
|
let y = rowTop + style.marginTop
|
|
1307
1391
|
+ (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
|
|
1308
1392
|
frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
|
|
1309
|
-
if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight +
|
|
1393
|
+
if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + rowBetween }
|
|
1310
1394
|
}
|
|
1311
1395
|
}
|
|
1312
1396
|
return CGSize(width: width, height: innerHeight ?? height)
|
|
@@ -3581,7 +3665,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3581
3665
|
current.protocolId = node["id"] as? String
|
|
3582
3666
|
current.style = style
|
|
3583
3667
|
result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
|
|
3584
|
-
if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
|
|
3668
|
+
if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "SectionList" && type != "FlatList" && type != "Modal" {
|
|
3585
3669
|
result.setContentHuggingPriority(.required, for: .vertical)
|
|
3586
3670
|
}
|
|
3587
3671
|
applyViewStyle(style, to: result, node: current)
|
|
@@ -3893,7 +3977,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3893
3977
|
controls: (props["controls"] as? Bool) == true,
|
|
3894
3978
|
resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
3895
3979
|
)
|
|
3896
|
-
case "ScrollView":
|
|
3980
|
+
case "ScrollView", "SectionList":
|
|
3897
3981
|
let scroll = result as! CraftNativeScrollView
|
|
3898
3982
|
let flexDirection = style["flexDirection"] as? String
|
|
3899
3983
|
let direction = (props["horizontal"] as? Bool) == true
|
|
@@ -4020,7 +4104,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4020
4104
|
let icon = CraftNativeIconView()
|
|
4021
4105
|
icon.contentMode = .scaleAspectFit
|
|
4022
4106
|
return icon
|
|
4023
|
-
case "ScrollView":
|
|
4107
|
+
case "ScrollView", "SectionList":
|
|
4024
4108
|
return CraftNativeScrollView()
|
|
4025
4109
|
case "FlatList":
|
|
4026
4110
|
return CraftNativeFlatList()
|
|
@@ -4055,6 +4139,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4055
4139
|
case "video": return "Video"
|
|
4056
4140
|
case "icon": return "Icon"
|
|
4057
4141
|
case "scrollview": return "ScrollView"
|
|
4142
|
+
case "sectionlist": return "SectionList"
|
|
4058
4143
|
case "flatlist": return "FlatList"
|
|
4059
4144
|
case "svg": return "Svg"
|
|
4060
4145
|
case "touchableopacity": return "TouchableOpacity"
|