@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.160",
3
+ "version": "0.0.162",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- let fixedWidth = style.left != nil && style.right != nil
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
- let fixedHeight = style.top != nil && style.bottom != nil
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 explicitMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
968
- let explicitCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
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
- let fixedWidth = style.width?.resolve(cellWidth) ?? (align == "stretch" ? available : nil)
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: style.height?.resolve(nil),
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
- style.width?.resolve(cellWidth), style.height?.resolve(nil),
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
- var rowTop = padding.top
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 + verticalGap }
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"