@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.160",
3
+ "version": "0.0.161",
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,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
- let fixedWidth = style.left != nil && style.right != nil
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
- let fixedHeight = style.top != nil && style.bottom != nil
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 explicitMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
968
- let explicitCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
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
- let fixedWidth = style.width?.resolve(cellWidth) ?? (align == "stretch" ? available : nil)
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: style.height?.resolve(nil),
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
- style.width?.resolve(cellWidth), style.height?.resolve(nil),
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
- var rowTop = padding.top
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 + verticalGap }
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"