craft-native 0.0.124 → 0.0.125

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.
@@ -1,5 +1,6 @@
1
1
  import Foundation
2
2
  import JavaScriptCore
3
+ import OSLog
3
4
  import SwiftUI
4
5
  import UIKit
5
6
 
@@ -17,7 +18,7 @@ private func craftNativeGridColumnCount(_ value: Any?) -> Int {
17
18
  return max(1, tracks.count)
18
19
  }
19
20
 
20
- /// A WebView-free host for the first stx-native vertical slice. The bundled
21
+ /// A WebView-free host for the first stx native vertical slice. The bundled
21
22
  /// JavaScript sends whole, compiled view trees to UIKit and receives control
22
23
  /// events and Craft API replies through JavaScriptCore.
23
24
  struct CraftNativeScreen: UIViewControllerRepresentable {
@@ -30,13 +31,46 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
30
31
  func updateUIViewController(_ controller: UINavigationController, context: Context) {}
31
32
  }
32
33
 
34
+ /// A length as a style gives it: points, or a percentage of the parent's
35
+ /// content box. A percentage against a size that is not known yet (a column
36
+ /// whose height comes from its content) resolves to nothing, as on the web.
37
+ private enum CraftNativeLength {
38
+ case points(CGFloat)
39
+ case percent(CGFloat)
40
+
41
+ init?(_ value: Any?) {
42
+ if let number = value as? NSNumber {
43
+ self = .points(CGFloat(truncating: number))
44
+ return
45
+ }
46
+ guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
47
+ if text.hasSuffix("%"), let fraction = Double(text.dropLast()) {
48
+ self = .percent(CGFloat(fraction) / 100)
49
+ } else if text.hasSuffix("px"), let points = Double(text.dropLast(2)) {
50
+ self = .points(CGFloat(points))
51
+ } else if let points = Double(text) {
52
+ self = .points(CGFloat(points))
53
+ } else {
54
+ return nil
55
+ }
56
+ }
57
+
58
+ func resolve(_ base: CGFloat?) -> CGFloat? {
59
+ switch self {
60
+ case .points(let value): return value
61
+ case .percent(let fraction): return base.map { max(0, $0 * fraction) }
62
+ }
63
+ }
64
+ }
65
+
66
+ /// How a parent lays a child out, read once from the child's style.
33
67
  private struct CraftNativeLayoutStyle {
34
- let width: CGFloat?
35
- let height: CGFloat?
36
- let minWidth: CGFloat?
37
- let maxWidth: CGFloat?
38
- let minHeight: CGFloat?
39
- let maxHeight: CGFloat?
68
+ let width: CraftNativeLength?
69
+ let height: CraftNativeLength?
70
+ let minWidth: CraftNativeLength?
71
+ let maxWidth: CraftNativeLength?
72
+ let minHeight: CraftNativeLength?
73
+ let maxHeight: CraftNativeLength?
40
74
  let position: String
41
75
  let top: CGFloat?
42
76
  let right: CGFloat?
@@ -44,8 +78,10 @@ private struct CraftNativeLayoutStyle {
44
78
  let left: CGFloat?
45
79
  let alignSelf: String?
46
80
  let flexGrow: CGFloat
47
- let flexShrink: CGFloat
48
- let flexBasis: CGFloat?
81
+ /// Nil when the style does not say: the parent picks the default for the
82
+ /// kind of view (text shrinks along a row, a box does not).
83
+ let flexShrink: CGFloat?
84
+ let flexBasis: CraftNativeLength?
49
85
  let marginTop: CGFloat
50
86
  let marginRight: CGFloat
51
87
  let marginBottom: CGFloat
@@ -54,23 +90,30 @@ private struct CraftNativeLayoutStyle {
54
90
  let gridColumns: Int
55
91
  let gridAutoRows: CGFloat?
56
92
 
93
+ static let empty = CraftNativeLayoutStyle([:])
94
+
57
95
  init(_ raw: [String: Any]) {
58
96
  func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
59
- width = number(raw["width"])
60
- height = number(raw["height"])
61
- minWidth = number(raw["minWidth"])
62
- maxWidth = number(raw["maxWidth"])
63
- minHeight = number(raw["minHeight"])
64
- maxHeight = number(raw["maxHeight"])
97
+ width = CraftNativeLength(raw["width"])
98
+ height = CraftNativeLength(raw["height"])
99
+ minWidth = CraftNativeLength(raw["minWidth"])
100
+ maxWidth = CraftNativeLength(raw["maxWidth"])
101
+ minHeight = CraftNativeLength(raw["minHeight"])
102
+ maxHeight = CraftNativeLength(raw["maxHeight"])
65
103
  position = raw["position"] as? String ?? "relative"
66
104
  top = number(raw["top"])
67
105
  right = number(raw["right"])
68
106
  bottom = number(raw["bottom"])
69
107
  left = number(raw["left"])
70
- alignSelf = raw["alignSelf"] as? String
71
- flexGrow = number(raw["flexGrow"] ?? raw["flex"]) ?? 0
72
- flexShrink = number(raw["flexShrink"]) ?? 0
73
- flexBasis = number(raw["flexBasis"])
108
+ let align = raw["alignSelf"] as? String
109
+ alignSelf = align == "auto" ? nil : align
110
+ // `flex: n` is the web's and React Native's shorthand: a positive
111
+ // number grows by n, shrinks, and starts from nothing, so `flex-1`
112
+ // siblings share a row equally whatever their content.
113
+ let flex = number(raw["flex"])
114
+ flexGrow = number(raw["flexGrow"]) ?? flex.map { max(0, $0) } ?? 0
115
+ flexShrink = number(raw["flexShrink"]) ?? flex.map { $0 != 0 ? 1 : 0 }
116
+ flexBasis = CraftNativeLength(raw["flexBasis"]) ?? flex.flatMap { $0 > 0 ? .points(0) : nil }
74
117
  let margin = number(raw["margin"]) ?? 0
75
118
  let horizontalMargin = number(raw["marginHorizontal"]) ?? margin
76
119
  let verticalMargin = number(raw["marginVertical"]) ?? margin
@@ -84,31 +127,146 @@ private struct CraftNativeLayoutStyle {
84
127
  }
85
128
  }
86
129
 
130
+ private final class CraftNativeLayoutStyleBox {
131
+ let style: CraftNativeLayoutStyle
132
+ init(_ style: CraftNativeLayoutStyle) { self.style = style }
133
+ }
134
+
135
+ private var craftNativeLayoutStyleKey: UInt8 = 0
136
+ private var craftNativeCornerRadiusKey: UInt8 = 0
137
+
138
+ extension UIView {
139
+ /// The layout style this view's parent reads. It lives on the view, so an
140
+ /// in-place style update reaches the parent's next layout pass without the
141
+ /// parent being reconciled too.
142
+ fileprivate var craftLayoutStyle: CraftNativeLayoutStyle {
143
+ get { (objc_getAssociatedObject(self, &craftNativeLayoutStyleKey) as? CraftNativeLayoutStyleBox)?.style ?? .empty }
144
+ set { objc_setAssociatedObject(self, &craftNativeLayoutStyleKey, CraftNativeLayoutStyleBox(newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
145
+ }
146
+
147
+ /// The corner radius the style asked for. The layer gets at most half the
148
+ /// shortest side: Core Animation draws nothing at all for a radius larger
149
+ /// than the view (`rounded-full` is 9999).
150
+ var craftRequestedCornerRadius: CGFloat? {
151
+ get { (objc_getAssociatedObject(self, &craftNativeCornerRadiusKey) as? NSNumber).map { CGFloat(truncating: $0) } }
152
+ set {
153
+ objc_setAssociatedObject(self, &craftNativeCornerRadiusKey, newValue.map { NSNumber(value: Double($0)) }, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
154
+ craftApplyCornerRadius()
155
+ }
156
+ }
157
+
158
+ func craftApplyCornerRadius() {
159
+ let requested = craftRequestedCornerRadius ?? 0
160
+ let limit = min(bounds.width, bounds.height) / 2
161
+ let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
162
+ if layer.cornerRadius != radius { layer.cornerRadius = radius }
163
+ }
164
+ }
165
+
166
+ /// Measures any native-screen view the way its parent's flex layout needs:
167
+ /// at a width (or height) the parent fixes, or at its natural size within the
168
+ /// room there is. Shared by the flow views, the scroll view and FlatList rows.
169
+ enum CraftNativeFlexLayout {
170
+ static let unbounded: CGFloat = 1_000_000
171
+
172
+ static func measure(_ view: UIView, width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
173
+ let maxWidth = max(0, maxWidth)
174
+ let maxHeight = max(0, maxHeight)
175
+ switch view {
176
+ case let flow as CraftNativeFlowView:
177
+ return flow.measure(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
178
+ case let scroll as CraftNativeScrollView:
179
+ return scroll.measure(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
180
+ case let list as CraftNativeFlatList:
181
+ let listWidth = width ?? maxWidth
182
+ return CGSize(width: listWidth, height: height ?? list.fittingHeight(width: listWidth))
183
+ case let label as UILabel:
184
+ // Text takes the width it needs up to the room there is, and
185
+ // wraps or truncates inside it rather than widening its parent.
186
+ let limit = width ?? maxWidth
187
+ let natural = label.sizeThatFits(CGSize(width: limit, height: unbounded))
188
+ let resolvedWidth = width ?? min(ceil(natural.width), limit)
189
+ let resolvedHeight = height ?? ceil(resolvedWidth < limit
190
+ ? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
191
+ : natural.height)
192
+ return CGSize(width: resolvedWidth, height: resolvedHeight)
193
+ case let image as UIImageView where image.image == nil:
194
+ return CGSize(width: width ?? 0, height: height ?? 0)
195
+ default:
196
+ let intrinsic = view.intrinsicContentSize
197
+ let fitted = view.sizeThatFits(CGSize(width: width ?? maxWidth, height: height ?? maxHeight))
198
+ let naturalWidth = intrinsic.width > 0 && intrinsic.width != UIView.noIntrinsicMetric ? intrinsic.width : fitted.width
199
+ let naturalHeight = intrinsic.height > 0 && intrinsic.height != UIView.noIntrinsicMetric ? intrinsic.height : fitted.height
200
+ return CGSize(width: width ?? min(max(0, naturalWidth), maxWidth), height: height ?? max(0, naturalHeight))
201
+ }
202
+ }
203
+
204
+ /// Marks every flex container above `view` for measuring again, after a
205
+ /// change inside it that may change its size. A FlatList row stops the
206
+ /// walk: the list measures its rows itself.
207
+ static func invalidateAncestors(of view: UIView) {
208
+ var current = view.superview
209
+ while let ancestor = current, !(ancestor is UICollectionViewCell) {
210
+ if let flow = ancestor as? CraftNativeFlowView { flow.invalidateMeasurements() }
211
+ if let scroll = ancestor as? CraftNativeScrollView { scroll.setContentNeedsLayout() }
212
+ current = ancestor.superview
213
+ }
214
+ }
215
+
216
+ /// A frame snapped to the screen's pixels edge by edge, so neighbours
217
+ /// neither overlap nor leave a hairline between them.
218
+ static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
219
+ let scale = max(1, scale)
220
+ func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
221
+ let minX = snap(rect.minX), minY = snap(rect.minY)
222
+ return CGRect(x: minX, y: minY, width: max(0, snap(rect.maxX) - minX), height: max(0, snap(rect.maxY) - minY))
223
+ }
224
+ }
225
+
226
+ /// A flexbox container: rows and columns with grow, shrink and basis, wrap,
227
+ /// gaps, alignment, margins, percentages, absolute children and a simple
228
+ /// grid. It is a UIStackView in name only, so existing callers and tests keep
229
+ /// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
230
+ /// constraints are never created, and every frame comes from `layout`.
87
231
  private final class CraftNativeFlowView: UIStackView {
88
- var wrap = false { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
89
- var alignItems = "stretch" { didSet { alignment = alignmentValue; setNeedsLayout() } }
90
- var justifyContent = "flex-start" { didSet { distribution = distributionValue; setNeedsLayout() } }
91
- var gap: CGFloat = 0 { didSet { spacing = gap; invalidateIntrinsicContentSize(); setNeedsLayout() } }
92
- var rowGap: CGFloat? { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
93
- var columnGap: CGFloat? { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
94
- var grid = false { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
95
- var gridColumns = 1 { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
96
- var gridAutoRows: CGFloat? { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
232
+ var pressActiveOpacity: CGFloat?
233
+ var pressBaseOpacity: CGFloat = 1
234
+ var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
235
+ var alignItems = "stretch" { didSet { alignment = alignmentValue; if alignItems != oldValue { invalidateMeasurements() } } }
236
+ var justifyContent = "flex-start" { didSet { distribution = distributionValue; if justifyContent != oldValue { invalidateMeasurements() } } }
237
+ var gap: CGFloat = 0 { didSet { spacing = gap; if gap != oldValue { invalidateMeasurements() } } }
238
+ var rowGap: CGFloat? { didSet { if rowGap != oldValue { invalidateMeasurements() } } }
239
+ var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
240
+ var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
241
+ var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
242
+ var gridAutoRows: CGFloat? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
97
243
  var padding = UIEdgeInsets.zero {
98
244
  didSet {
99
245
  layoutMargins = padding
100
246
  isLayoutMarginsRelativeArrangement = true
101
- invalidateIntrinsicContentSize()
102
- setNeedsLayout()
247
+ if padding != oldValue { invalidateMeasurements() }
103
248
  }
104
249
  }
105
- private var childStyles: [ObjectIdentifier: CraftNativeLayoutStyle] = [:]
250
+ override var axis: NSLayoutConstraint.Axis {
251
+ didSet { if axis != oldValue { invalidateMeasurements() } }
252
+ }
253
+
254
+ private var flowChildren: [UIView] = []
255
+ private var measurements: [MeasureKey: CGSize] = [:]
256
+
257
+ private struct MeasureKey: Hashable {
258
+ let width: CGFloat?
259
+ let height: CGFloat?
260
+ let maxWidth: CGFloat
261
+ let maxHeight: CGFloat
262
+ }
106
263
 
107
264
  override init(frame: CGRect) {
108
265
  super.init(frame: frame)
109
266
  alignment = .fill
110
267
  distribution = .fill
111
268
  isLayoutMarginsRelativeArrangement = true
269
+ insetsLayoutMarginsFromSafeArea = false
112
270
  }
113
271
 
114
272
  required init(coder: NSCoder) { super.init(coder: coder) }
@@ -131,304 +289,677 @@ private final class CraftNativeFlowView: UIStackView {
131
289
  }
132
290
  }
133
291
 
134
- func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
135
- childStyles[ObjectIdentifier(view)] = CraftNativeLayoutStyle(raw)
136
- invalidateIntrinsicContentSize()
137
- setNeedsLayout()
292
+ // MARK: Children
293
+
294
+ override var arrangedSubviews: [UIView] { flowChildren }
295
+
296
+ override func addArrangedSubview(_ view: UIView) {
297
+ insertArrangedSubview(view, at: flowChildren.count)
138
298
  }
139
299
 
140
- func removeLayoutStyle(for view: UIView) { childStyles.removeValue(forKey: ObjectIdentifier(view)) }
300
+ override func insertArrangedSubview(_ view: UIView, at stackIndex: Int) {
301
+ flowChildren.removeAll { $0 === view }
302
+ flowChildren.insert(view, at: min(max(0, stackIndex), flowChildren.count))
303
+ if view.superview !== self {
304
+ view.removeFromSuperview()
305
+ addSubview(view)
306
+ }
307
+ view.translatesAutoresizingMaskIntoConstraints = true
308
+ invalidateMeasurements()
309
+ }
141
310
 
142
- override func didAddSubview(_ subview: UIView) {
143
- super.didAddSubview(subview)
144
- setNeedsLayout()
311
+ override func removeArrangedSubview(_ view: UIView) {
312
+ flowChildren.removeAll { $0 === view }
313
+ invalidateMeasurements()
145
314
  }
146
315
 
147
316
  override func willRemoveSubview(_ subview: UIView) {
148
- childStyles.removeValue(forKey: ObjectIdentifier(subview))
317
+ flowChildren.removeAll { $0 === subview }
149
318
  super.willRemoveSubview(subview)
319
+ invalidateMeasurements()
320
+ }
321
+
322
+ func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
323
+ view.craftLayoutStyle = CraftNativeLayoutStyle(raw)
324
+ invalidateMeasurements()
325
+ }
326
+
327
+ func removeLayoutStyle(for view: UIView) {
328
+ view.craftLayoutStyle = .empty
150
329
  }
151
330
 
331
+ /// Forgets measured sizes and lays out again on the next pass.
332
+ func invalidateMeasurements() {
333
+ measurements.removeAll(keepingCapacity: true)
334
+ setNeedsLayout()
335
+ }
336
+
337
+ // MARK: Measuring and layout
338
+
152
339
  override var intrinsicContentSize: CGSize {
153
- let children = subviews.filter {
154
- !$0.isHidden && !($0 is CraftNativeFlexSpacer)
155
- && (childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:])).position != "absolute"
156
- }
157
- guard !children.isEmpty else { return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) }
158
- if grid { return gridIntrinsicContentSize(children) }
159
- let sizes = children.map { measuredSize(for: $0, available: CGSize(width: 10_000, height: 10_000)) }
160
- let styles = children.map { childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:]) }
161
- let main = sizes.enumerated().map { index, size in
162
- (axis == .horizontal ? size.width : size.height) + mainMargins(styles[index])
163
- }.reduce(0, +)
164
- let cross = sizes.enumerated().map { index, size in
165
- (axis == .horizontal ? size.height : size.width) + crossMargins(styles[index])
166
- }.max() ?? 0
167
- let mainGap = max(0, CGFloat(max(0, children.count - 1))) * mainGapValue
168
- if axis == .horizontal {
169
- return CGSize(width: padding.left + padding.right + main + mainGap, height: padding.top + padding.bottom + cross)
170
- }
171
- return CGSize(width: padding.left + padding.right + cross, height: padding.top + padding.bottom + main + mainGap)
340
+ CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
341
+ }
342
+
343
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
344
+ measure(width: nil, height: nil, maxWidth: size.width, maxHeight: size.height)
345
+ }
346
+
347
+ func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
348
+ let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
349
+ if let cached = measurements[key] { return cached }
350
+ let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
351
+ measurements[key] = size
352
+ return size
172
353
  }
173
354
 
174
355
  override func layoutSubviews() {
175
356
  super.layoutSubviews()
176
- let content = bounds.inset(by: padding)
177
- if grid {
178
- layoutGrid(content: content)
179
- return
357
+ craftApplyCornerRadius()
358
+ let scale = window?.screen.scale ?? traitCollection.displayScale
359
+ let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
360
+ for (child, frame) in result.frames {
361
+ let snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
362
+ if child.frame != snapped { child.frame = snapped }
363
+ child.craftApplyCornerRadius()
180
364
  }
181
- let availableMain = axis == .horizontal ? content.width : content.height
182
- var lines: [([UIView], [CGSize], CGFloat, CGFloat)] = []
183
- var items: [UIView] = []
184
- var sizes: [CGSize] = []
365
+ }
366
+
367
+ private struct Item {
368
+ let view: UIView
369
+ let style: CraftNativeLayoutStyle
370
+ let align: String
371
+ let mainLeading: CGFloat
372
+ let mainTrailing: CGFloat
373
+ let crossLeading: CGFloat
374
+ let crossTrailing: CGFloat
375
+ let minMain: CGFloat?
376
+ let maxMain: CGFloat?
377
+ let minCross: CGFloat?
378
+ let maxCross: CGFloat?
379
+ let fixedCross: CGFloat?
380
+ let grow: CGFloat
381
+ let shrink: CGFloat
382
+ var basis: CGFloat
185
383
  var main: CGFloat = 0
186
384
  var cross: CGFloat = 0
187
385
 
188
- func flush() {
189
- guard !items.isEmpty else { return }
190
- lines.append((items, sizes, main, cross))
191
- items = []; sizes = []; main = 0; cross = 0
192
- }
193
-
194
- for child in subviews where !child.isHidden && !(child is CraftNativeFlexSpacer) {
195
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
196
- if style.position == "absolute" { continue }
197
- let size = measuredSize(for: child, available: content.size, style: style)
198
- let childMain = (axis == .horizontal ? size.width : size.height) + mainMargins(style)
199
- let childCross = (axis == .horizontal ? size.height : size.width) + crossMargins(style)
200
- let next = items.isEmpty ? childMain : main + mainGapValue + childMain
201
- if wrap && !items.isEmpty && next > availableMain { flush() }
202
- items.append(child); sizes.append(size)
203
- main = items.count == 1 ? childMain : main + mainGapValue + childMain
204
- cross = max(cross, childCross)
205
- }
206
- flush()
207
-
208
- var crossOffset: CGFloat = 0
209
- for (lineItems, lineSizes, lineMain, lineCross) in lines {
210
- let styles = lineItems.map { childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:]) }
211
- let distributed = distributeMainAxisSizes(lineSizes, styles: styles, available: availableMain, lineMain: lineMain)
212
- let distributedMain = distributed.enumerated().reduce(0) { total, entry in
213
- total + (axis == .horizontal ? entry.element.width : entry.element.height) + mainMargins(styles[entry.offset])
214
- } + CGFloat(max(0, lineItems.count - 1)) * mainGapValue
215
- let free = max(0, availableMain - distributedMain)
216
- let (leading, between) = distribution(free: free, count: lineItems.count)
217
- var mainOffset = leading
218
- for (index, child) in lineItems.enumerated() {
219
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
220
- let size = distributed[index]
221
- let childCross = axis == .horizontal ? size.height : size.width
222
- let mainLeading = axis == .horizontal ? style.marginLeft : style.marginTop
223
- let mainTrailing = axis == .horizontal ? style.marginRight : style.marginBottom
224
- let crossLeading = axis == .horizontal ? style.marginTop : style.marginLeft
225
- let crossTrailing = axis == .horizontal ? style.marginBottom : style.marginRight
226
- let alignment = style.alignSelf ?? alignItems
227
- let crossSize: CGFloat = alignment == "stretch" && styleCrossDimension(style) == nil
228
- ? max(0, lineCross - crossLeading - crossTrailing) : childCross
229
- let crossPosition: CGFloat
230
- switch alignment {
231
- case "center": crossPosition = crossLeading + (lineCross - crossLeading - crossTrailing - crossSize) / 2
232
- case "flex-end": crossPosition = lineCross - crossTrailing - crossSize
233
- default: crossPosition = crossLeading
234
- }
235
- let frame: CGRect
236
- if axis == .horizontal {
237
- frame = CGRect(x: content.minX + mainOffset + mainLeading, y: content.minY + crossOffset + crossPosition, width: size.width, height: crossSize)
238
- } else {
239
- frame = CGRect(x: content.minX + crossOffset + crossPosition, y: content.minY + mainOffset + mainLeading, width: crossSize, height: size.height)
240
- }
241
- child.frame = frame.integral
242
- mainOffset += mainLeading + (axis == .horizontal ? size.width : size.height) + mainTrailing + mainGapValue + between
386
+ var mainMargins: CGFloat { mainLeading + mainTrailing }
387
+ var crossMargins: CGFloat { crossLeading + crossTrailing }
388
+ }
389
+
390
+ private func clamp(_ value: CGFloat, _ lower: CGFloat?, _ upper: CGFloat?) -> CGFloat {
391
+ var result = value
392
+ if let upper { result = min(result, upper) }
393
+ if let lower { result = max(result, lower) }
394
+ return max(0, result)
395
+ }
396
+
397
+ /// Children that take part in the flow: shown, not a modal overlay.
398
+ private var visibleChildren: [UIView] {
399
+ flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
400
+ }
401
+
402
+ /// The flexbox algorithm, single pass. `width`/`height` are this view's
403
+ /// outer size when its parent fixes it, nil when it sizes to its content
404
+ /// within `maxWidth`/`maxHeight`. Returns its outer size and, when
405
+ /// placing, every child's frame in its own coordinates.
406
+ private func layout(
407
+ width: CGFloat?,
408
+ height: CGFloat?,
409
+ maxWidth: CGFloat,
410
+ maxHeight: CGFloat,
411
+ place: Bool
412
+ ) -> (size: CGSize, frames: [(UIView, CGRect)]) {
413
+ let children = visibleChildren
414
+ let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
415
+ let horizontal = axis == .horizontal
416
+ let paddingWidth = padding.left + padding.right
417
+ let paddingHeight = padding.top + padding.bottom
418
+ let innerWidth = width.map { max(0, $0 - paddingWidth) }
419
+ let innerHeight = height.map { max(0, $0 - paddingHeight) }
420
+ let roomWidth = max(0, (width ?? maxWidth) - paddingWidth)
421
+ let roomHeight = max(0, (height ?? maxHeight) - paddingHeight)
422
+
423
+ var frames: [(UIView, CGRect)] = []
424
+ var contentSize: CGSize
425
+ if grid {
426
+ contentSize = layoutGrid(flow, innerWidth: innerWidth, roomWidth: roomWidth, innerHeight: innerHeight, place: place, frames: &frames)
427
+ } else {
428
+ contentSize = layoutFlex(
429
+ flow,
430
+ horizontal: horizontal,
431
+ innerWidth: innerWidth,
432
+ innerHeight: innerHeight,
433
+ roomWidth: roomWidth,
434
+ roomHeight: roomHeight,
435
+ place: place,
436
+ frames: &frames
437
+ )
438
+ }
439
+ let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
440
+
441
+ if place {
442
+ let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
443
+ for child in children where child.craftLayoutStyle.position == "absolute" {
444
+ let style = child.craftLayoutStyle
445
+ let fixedWidth = style.left != nil && style.right != nil
446
+ ? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
447
+ : style.width?.resolve(content.width)
448
+ let fixedHeight = style.top != nil && style.bottom != nil
449
+ ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
450
+ : style.height?.resolve(content.height)
451
+ let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
452
+ let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
453
+ let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
454
+ frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height)))
243
455
  }
244
- crossOffset += lineCross + crossGapValue
245
456
  }
457
+ return (size, frames)
458
+ }
459
+
460
+ private func layoutFlex(
461
+ _ children: [UIView],
462
+ horizontal: Bool,
463
+ innerWidth: CGFloat?,
464
+ innerHeight: CGFloat?,
465
+ roomWidth: CGFloat,
466
+ roomHeight: CGFloat,
467
+ place: Bool,
468
+ frames: inout [(UIView, CGRect)]
469
+ ) -> CGSize {
470
+ let definiteMain = horizontal ? innerWidth : innerHeight
471
+ let definiteCross = horizontal ? innerHeight : innerWidth
472
+ let roomMain = horizontal ? roomWidth : roomHeight
473
+ let roomCross = horizontal ? roomHeight : roomWidth
474
+ let mainGap = horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
475
+ let crossGap = horizontal ? (rowGap ?? 0) : (columnGap ?? 0)
476
+
477
+ func measure(_ view: UIView, main: CGFloat?, cross: CGFloat?, mainLimit: CGFloat, crossLimit: CGFloat) -> (main: CGFloat, cross: CGFloat) {
478
+ let size = horizontal
479
+ ? CraftNativeFlexLayout.measure(view, width: main, height: cross, maxWidth: mainLimit, maxHeight: crossLimit)
480
+ : CraftNativeFlexLayout.measure(view, width: cross, height: main, maxWidth: crossLimit, maxHeight: mainLimit)
481
+ return horizontal ? (size.width, size.height) : (size.height, size.width)
482
+ }
483
+
484
+ var items: [Item] = children.map { view in
485
+ let style = view.craftLayoutStyle
486
+ let mainLeading = horizontal ? style.marginLeft : style.marginTop
487
+ let mainTrailing = horizontal ? style.marginRight : style.marginBottom
488
+ let crossLeading = horizontal ? style.marginTop : style.marginLeft
489
+ let crossTrailing = horizontal ? style.marginBottom : style.marginRight
490
+ let align = style.alignSelf ?? alignItems
491
+ let explicitMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
492
+ let explicitCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
493
+ let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
494
+ let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
495
+ let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
496
+ let maxCross = (horizontal ? style.maxHeight : style.maxWidth)?.resolve(definiteCross)
497
+ var fixedCross = explicitCross
498
+ if fixedCross == nil, align == "stretch", !wrap, let definiteCross {
499
+ fixedCross = definiteCross - crossLeading - crossTrailing
500
+ }
501
+ fixedCross = fixedCross.map { clamp($0, minCross, maxCross) }
502
+ let defaultShrink: CGFloat
503
+ if view is UIScrollView {
504
+ defaultShrink = 1
505
+ } else if view is UILabel {
506
+ defaultShrink = horizontal ? 1 : 0
507
+ } else {
508
+ defaultShrink = 0
509
+ }
246
510
 
247
- for child in subviews where !child.isHidden && !(child is CraftNativeFlexSpacer) {
248
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
249
- guard style.position == "absolute" else { continue }
250
- let size = measuredSize(for: child, available: content.size, style: style)
251
- let width = style.left != nil && style.right != nil ? max(0, content.width - (style.left ?? 0) - (style.right ?? 0)) : size.width
252
- let height = style.top != nil && style.bottom != nil ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0)) : size.height
253
- let x = style.left ?? (style.right.map { content.width - $0 - width } ?? 0)
254
- let y = style.top ?? (style.bottom.map { content.height - $0 - height } ?? 0)
255
- child.frame = CGRect(x: content.minX + x, y: content.minY + y, width: width, height: height).integral
511
+ // The basis: a flex-basis, else the explicit size, else the
512
+ // content's size. A zero basis (`flex-1`) means "share the free
513
+ // space" only when there is a free space to share; in a container
514
+ // sized by its content, the content is the basis, as on the web.
515
+ let basis: CGFloat
516
+ if let flexBasis = style.flexBasis?.resolve(definiteMain), definiteMain != nil || flexBasis > 0 {
517
+ basis = flexBasis
518
+ } else if let explicitMain {
519
+ basis = explicitMain
520
+ } else {
521
+ basis = measure(
522
+ view,
523
+ main: nil,
524
+ cross: fixedCross,
525
+ mainLimit: max(0, (maxMain ?? roomMain) - mainLeading - mainTrailing),
526
+ crossLimit: max(0, (fixedCross ?? (maxCross ?? roomCross - crossLeading - crossTrailing)))
527
+ ).main
528
+ }
529
+ return Item(
530
+ view: view,
531
+ style: style,
532
+ align: align,
533
+ mainLeading: mainLeading,
534
+ mainTrailing: mainTrailing,
535
+ crossLeading: crossLeading,
536
+ crossTrailing: crossTrailing,
537
+ minMain: minMain,
538
+ maxMain: maxMain,
539
+ minCross: minCross,
540
+ maxCross: maxCross,
541
+ fixedCross: fixedCross,
542
+ grow: style.flexGrow,
543
+ shrink: style.flexShrink ?? defaultShrink,
544
+ basis: clamp(basis, minMain, maxMain)
545
+ )
256
546
  }
257
- }
258
547
 
259
- private func gridIntrinsicContentSize(_ children: [UIView]) -> CGSize {
260
- let columns = max(1, gridColumns)
261
- let horizontalGap = columnGap ?? gap
262
- let verticalGap = rowGap ?? gap
263
- var columnWidth: CGFloat = 0
264
- var rowHeights: [CGFloat] = []
265
- let flowChildren = children.filter {
266
- (childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:])).position != "absolute"
548
+ // Lines: one, or as many as wrapping needs.
549
+ var lines: [Range<Int>] = []
550
+ let lineLimit = definiteMain ?? roomMain
551
+ var start = 0
552
+ var used: CGFloat = 0
553
+ for index in items.indices {
554
+ let size = items[index].basis + items[index].mainMargins
555
+ let next = index == start ? size : used + mainGap + size
556
+ if wrap, index > start, next > lineLimit {
557
+ lines.append(start..<index)
558
+ start = index
559
+ used = size
560
+ } else {
561
+ used = next
562
+ }
267
563
  }
268
- for (index, child) in flowChildren.enumerated() {
269
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
270
- let size = measuredSize(for: child, available: CGSize(width: 10_000, height: 10_000), style: style)
271
- let row = index / columns
272
- while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
273
- columnWidth = max(columnWidth, size.width + style.marginLeft + style.marginRight)
274
- rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
564
+ if start < items.count { lines.append(start..<items.count) }
565
+
566
+ var lineMains: [CGFloat] = []
567
+ var lineCrosses: [CGFloat] = []
568
+ for line in lines {
569
+ let gaps = CGFloat(max(0, line.count - 1)) * mainGap
570
+ let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + gaps
571
+ // A row sized by its content is as wide as that content, up to the
572
+ // room it has; a column sized by its content is as tall as it is.
573
+ let lineMain = definiteMain ?? (horizontal ? min(hypothetical, roomMain) : hypothetical)
574
+ let free = lineMain - hypothetical
575
+ if free > 0 {
576
+ let total = line.reduce(0) { $0 + items[$1].grow }
577
+ for index in line {
578
+ let share = total > 0 ? free * items[index].grow / total : 0
579
+ items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
580
+ }
581
+ } else if free < 0 {
582
+ let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
583
+ for index in line {
584
+ let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
585
+ items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
586
+ }
587
+ } else {
588
+ for index in line { items[index].main = items[index].basis }
589
+ }
590
+
591
+ var lineCross: CGFloat = 0
592
+ for index in line {
593
+ let item = items[index]
594
+ if let fixedCross = item.fixedCross {
595
+ items[index].cross = fixedCross
596
+ } else {
597
+ let measured = measure(
598
+ item.view,
599
+ main: item.main,
600
+ cross: nil,
601
+ mainLimit: item.main,
602
+ crossLimit: max(0, (item.maxCross ?? roomCross) - item.crossMargins)
603
+ )
604
+ items[index].cross = clamp(measured.cross, item.minCross, item.maxCross)
605
+ }
606
+ lineCross = max(lineCross, items[index].cross + item.crossMargins)
607
+ }
608
+ // A single line is as thick as the container when the container's
609
+ // size is known: stretched children fill it, others align in it.
610
+ if !wrap, let definiteCross { lineCross = definiteCross }
611
+ for index in line where items[index].fixedCross == nil && items[index].align == "stretch" {
612
+ let item = items[index]
613
+ if case .some = (horizontal ? item.style.height : item.style.width) { continue }
614
+ items[index].cross = clamp(lineCross - item.crossMargins, item.minCross, item.maxCross)
615
+ }
616
+ lineMains.append(line.reduce(0) { $0 + items[$1].main + items[$1].mainMargins } + gaps)
617
+ lineCrosses.append(lineCross)
618
+ }
619
+
620
+ let contentMain = definiteMain ?? (lineMains.max() ?? 0)
621
+ let contentCross = definiteCross
622
+ ?? (lineCrosses.reduce(0, +) + CGFloat(max(0, lineCrosses.count - 1)) * crossGap)
623
+
624
+ if place {
625
+ var crossOffset: CGFloat = 0
626
+ for (lineIndex, line) in lines.enumerated() {
627
+ let free = max(0, contentMain - lineMains[lineIndex])
628
+ let count = line.count
629
+ let leading: CGFloat
630
+ let between: CGFloat
631
+ switch justifyContent {
632
+ case "center": leading = free / 2; between = 0
633
+ case "flex-end": leading = free; between = 0
634
+ case "space-between": leading = 0; between = count > 1 ? free / CGFloat(count - 1) : 0
635
+ case "space-around": leading = free / CGFloat(count * 2); between = free / CGFloat(count)
636
+ case "space-evenly": leading = free / CGFloat(count + 1); between = free / CGFloat(count + 1)
637
+ default: leading = 0; between = 0
638
+ }
639
+ let lineCross = lineCrosses[lineIndex]
640
+ var mainOffset = leading
641
+ for index in line {
642
+ let item = items[index]
643
+ let crossPosition: CGFloat
644
+ switch item.align {
645
+ case "center": crossPosition = item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
646
+ case "flex-end": crossPosition = lineCross - item.crossTrailing - item.cross
647
+ default: crossPosition = item.crossLeading
648
+ }
649
+ let mainPosition = mainOffset + item.mainLeading
650
+ let frame = horizontal
651
+ ? CGRect(x: padding.left + mainPosition, y: padding.top + crossOffset + crossPosition, width: item.main, height: item.cross)
652
+ : CGRect(x: padding.left + crossOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
653
+ frames.append((item.view, frame))
654
+ mainOffset += item.mainMargins + item.main + mainGap + between
655
+ }
656
+ crossOffset += lineCross + crossGap
657
+ }
275
658
  }
276
- let width = padding.left + padding.right + CGFloat(columns) * columnWidth + CGFloat(max(0, columns - 1)) * horizontalGap
277
- let height = padding.top + padding.bottom + rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
278
- return CGSize(width: width, height: height)
659
+ return horizontal ? CGSize(width: contentMain, height: contentCross) : CGSize(width: contentCross, height: contentMain)
279
660
  }
280
661
 
281
- private func layoutGrid(content: CGRect) {
282
- let children = subviews.filter { !$0.isHidden && !($0 is CraftNativeFlexSpacer) }
283
- let flowChildren = children.filter {
284
- (childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:])).position != "absolute"
285
- }
662
+ private func layoutGrid(
663
+ _ children: [UIView],
664
+ innerWidth: CGFloat?,
665
+ roomWidth: CGFloat,
666
+ innerHeight: CGFloat?,
667
+ place: Bool,
668
+ frames: inout [(UIView, CGRect)]
669
+ ) -> CGSize {
286
670
  let columns = max(1, gridColumns)
287
671
  let horizontalGap = columnGap ?? gap
288
672
  let verticalGap = rowGap ?? gap
289
- let cellWidth = max(0, (content.width - CGFloat(max(0, columns - 1)) * horizontalGap) / CGFloat(columns))
673
+ let gaps = CGFloat(columns - 1) * horizontalGap
674
+ var cellWidth: CGFloat
675
+ if let innerWidth {
676
+ cellWidth = max(0, (innerWidth - gaps) / CGFloat(columns))
677
+ } else {
678
+ // Sized by content: the widest cell, as long as the row fits.
679
+ let widest = children.map { child -> CGFloat in
680
+ let style = child.craftLayoutStyle
681
+ return CraftNativeFlexLayout.measure(child, width: style.width?.resolve(nil), height: nil, maxWidth: roomWidth, maxHeight: CraftNativeFlexLayout.unbounded).width
682
+ + style.marginLeft + style.marginRight
683
+ }.max() ?? 0
684
+ cellWidth = min(widest, max(0, (roomWidth - gaps) / CGFloat(columns)))
685
+ }
290
686
  var rowHeights: [CGFloat] = []
291
687
  var sizes: [CGSize] = []
292
- for (index, child) in flowChildren.enumerated() {
293
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
294
- let size = measuredSize(
295
- for: child,
296
- available: CGSize(width: max(0, cellWidth - style.marginLeft - style.marginRight), height: content.height),
297
- style: style
688
+ for (index, child) in children.enumerated() {
689
+ let style = child.craftLayoutStyle
690
+ let available = max(0, cellWidth - style.marginLeft - style.marginRight)
691
+ let align = style.alignSelf ?? alignItems
692
+ let fixedWidth = style.width?.resolve(cellWidth) ?? (align == "stretch" ? available : nil)
693
+ let size = CraftNativeFlexLayout.measure(
694
+ child,
695
+ width: fixedWidth.map { min($0, available) },
696
+ height: style.height?.resolve(nil),
697
+ maxWidth: available,
698
+ maxHeight: CraftNativeFlexLayout.unbounded
298
699
  )
299
700
  sizes.append(size)
300
701
  let row = index / columns
301
702
  while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
302
703
  rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
303
704
  }
304
- var rowTop = content.minY
305
- for (index, child) in flowChildren.enumerated() {
306
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
307
- let row = index / columns
308
- let column = index % columns
309
- let rowHeight = rowHeights[row]
310
- let size = sizes[index]
311
- let align = style.alignSelf ?? alignItems
312
- let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
313
- let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
314
- let width = align == "stretch" && style.width == nil ? availableWidth : min(availableWidth, size.width)
315
- let height = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
316
- let x = content.minX + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
317
- + (align == "center" ? (availableWidth - width) / 2 : align == "flex-end" ? availableWidth - width : 0)
318
- let y = rowTop + style.marginTop
319
- + (align == "center" ? (availableHeight - height) / 2 : align == "flex-end" ? availableHeight - height : 0)
320
- child.frame = CGRect(x: x, y: y, width: width, height: height).integral
321
- if column == columns - 1 || index == flowChildren.count - 1 { rowTop += rowHeight + verticalGap }
322
- }
323
- for child in children {
324
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
325
- guard style.position == "absolute" else { continue }
326
- let size = measuredSize(for: child, available: content.size, style: style)
327
- let width = style.left != nil && style.right != nil ? max(0, content.width - (style.left ?? 0) - (style.right ?? 0)) : size.width
328
- let height = style.top != nil && style.bottom != nil ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0)) : size.height
329
- let x = style.left ?? (style.right.map { content.width - $0 - width } ?? 0)
330
- let y = style.top ?? (style.bottom.map { content.height - $0 - height } ?? 0)
331
- child.frame = CGRect(x: content.minX + x, y: content.minY + y, width: width, height: height).integral
332
- }
333
- }
334
-
335
- private var mainGapValue: CGFloat { axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap) }
336
- private var crossGapValue: CGFloat { axis == .horizontal ? (rowGap ?? 0) : (columnGap ?? 0) }
337
-
338
- private func mainMargins(_ style: CraftNativeLayoutStyle) -> CGFloat {
339
- axis == .horizontal ? style.marginLeft + style.marginRight : style.marginTop + style.marginBottom
340
- }
341
-
342
- private func crossMargins(_ style: CraftNativeLayoutStyle) -> CGFloat {
343
- axis == .horizontal ? style.marginTop + style.marginBottom : style.marginLeft + style.marginRight
344
- }
345
-
346
- private func styleCrossDimension(_ style: CraftNativeLayoutStyle) -> CGFloat? {
347
- axis == .horizontal ? style.height : style.width
348
- }
349
-
350
- private func measuredSize(for view: UIView, available: CGSize, style: CraftNativeLayoutStyle? = nil) -> CGSize {
351
- let style = style ?? childStyles[ObjectIdentifier(view)] ?? CraftNativeLayoutStyle([:])
352
- let intrinsic = view.intrinsicContentSize
353
- let fitted = view.sizeThatFits(available)
354
- let width = (axis == .horizontal ? style.flexBasis : nil) ?? style.width ?? (intrinsic.width > 0 && intrinsic.width != UIView.noIntrinsicMetric ? intrinsic.width : max(0, fitted.width))
355
- let height = (axis == .vertical ? style.flexBasis : nil) ?? style.height ?? (intrinsic.height > 0 && intrinsic.height != UIView.noIntrinsicMetric ? intrinsic.height : max(0, fitted.height))
356
- return CGSize(width: clamp(width, min: style.minWidth, max: style.maxWidth), height: clamp(height, min: style.minHeight, max: style.maxHeight))
357
- }
358
-
359
- private func distributeMainAxisSizes(
360
- _ sizes: [CGSize],
361
- styles: [CraftNativeLayoutStyle],
362
- available: CGFloat,
363
- lineMain: CGFloat
364
- ) -> [CGSize] {
365
- let free = available - lineMain
366
- let factors = free >= 0 ? styles.map(\.flexGrow) : styles.map(\.flexShrink)
367
- let total = factors.reduce(0, +)
368
- guard total > 0, free != 0 else { return sizes }
369
- return sizes.enumerated().map { index, size in
370
- let delta = free * factors[index] / total
371
- let style = styles[index]
372
- if axis == .horizontal {
373
- return CGSize(width: clamp(max(0, size.width + delta), min: style.minWidth, max: style.maxWidth), height: size.height)
705
+ let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
706
+ let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
707
+ if place {
708
+ var rowTop = padding.top
709
+ for (index, child) in children.enumerated() {
710
+ let style = child.craftLayoutStyle
711
+ let row = index / columns
712
+ let column = index % columns
713
+ let rowHeight = rowHeights[row]
714
+ let size = sizes[index]
715
+ let align = style.alignSelf ?? alignItems
716
+ let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
717
+ let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
718
+ let childWidth = min(availableWidth, size.width)
719
+ let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
720
+ let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
721
+ + (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
722
+ let y = rowTop + style.marginTop
723
+ + (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
724
+ frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
725
+ if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + verticalGap }
374
726
  }
375
- return CGSize(width: size.width, height: clamp(max(0, size.height + delta), min: style.minHeight, max: style.maxHeight))
376
727
  }
728
+ return CGSize(width: width, height: innerHeight ?? height)
377
729
  }
730
+ }
378
731
 
379
- private func clamp(_ value: CGFloat, min lower: CGFloat?, max upper: CGFloat?) -> CGFloat {
380
- var result = value
381
- if let lower { result = max(result, lower) }
382
- if let upper { result = min(result, upper) }
383
- return result
732
+ private final class CraftNativeModalView: UIView {
733
+ let contentStack = CraftNativeFlowView()
734
+ private let blocker = UIView()
735
+ private var renderedVisible = false
736
+ var onRequestClose: (() -> Void)?
737
+
738
+ var transparent = false {
739
+ didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
384
740
  }
385
741
 
386
- private func distribution(free: CGFloat, count: Int) -> (CGFloat, CGFloat) {
387
- guard count > 0 else { return (0, 0) }
388
- switch justifyContent {
389
- case "center": return (free / 2, 0)
390
- case "flex-end": return (free, 0)
391
- case "space-between": return (0, count > 1 ? free / CGFloat(count - 1) : 0)
392
- case "space-around": return (free / CGFloat(count * 2), free / CGFloat(count))
393
- case "space-evenly": return (free / CGFloat(count + 1), free / CGFloat(count + 1))
394
- default: return (0, 0)
742
+ override init(frame: CGRect) {
743
+ super.init(frame: frame)
744
+ isUserInteractionEnabled = true
745
+ blocker.isUserInteractionEnabled = true
746
+ blocker.backgroundColor = UIColor.black.withAlphaComponent(0.32)
747
+ blocker.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blockerTapped)))
748
+ addSubview(blocker)
749
+ contentStack.backgroundColor = .clear
750
+ addSubview(contentStack)
751
+ }
752
+
753
+ required init?(coder: NSCoder) { nil }
754
+
755
+ func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
756
+ guard renderedVisible != visible || isHidden != !visible else { return }
757
+ renderedVisible = visible
758
+ guard visible else {
759
+ isHidden = true
760
+ alpha = 0
761
+ return
762
+ }
763
+ isHidden = false
764
+ if animationType == "none" {
765
+ alpha = opacity
766
+ } else {
767
+ alpha = 0
768
+ UIView.animate(withDuration: animationType == "slide" ? 0.24 : 0.18) { self.alpha = opacity }
395
769
  }
396
770
  }
771
+
772
+ @objc private func blockerTapped() { onRequestClose?() }
773
+
774
+ override func layoutSubviews() {
775
+ super.layoutSubviews()
776
+ blocker.frame = bounds
777
+ contentStack.frame = bounds
778
+ }
397
779
  }
398
780
 
399
- private final class CraftNativeScrollView: UIScrollView {
781
+ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
400
782
  let contentStack = CraftNativeFlowView()
401
- private var crossAxisConstraint: NSLayoutConstraint?
783
+ private let keyboardTapGesture = UITapGestureRecognizer()
784
+ private var keyboardShouldPersistTaps = "never"
785
+ private var contentNeedsLayout = true
786
+ private var laidOutSize: CGSize = .zero
787
+ private let pullToRefresh = UIRefreshControl()
788
+ private var refreshHandler: (() -> Void)?
402
789
 
403
790
  override init(frame: CGRect) {
404
791
  super.init(frame: frame)
405
- contentStack.translatesAutoresizingMaskIntoConstraints = false
792
+ keyboardTapGesture.cancelsTouchesInView = false
793
+ keyboardTapGesture.delegate = self
794
+ keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
795
+ addGestureRecognizer(keyboardTapGesture)
796
+ pullToRefresh.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
406
797
  addSubview(contentStack)
407
- NSLayoutConstraint.activate([
408
- contentStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
409
- contentStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
410
- contentStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
411
- contentStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor)
412
- ])
413
798
  setAxis(.vertical)
414
799
  }
415
800
 
416
801
  required init?(coder: NSCoder) { nil }
417
802
 
803
+ var isVertical: Bool { contentStack.axis == .vertical }
804
+
805
+ /// Lays the content out again on the next pass. Scrolling alone does not:
806
+ /// a scroll view lays out on every frame of a scroll.
807
+ func setContentNeedsLayout() {
808
+ contentNeedsLayout = true
809
+ setNeedsLayout()
810
+ }
811
+
812
+ /// The size of the content laid out across the visible width (or, sideways,
813
+ /// height), which is also the scroll view's own size when nothing fixes it.
814
+ func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
815
+ if isVertical {
816
+ let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
817
+ return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
818
+ }
819
+ let contentHeight = height.map { max(0, $0 - adjustedContentInset.top - adjustedContentInset.bottom) }
820
+ return contentStack.measure(width: nil, height: contentHeight, maxWidth: CraftNativeFlexLayout.unbounded, maxHeight: contentHeight ?? CraftNativeFlexLayout.unbounded)
821
+ }
822
+
823
+ func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
824
+ if isVertical {
825
+ let resolvedWidth = width ?? maxWidth
826
+ return CGSize(width: resolvedWidth, height: height ?? contentFitting(width: resolvedWidth, height: nil).height)
827
+ }
828
+ let content = contentFitting(width: nil, height: height)
829
+ return CGSize(width: width ?? min(content.width, maxWidth), height: height ?? content.height)
830
+ }
831
+
832
+ override func layoutSubviews() {
833
+ super.layoutSubviews()
834
+ craftApplyCornerRadius()
835
+ guard contentNeedsLayout || laidOutSize != bounds.size else { return }
836
+ contentNeedsLayout = false
837
+ laidOutSize = bounds.size
838
+ var size = contentFitting(width: bounds.width, height: bounds.height)
839
+ if isVertical {
840
+ size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
841
+ } else {
842
+ size.height = max(0, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
843
+ }
844
+ let frame = CGRect(origin: .zero, size: size)
845
+ if contentStack.frame != frame { contentStack.frame = frame }
846
+ if contentSize != size { contentSize = size }
847
+ }
848
+
849
+ override func adjustedContentInsetDidChange() {
850
+ super.adjustedContentInsetDidChange()
851
+ setContentNeedsLayout()
852
+ }
853
+
854
+ private var refreshingProp = false
855
+
856
+ /// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
857
+ /// as in React Native. The spinner shows while `refreshing` is true; a
858
+ /// pull that the screen does not answer with `refreshing` true ends at
859
+ /// once instead of spinning forever.
860
+ func setRefreshHandler(_ handler: (() -> Void)?, refreshing: Bool) {
861
+ refreshHandler = handler
862
+ refreshingProp = refreshing
863
+ if handler == nil {
864
+ if pullToRefresh.isRefreshing { pullToRefresh.endRefreshing() }
865
+ if refreshControl === pullToRefresh { refreshControl = nil }
866
+ return
867
+ }
868
+ if refreshControl !== pullToRefresh { refreshControl = pullToRefresh }
869
+ if refreshing, !pullToRefresh.isRefreshing {
870
+ pullToRefresh.beginRefreshing()
871
+ } else if !refreshing, pullToRefresh.isRefreshing {
872
+ pullToRefresh.endRefreshing()
873
+ }
874
+ }
875
+
876
+ var isRefreshingNow: Bool { pullToRefresh.isRefreshing }
877
+
878
+ @objc private func refreshPulled() {
879
+ refreshHandler?()
880
+ // The handler's synchronous render has landed by now.
881
+ DispatchQueue.main.async { [weak self] in
882
+ guard let self, !self.refreshingProp, self.pullToRefresh.isRefreshing else { return }
883
+ self.pullToRefresh.endRefreshing()
884
+ }
885
+ }
886
+
887
+ func setKeyboardShouldPersistTaps(_ value: Any?) {
888
+ let requested = value as? String
889
+ keyboardShouldPersistTaps = ["always", "handled", "never"].contains(requested) ? requested! : "never"
890
+ keyboardTapGesture.isEnabled = keyboardShouldPersistTaps != "always"
891
+ }
892
+
893
+ @objc private func keyboardTap() {
894
+ guard keyboardShouldPersistTaps != "always" else { return }
895
+ if keyboardShouldPersistTaps == "handled" {
896
+ let point = keyboardTapGesture.location(in: self)
897
+ var hit = hitTest(point, with: nil)
898
+ while let view = hit, view !== self {
899
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
900
+ hit = view.superview
901
+ }
902
+ }
903
+ window?.endEditing(true)
904
+ }
905
+
906
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
907
+ true
908
+ }
909
+
418
910
  func setAxis(_ axis: NSLayoutConstraint.Axis) {
911
+ if contentStack.axis != axis { setContentNeedsLayout() }
419
912
  contentStack.axis = axis
420
- crossAxisConstraint?.isActive = false
421
- crossAxisConstraint = axis == .vertical
422
- ? contentStack.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor)
423
- : contentStack.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor)
424
- crossAxisConstraint?.isActive = true
425
913
  alwaysBounceVertical = axis == .vertical
426
914
  alwaysBounceHorizontal = axis == .horizontal
427
915
  }
428
916
  }
429
917
 
918
+ /// Text with padding of its own: the padding is inside the label, around
919
+ /// the text, and counts in its measured size.
920
+ private final class CraftNativeLabel: UILabel {
921
+ var insets = UIEdgeInsets.zero {
922
+ didSet {
923
+ guard insets != oldValue else { return }
924
+ invalidateIntrinsicContentSize()
925
+ setNeedsDisplay()
926
+ }
927
+ }
928
+
929
+ override func drawText(in rect: CGRect) {
930
+ super.drawText(in: rect.inset(by: insets))
931
+ }
932
+
933
+ override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect {
934
+ guard insets != .zero else { return super.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines) }
935
+ let inner = super.textRect(forBounds: bounds.inset(by: insets), limitedToNumberOfLines: numberOfLines)
936
+ return CGRect(
937
+ x: inner.minX - insets.left,
938
+ y: inner.minY - insets.top,
939
+ width: inner.width + insets.left + insets.right,
940
+ height: inner.height + insets.top + insets.bottom
941
+ )
942
+ }
943
+
944
+ override func layoutSubviews() {
945
+ super.layoutSubviews()
946
+ craftApplyCornerRadius()
947
+ }
948
+ }
949
+
430
950
  private final class CraftNativeFlexSpacer: UIView {}
431
951
 
952
+ /// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
953
+ /// when the style gives it no size at all.
954
+ private final class CraftNativeIconView: UIImageView {
955
+ var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
956
+
957
+ override var intrinsicContentSize: CGSize {
958
+ if let defaultBox { return CGSize(width: defaultBox, height: defaultBox) }
959
+ return super.intrinsicContentSize
960
+ }
961
+ }
962
+
432
963
  private final class CraftNativeTextView: UITextView {
433
964
  private let placeholderLabel = UILabel()
434
965
  var placeholder: String? {
@@ -482,7 +1013,7 @@ private final class CraftNativeTextView: UITextView {
482
1013
  }
483
1014
  }
484
1015
 
485
- final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate {
1016
+ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
486
1017
  private final class RenderedNode {
487
1018
  let identity: String
488
1019
  let type: String
@@ -506,6 +1037,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
506
1037
  private let jsContext = JSContext()!
507
1038
  private let rootStack = CraftNativeFlowView()
508
1039
  private var handlers: [ObjectIdentifier: String] = [:]
1040
+ private var pressInHandlers: [ObjectIdentifier: String] = [:]
1041
+ private var pressOutHandlers: [ObjectIdentifier: String] = [:]
509
1042
  private var focusHandlers: [ObjectIdentifier: String] = [:]
510
1043
  private var blurHandlers: [ObjectIdentifier: String] = [:]
511
1044
  private var endEditingHandlers: [ObjectIdentifier: String] = [:]
@@ -521,10 +1054,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
521
1054
  private var scrollMomentumEndHandlers: [ObjectIdentifier: String] = [:]
522
1055
  private var sliderCompleteHandlers: [ObjectIdentifier: String] = [:]
523
1056
  private var sliderSteps: [ObjectIdentifier: Float] = [:]
1057
+ private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
1058
+ private var modalVisibility: [ObjectIdentifier: Bool] = [:]
524
1059
  private var layoutHandlers: [ObjectIdentifier: String] = [:]
525
1060
  private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
526
1061
  private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
527
1062
  private var longPressRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
1063
+ private var pressFeedbackRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
528
1064
  private var imageSources: [ObjectIdentifier: String] = [:]
529
1065
  private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
530
1066
  private var imageErrors: [ObjectIdentifier: String] = [:]
@@ -541,8 +1077,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
541
1077
  private let capabilityScope = UUID().uuidString
542
1078
  private var pendingCapabilityRequests = Set<String>()
543
1079
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
1080
+ private var pendingTimers: [Int: DispatchWorkItem] = [:]
544
1081
  private var lifecycleObservers: [NSObjectProtocol] = []
545
1082
  private var deepLinkListener: UUID?
1083
+ private var keyboardSafeAreaInset: CGFloat = 0
1084
+ /// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
1085
+ /// app's native screens: told of the first frame, of a first frame that
1086
+ /// could not be drawn, and of `craft.navigation.open(path)`.
1087
+ weak var hybridEvents: CraftHybridScreenEvents?
1088
+ private var hybridRendered = false
546
1089
 
547
1090
  init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
548
1091
  self.config = config
@@ -558,6 +1101,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
558
1101
  deinit {
559
1102
  imageTasks.values.forEach { $0.cancel() }
560
1103
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
1104
+ pendingTimers.values.forEach { $0.cancel() }
561
1105
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
562
1106
  lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
563
1107
  if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
@@ -571,8 +1115,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
571
1115
  rootStack.alignItems = "stretch"
572
1116
  rootStack.translatesAutoresizingMaskIntoConstraints = false
573
1117
  view.addSubview(rootStack)
1118
+ rootTopToSafeArea = rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
1119
+ rootTopToView = rootStack.topAnchor.constraint(equalTo: view.topAnchor)
574
1120
  NSLayoutConstraint.activate([
575
- rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
1121
+ rootTopToSafeArea!,
576
1122
  rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
577
1123
  rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
578
1124
  rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
@@ -586,10 +1132,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
586
1132
  super.traitCollectionDidChange(previousTraitCollection)
587
1133
  guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
588
1134
  refreshTraitDefaults()
1135
+ send(type: "APPEARANCE", payload: ["colorScheme": colorScheme])
589
1136
  }
590
1137
 
591
1138
  override func viewWillAppear(_ animated: Bool) {
592
1139
  super.viewWillAppear(animated)
1140
+ if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
1141
+ navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
1142
+ } else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
1143
+ navigationController?.viewControllers.first !== self {
1144
+ // A screen that did not ask for a hidden bar shows one, even after
1145
+ // a screen that hid its own.
1146
+ navigationController?.setNavigationBarHidden(false, animated: animated)
1147
+ }
593
1148
  refreshTraitDefaults()
594
1149
  }
595
1150
 
@@ -619,13 +1174,34 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
619
1174
  jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
620
1175
  }
621
1176
  jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
622
- jsContext.exceptionHandler = { _, exception in
623
- NSLog("[craft native] JavaScript exception: %@", exception?.toString() ?? "unknown")
1177
+ jsContext.exceptionHandler = { [weak self] context, exception in
1178
+ let message = exception?.toString() ?? "unknown"
1179
+ let stack = exception?.objectForKeyedSubscript("stack")?.toString() ?? ""
1180
+ CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
1181
+ self?.hybridFailed(message)
624
1182
  }
625
1183
  let postMessage: @convention(block) (String) -> Void = { [weak self] json in
626
1184
  self?.receive(json)
627
1185
  }
628
1186
  jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
1187
+ // JavaScriptCore has no timers of its own. The callbacks stay in
1188
+ // JavaScript; native only owns the main-queue deadline, so route
1189
+ // teardown cancels every pending timer with the controller.
1190
+ let scheduleTimer: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
1191
+ guard let self = self else { return }
1192
+ let work = DispatchWorkItem { [weak self] in
1193
+ guard let self = self, self.pendingTimers.removeValue(forKey: id) != nil else { return }
1194
+ self.jsContext.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
1195
+ }
1196
+ self.pendingTimers[id] = work
1197
+ let milliseconds = delay.isFinite ? Int(min(max(delay, 0), 2_147_483_647)) : 0
1198
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
1199
+ }
1200
+ let cancelTimer: @convention(block) (Int) -> Void = { [weak self] id in
1201
+ self?.pendingTimers.removeValue(forKey: id)?.cancel()
1202
+ }
1203
+ jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
1204
+ jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
629
1205
  let capabilities = CraftNativeActions.capabilities(config: config)
630
1206
  let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
631
1207
  let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
@@ -638,12 +1214,185 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
638
1214
  capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
639
1215
  capabilities: \(capabilityJSON),
640
1216
  initialAppState: "\(CraftNativeActions.currentAppState())",
1217
+ colorScheme: "\(colorScheme)",
641
1218
  postMessage: function(message) { craftNativePostMessage(message); },
642
1219
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
643
1220
  };
644
- globalThis.console = {
645
- log: function() {}, warn: function() {}, error: function() {}
646
- };
1221
+ """)
1222
+ installHostAPIs()
1223
+ jsContext.evaluateScript("""
1224
+ if (typeof globalThis.setTimeout !== 'function') {
1225
+ (function() {
1226
+ var timers = new Map();
1227
+ var nextTimer = 0;
1228
+ globalThis.setTimeout = function(callback, delay) {
1229
+ var args = Array.prototype.slice.call(arguments, 2);
1230
+ var id = ++nextTimer;
1231
+ timers.set(id, function() {
1232
+ if (typeof callback === 'function') callback.apply(undefined, args);
1233
+ });
1234
+ craftNativeScheduleTimer(id, Number(delay) || 0);
1235
+ return id;
1236
+ };
1237
+ globalThis.clearTimeout = function(id) {
1238
+ if (timers.delete(id)) craftNativeCancelTimer(id);
1239
+ };
1240
+ globalThis.__craftNativeFireTimer = function(id) {
1241
+ var callback = timers.get(id);
1242
+ if (!callback) return;
1243
+ timers.delete(id);
1244
+ callback();
1245
+ };
1246
+ })();
1247
+ }
1248
+ """)
1249
+ }
1250
+
1251
+ /// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
1252
+ /// the window's appearance once there is one, else the app's setting.
1253
+ private var colorScheme: String {
1254
+ let traits = [view.window?.traitCollection, CraftPresenter.keyWindow()?.traitCollection, traitCollection]
1255
+ if let style = traits.compactMap({ $0 }).first(where: { $0.userInterfaceStyle != .unspecified })?.userInterfaceStyle {
1256
+ return style == .dark ? "dark" : "light"
1257
+ }
1258
+ return config.appearance == "dark" ? "dark" : "light"
1259
+ }
1260
+
1261
+ /// The screen's name as its script knows it: the route the host opened,
1262
+ /// else the one a route bundle selected for itself.
1263
+ private func screenName(in context: JSContext? = nil) -> String {
1264
+ if let routeName, !routeName.isEmpty { return routeName }
1265
+ if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
1266
+ return selected.toString()
1267
+ }
1268
+ return "screen"
1269
+ }
1270
+
1271
+ /// Host functions every screen script can call synchronously, installed
1272
+ /// before the script runs (contract sections 3, 4 and 5):
1273
+ ///
1274
+ /// - `console.*` writes to the unified log, subsystem `craft.native`,
1275
+ /// category the screen's name.
1276
+ /// - `craft.storage.getSync(key)` / `setSync(key, value)`: the store the
1277
+ /// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
1278
+ /// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
1279
+ /// promise): `Application Support/craft-snapshots/<name>.json`.
1280
+ /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
1281
+ /// `craft.secureStorage.set` wrote.
1282
+ /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
1283
+ /// rightButtons })`; a right button's tap is the event `navButton`.
1284
+ ///
1285
+ /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
1286
+ /// these: each namespace is a property whose setter adds them back to the
1287
+ /// object it is given.
1288
+ private func installHostAPIs() {
1289
+ let log: @convention(block) (String, String, String) -> Void = { level, category, message in
1290
+ CraftNativeConsole.write(level, category: category, message: message)
1291
+ }
1292
+ let storageGet: @convention(block) (String) -> String? = { key in
1293
+ CraftNativeActions.storageJSON(forKey: key)
1294
+ }
1295
+ let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
1296
+ CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
1297
+ }
1298
+ let snapshotGet: @convention(block) (String) -> String? = { name in
1299
+ CraftSnapshots.read(name)
1300
+ }
1301
+ let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
1302
+ CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
1303
+ done.call(withArguments: [ok])
1304
+ }
1305
+ }
1306
+ let config = self.config
1307
+ let secureGet: @convention(block) (String) -> String? = { key in
1308
+ config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
1309
+ }
1310
+ let setOptions: @convention(block) (String) -> Void = { [weak self] json in
1311
+ guard let data = json.data(using: .utf8),
1312
+ let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
1313
+ self?.applyNavigationOptions(options)
1314
+ }
1315
+ jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
1316
+ jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
1317
+ jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
1318
+ jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
1319
+ jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
1320
+ jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
1321
+ jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
1322
+ jsContext.evaluateScript("""
1323
+ (function() {
1324
+ function text(value) {
1325
+ if (typeof value === 'string') return value;
1326
+ if (value === undefined) return 'undefined';
1327
+ if (value instanceof Error) return value.stack ? value.message + '\\n' + value.stack : String(value);
1328
+ try { var json = JSON.stringify(value); return json === undefined ? String(value) : json; }
1329
+ catch (error) { return String(value); }
1330
+ }
1331
+ function screen() {
1332
+ var route = globalThis.__stxNativeRoute;
1333
+ return typeof route === 'string' && route ? route : 'screen';
1334
+ }
1335
+ function writer(level) {
1336
+ return function() {
1337
+ craftNativeLog(level, screen(), Array.prototype.map.call(arguments, text).join(' '));
1338
+ };
1339
+ }
1340
+ globalThis.console = {
1341
+ log: writer('log'), info: writer('info'), debug: writer('debug'), trace: writer('debug'),
1342
+ warn: writer('warn'), error: writer('error')
1343
+ };
1344
+
1345
+ function parsed(json) {
1346
+ if (json === null || json === undefined) return null;
1347
+ try { return JSON.parse(json); } catch (error) { return null; }
1348
+ }
1349
+ function encoded(value) {
1350
+ return value === null || value === undefined ? null : JSON.stringify(value);
1351
+ }
1352
+ var craft = globalThis.craft = globalThis.craft || {};
1353
+ function keep(name, extras) {
1354
+ var current = {};
1355
+ function adopt(next) {
1356
+ current = next && typeof next === 'object' ? next : {};
1357
+ Object.keys(extras).forEach(function(key) {
1358
+ if (!(key in current)) current[key] = extras[key];
1359
+ });
1360
+ }
1361
+ adopt(craft[name]);
1362
+ Object.defineProperty(craft, name, {
1363
+ configurable: true,
1364
+ enumerable: true,
1365
+ get: function() { return current; },
1366
+ set: adopt
1367
+ });
1368
+ }
1369
+ keep('storage', {
1370
+ getSync: function(key) { return parsed(craftNativeStorageGet(String(key))); },
1371
+ setSync: function(key, value) {
1372
+ if (!craftNativeStorageSet(String(key), encoded(value))) throw new Error('craft.storage.setSync could not store ' + key);
1373
+ }
1374
+ });
1375
+ keep('snapshots', {
1376
+ get: function(name) { return parsed(craftNativeSnapshotGet(String(name))); },
1377
+ set: function(name, value) {
1378
+ return new Promise(function(resolve, reject) {
1379
+ craftNativeSnapshotSet(String(name), encoded(value), function(ok) {
1380
+ if (ok) resolve();
1381
+ else reject(new Error('craft.snapshots.set could not write ' + name));
1382
+ });
1383
+ });
1384
+ }
1385
+ });
1386
+ keep('secureStorage', {
1387
+ getSync: function(key) {
1388
+ var value = craftNativeSecureGet(String(key));
1389
+ return value === undefined ? null : value;
1390
+ }
1391
+ });
1392
+ keep('navigation', {
1393
+ setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
1394
+ });
1395
+ })();
647
1396
  """)
648
1397
  }
649
1398
 
@@ -651,13 +1400,108 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
651
1400
  guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
652
1401
  ?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
653
1402
  let script = try? String(contentsOf: url, encoding: .utf8) else {
654
- showError("Missing dist/native-screen.js. Compile a .stx screen with stx-native first.")
1403
+ showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
655
1404
  return
656
1405
  }
657
1406
  jsContext.evaluateScript(script)
658
- if routeName == nil, let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute")?.toString() {
659
- navigationItem.title = selected
1407
+ // A bundle that names no route leaves `__stxNativeRoute` undefined;
1408
+ // its title is the app's, not the word "undefined". A title the
1409
+ // script set with `navigation.setOptions` wins over both.
1410
+ if routeName == nil, !titleFromOptions,
1411
+ let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
1412
+ let name = selected.toString(), !name.isEmpty {
1413
+ navigationItem.title = name
1414
+ }
1415
+ }
1416
+
1417
+ // MARK: Navigation bar options
1418
+
1419
+ private var titleFromOptions = false
1420
+ private var rootTopToSafeArea: NSLayoutConstraint?
1421
+ private var rootTopToView: NSLayoutConstraint?
1422
+ private var navigationBarHidden: Bool?
1423
+ private var prefersLargeTitle: Bool?
1424
+ private weak var trackedScrollView: UIScrollView?
1425
+
1426
+ /// `craft.navigation.setOptions`: this screen's bar. Every key is
1427
+ /// optional and only the keys given change.
1428
+ private func applyNavigationOptions(_ options: [String: Any]) {
1429
+ if options.keys.contains("title") {
1430
+ navigationItem.title = options["title"] as? String
1431
+ titleFromOptions = true
1432
+ }
1433
+ if options.keys.contains("backTitle") {
1434
+ navigationItem.backButtonTitle = options["backTitle"] as? String
1435
+ }
1436
+ if let largeTitle = options["largeTitle"] as? Bool {
1437
+ prefersLargeTitle = largeTitle
1438
+ navigationItem.largeTitleDisplayMode = largeTitle ? .always : .never
1439
+ if largeTitle { navigationController?.navigationBar.prefersLargeTitles = true }
1440
+ trackContentScrollView()
1441
+ }
1442
+ if let hidden = options["hidden"] as? Bool {
1443
+ navigationBarHidden = hidden
1444
+ if navigationController?.topViewController === self {
1445
+ navigationController?.setNavigationBarHidden(hidden, animated: view.window != nil)
1446
+ }
1447
+ }
1448
+ if options.keys.contains("rightButtons") {
1449
+ let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
1450
+ navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
1451
+ }
1452
+ }
1453
+
1454
+ /// A bar button from `{ id, symbol | title }`. Its tap is the event
1455
+ /// `navButton` with `{ id }`.
1456
+ private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
1457
+ guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
1458
+ let action = UIAction { [weak self] _ in
1459
+ self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": id]])
1460
+ }
1461
+ let item: UIBarButtonItem
1462
+ if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
1463
+ item = UIBarButtonItem(image: image, primaryAction: action)
1464
+ } else if let title = spec["title"] as? String {
1465
+ item = UIBarButtonItem(title: title, primaryAction: action)
1466
+ } else {
1467
+ return nil
660
1468
  }
1469
+ item.accessibilityIdentifier = "nav-\(id)"
1470
+ if let label = (spec["accessibilityLabel"] as? String) ?? (spec["title"] as? String) {
1471
+ item.accessibilityLabel = label
1472
+ } else {
1473
+ item.accessibilityLabel = id
1474
+ }
1475
+ if let tint = (spec["color"] as? String).flatMap({ UIColor(hex: $0) }) { item.tintColor = tint }
1476
+ return item
1477
+ }
1478
+
1479
+ /// The first vertical scroll view of the screen drives the bar: a large
1480
+ /// title collapses as it scrolls, and the bar's background follows it.
1481
+ private func trackContentScrollView() {
1482
+ guard let renderedRoot else { return }
1483
+ func find(_ node: RenderedNode) -> UIScrollView? {
1484
+ if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical, scroll.isScrollEnabled { return scroll }
1485
+ if let list = node.view as? CraftNativeFlatList, list.isScrollEnabled,
1486
+ (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { return list }
1487
+ for child in node.children {
1488
+ if let match = find(child) { return match }
1489
+ }
1490
+ return nil
1491
+ }
1492
+ let scroll = find(renderedRoot)
1493
+ // Under a large title the screen runs edge to edge, as UIKit's own
1494
+ // screens do: the scroll view starts under the bar and insets its
1495
+ // content by it, so the title collapses and pull to refresh draws
1496
+ // in the bar's space. Other screens stay inside the safe area.
1497
+ let edgeToEdge = scroll != nil && prefersLargeTitle == true
1498
+ if rootTopToView?.isActive != edgeToEdge {
1499
+ rootTopToSafeArea?.isActive = !edgeToEdge
1500
+ rootTopToView?.isActive = edgeToEdge
1501
+ }
1502
+ guard scroll !== trackedScrollView else { return }
1503
+ trackedScrollView = scroll
1504
+ setContentScrollView(scroll, for: .top)
661
1505
  }
662
1506
 
663
1507
  private func observeNativeEvents() {
@@ -671,6 +1515,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
671
1515
  observe(UIApplication.didBecomeActiveNotification, state: "active")
672
1516
  observe(UIApplication.willResignActiveNotification, state: "inactive")
673
1517
  observe(UIApplication.didEnterBackgroundNotification, state: "background")
1518
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
1519
+ forName: UIResponder.keyboardWillChangeFrameNotification,
1520
+ object: nil,
1521
+ queue: .main
1522
+ ) { [weak self] notification in
1523
+ self?.applyKeyboardSafeArea(notification)
1524
+ })
1525
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
1526
+ forName: UIResponder.keyboardWillHideNotification,
1527
+ object: nil,
1528
+ queue: .main
1529
+ ) { [weak self] notification in
1530
+ self?.applyKeyboardSafeArea(notification, hidden: true)
1531
+ })
674
1532
  sendAppState(CraftNativeActions.currentAppState())
675
1533
  deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
676
1534
  guard let self = self, self.navigationController?.topViewController === self else { return }
@@ -680,6 +1538,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
680
1538
  }
681
1539
  }
682
1540
 
1541
+ private func applyKeyboardSafeArea(_ notification: Notification, hidden: Bool = false) {
1542
+ let inset: CGFloat
1543
+ if hidden {
1544
+ inset = 0
1545
+ } else if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
1546
+ let keyboardFrame = view.convert(frame.cgRectValue, from: nil)
1547
+ inset = max(0, view.bounds.intersection(keyboardFrame).height - view.safeAreaInsets.bottom)
1548
+ } else {
1549
+ return
1550
+ }
1551
+ guard abs(inset - keyboardSafeAreaInset) > 0.5 else { return }
1552
+ keyboardSafeAreaInset = inset
1553
+ let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
1554
+ let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
1555
+ UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
1556
+ self.additionalSafeAreaInsets.bottom = inset
1557
+ self.view.layoutIfNeeded()
1558
+ }
1559
+ }
1560
+
683
1561
  private func sendAppState(_ state: String) {
684
1562
  guard navigationController?.topViewController === self else { return }
685
1563
  send(type: "APP_STATE", payload: ["state": state])
@@ -694,6 +1572,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
694
1572
  rootStack.addArrangedSubview(label)
695
1573
  rootStack.setLayoutStyle(["alignSelf": "stretch"], for: label)
696
1574
  NSLog("[craft native] %@", message)
1575
+ hybridFailed(message)
1576
+ }
1577
+
1578
+ /// Only before the first frame: later exceptions belong to a screen that
1579
+ /// is up, and are logged.
1580
+ private func hybridFailed(_ message: String) {
1581
+ guard !hybridRendered else { return }
1582
+ hybridEvents?.nativeScreen(self, didFail: message)
697
1583
  }
698
1584
 
699
1585
  private func receive(_ raw: String) {
@@ -737,11 +1623,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
737
1623
  let navigation = navigationController else { return }
738
1624
  let params = payload["params"] as? [String: Any] ?? [:]
739
1625
  let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
1626
+ next.hybridEvents = hybridEvents
740
1627
  if type == "NAVIGATE" {
741
1628
  navigation.pushViewController(next, animated: true)
742
1629
  } else {
743
1630
  navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
744
1631
  }
1632
+ case "NAVIGATION_SET_OPTIONS":
1633
+ applyNavigationOptions(payload)
1634
+ case "NAVIGATE_OPEN":
1635
+ // A path rather than a screen: the hybrid shell opens it, natively
1636
+ // or in the web view.
1637
+ guard let path = payload["path"] as? String, !path.isEmpty else { return }
1638
+ hybridEvents?.nativeScreen(self, open: path)
745
1639
  case "NAVIGATE_BACK":
746
1640
  if navigationController?.topViewController === self {
747
1641
  navigationController?.popViewController(animated: true)
@@ -804,10 +1698,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
804
1698
  }
805
1699
  }
806
1700
 
1701
+ /// Runs script in the screen's JavaScript context. Internal for
1702
+ /// simulator-hosted XCTest of the host APIs.
1703
+ @discardableResult
1704
+ func evaluateScript(_ script: String) -> JSValue? {
1705
+ jsContext.evaluateScript(script)
1706
+ }
1707
+
807
1708
  // Internal so simulator-hosted XCTest can assert actual UIView identity.
808
1709
  func render(_ document: [String: Any]) {
809
1710
  mutationDocument.replace(with: document)
810
1711
  renderCommitted(document)
1712
+ if !hybridRendered {
1713
+ hybridRendered = true
1714
+ hybridEvents?.nativeScreenDidRender(self)
1715
+ }
811
1716
  }
812
1717
 
813
1718
  @discardableResult
@@ -825,7 +1730,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
825
1730
  let target = flatListOwners[id] ?? id
826
1731
  guard applied.insert(target).inserted else { continue }
827
1732
  guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
828
- _ = reconcile(document, identity: previous.identity, path: target, previous: previous)
1733
+ let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
1734
+ // A style patch is applied in place, layout included: the parent
1735
+ // reads the node's new layout style on its next pass.
1736
+ CraftNativeFlexLayout.invalidateAncestors(of: next.view)
829
1737
  }
830
1738
  restoreFocus(focused)
831
1739
  return true
@@ -846,10 +1754,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
846
1754
  let next = reconcile(document, identity: "root", path: "root", previous: previous)
847
1755
  if previous?.view !== next.view {
848
1756
  if let old = previous { detach(old, from: rootStack) }
849
- rootStack.addArrangedSubview(next.view)
850
1757
  }
1758
+ // A failed or interrupted reconciliation can leave an older root
1759
+ // attached even though `renderedRoot` points at the latest tree. Keep
1760
+ // the host single-rooted so accessibility queries and event routing
1761
+ // never see stale copies of the native screen.
1762
+ for child in rootStack.arrangedSubviews where child !== next.view {
1763
+ rootStack.removeArrangedSubview(child)
1764
+ child.removeFromSuperview()
1765
+ }
1766
+ if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }
851
1767
  rootStack.setLayoutStyle(next.style, for: next.view)
1768
+ rootStack.invalidateMeasurements()
852
1769
  renderedRoot = next
1770
+ trackContentScrollView()
853
1771
  restoreFocus(focused)
854
1772
  }
855
1773
 
@@ -890,7 +1808,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
890
1808
  current.protocolId = node["id"] as? String
891
1809
  current.style = style
892
1810
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
893
- if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" {
1811
+ if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
894
1812
  result.setContentHuggingPriority(.required, for: .vertical)
895
1813
  }
896
1814
  applyViewStyle(style, to: result, node: current)
@@ -901,6 +1819,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
901
1819
  let label = result as! UILabel
902
1820
  label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
903
1821
  label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
1822
+ (label as? CraftNativeLabel)?.insets = paddingInsets(style)
904
1823
  configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
905
1824
  case "Button", "Link":
906
1825
  let button = result as! UIButton
@@ -911,6 +1830,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
911
1830
  style,
912
1831
  default: .systemFont(ofSize: 16)
913
1832
  )
1833
+ // The plain title too, so `title(for:)` and accessibility read it.
1834
+ if button.title(for: .normal) != transformedTitle { button.setTitle(transformedTitle, for: .normal) }
914
1835
  button.setAttributedTitle(NSAttributedString(
915
1836
  string: transformedTitle,
916
1837
  attributes: buttonTitleAttributes(style, color: titleColor, font: font, forceUnderline: type == "Link")
@@ -962,10 +1883,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
962
1883
  if field.returnKeyType != desiredReturnKeyType { field.returnKeyType = desiredReturnKeyType }
963
1884
  let desiredAutocorrection: UITextAutocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
964
1885
  if field.autocorrectionType != desiredAutocorrection { field.autocorrectionType = desiredAutocorrection }
965
- let desiredCapitalization = capitalizationType(props["autoCapitalize"])
966
- if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
1886
+ // Secure entry first: turning it on resets the capitalization.
967
1887
  let desiredSecureEntry = props["secureTextEntry"] as? Bool == true
968
1888
  if field.isSecureTextEntry != desiredSecureEntry { field.isSecureTextEntry = desiredSecureEntry }
1889
+ let desiredCapitalization = capitalizationType(props["autoCapitalize"])
1890
+ if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
969
1891
  let desiredEnabled = props["editable"] as? Bool != false
970
1892
  if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
971
1893
  if let value = props["value"] as? String {
@@ -981,6 +1903,52 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
981
1903
  if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
982
1904
  DispatchQueue.main.async { _ = field.becomeFirstResponder() }
983
1905
  }
1906
+ case "Picker":
1907
+ let picker = result as! UIPickerView
1908
+ let options = children.compactMap { child -> (value: String, label: String)? in
1909
+ guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
1910
+ let optionProps = node["props"] as? [String: Any] ?? [:]
1911
+ let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
1912
+ guard !label.isEmpty else { return nil }
1913
+ return (value: optionProps["value"] as? String ?? label, label: label)
1914
+ }
1915
+ let pickerID = ObjectIdentifier(picker)
1916
+ pickerOptions[pickerID] = options
1917
+ picker.reloadAllComponents()
1918
+ if let selected = (props["selectedValue"] as? String) ?? (props["value"] as? String),
1919
+ let row = options.firstIndex(where: { $0.value == selected }) {
1920
+ picker.selectRow(row, inComponent: 0, animated: false)
1921
+ } else if !options.isEmpty {
1922
+ picker.selectRow(0, inComponent: 0, animated: false)
1923
+ }
1924
+ picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
1925
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
1926
+ case "Modal":
1927
+ let modal = result as! CraftNativeModalView
1928
+ modal.transparent = props["transparent"] as? Bool == true
1929
+ let visible = props["visible"] as? Bool == true
1930
+ let modalID = ObjectIdentifier(modal)
1931
+ let wasVisible = modalVisibility[modalID]
1932
+ modalVisibility[modalID] = visible
1933
+ modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
1934
+ modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
1935
+ { [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
1936
+ }
1937
+ if wasVisible != visible, visible || wasVisible == true {
1938
+ let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
1939
+ if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
1940
+ send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
1941
+ }
1942
+ }
1943
+ configureStack(modal.contentStack, style: style)
1944
+ reconcileChildren(children, in: modal.contentStack, parent: current, path: path, style: style)
1945
+ case "TouchableOpacity", "TouchableHighlight", "Pressable":
1946
+ let pressable = result as! CraftNativeFlowView
1947
+ configureStack(pressable, style: style)
1948
+ reconcileChildren(children, in: pressable, parent: current, path: path, style: style)
1949
+ pressable.pressActiveOpacity = props["disabled"] as? Bool == true
1950
+ ? nil : number(props["activeOpacity"]) ?? 0.5
1951
+ pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
984
1952
  case "Switch":
985
1953
  let toggle = result as! UISwitch
986
1954
  toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
@@ -1029,6 +1997,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1029
1997
  } else {
1030
1998
  indicator.startAnimating()
1031
1999
  }
2000
+ case "Icon":
2001
+ configureIcon(result as! UIImageView, props: props, style: style)
1032
2002
  case "Image":
1033
2003
  let image = result as! UIImageView
1034
2004
  let imageID = ObjectIdentifier(image)
@@ -1061,6 +2031,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1061
2031
  scroll.bounces = props["bounces"] as? Bool ?? true
1062
2032
  scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
1063
2033
  scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
2034
+ scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
1064
2035
  scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
1065
2036
  scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
1066
2037
  scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
@@ -1070,8 +2041,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1070
2041
  updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
1071
2042
  updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
1072
2043
  updateAuxiliaryHandler(events["onMomentumScrollEnd"], in: &scrollMomentumEndHandlers, for: scroll)
1073
- configureStack(scroll.contentStack, style: style)
1074
- reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: style)
2044
+ scroll.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
2045
+ { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
2046
+ }, refreshing: props["refreshing"] as? Bool == true)
2047
+ var contentStyle = style
2048
+ if let override = props["contentContainerStyle"] as? [String: Any] {
2049
+ contentStyle.merge(override) { _, next in next }
2050
+ }
2051
+ configureStack(scroll.contentStack, style: contentStyle)
2052
+ reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
1075
2053
  case "FlatList":
1076
2054
  let list = result as! CraftNativeFlatList
1077
2055
  reconcileFlatList(
@@ -1087,9 +2065,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1087
2065
  configureStack(stack, style: style)
1088
2066
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
1089
2067
  }
1090
- updateLongPressHandler(events["onLongPress"], for: result)
1091
- if type != "Button" && type != "Link" && type != "TextInput" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
1092
- updatePressHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
2068
+ updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
2069
+ if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
2070
+ let disabled = props["disabled"] as? Bool == true
2071
+ updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
2072
+ updatePressFeedback(
2073
+ disabled ? nil : nonEmptyHandler(events["onPressIn"]),
2074
+ disabled ? nil : nonEmptyHandler(events["onPressOut"]),
2075
+ for: result
2076
+ )
2077
+ if disabled { result.isUserInteractionEnabled = false }
1093
2078
  }
1094
2079
  applyAccessibility(props, type: type, to: result)
1095
2080
  return current
@@ -1098,7 +2083,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1098
2083
  private func makeView(_ type: String, props: [String: Any]) -> UIView {
1099
2084
  switch type {
1100
2085
  case "Text":
1101
- let label = UILabel()
2086
+ let label = CraftNativeLabel()
1102
2087
  label.numberOfLines = 0
1103
2088
  return label
1104
2089
  case "Button", "Link":
@@ -1118,6 +2103,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1118
2103
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
1119
2104
  field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
1120
2105
  return field
2106
+ case "Picker":
2107
+ let picker = UIPickerView()
2108
+ picker.dataSource = self
2109
+ picker.delegate = self
2110
+ return picker
2111
+ case "Modal":
2112
+ return CraftNativeModalView()
1121
2113
  case "Switch":
1122
2114
  let toggle = UISwitch()
1123
2115
  toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
@@ -1131,19 +2123,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1131
2123
  return UIActivityIndicatorView(style: .medium)
1132
2124
  case "Image":
1133
2125
  return UIImageView()
2126
+ case "Icon":
2127
+ let icon = CraftNativeIconView()
2128
+ icon.contentMode = .scaleAspectFit
2129
+ return icon
1134
2130
  case "ScrollView":
1135
2131
  return CraftNativeScrollView()
1136
2132
  case "FlatList":
1137
2133
  return CraftNativeFlatList()
1138
2134
  case "View", "SafeAreaView":
1139
2135
  return CraftNativeFlowView()
2136
+ case "TouchableOpacity", "TouchableHighlight", "Pressable":
2137
+ return CraftNativeFlowView()
1140
2138
  default:
1141
2139
  return CraftNativeFlowView()
1142
2140
  }
1143
2141
  }
1144
2142
 
1145
2143
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
1146
- // The current stx-native compiler emits key in props; accept the IR
2144
+ // The current stx native compiler emits key in props; accept the IR
1147
2145
  // field too so keyed children keep working when the compiler adopts it.
1148
2146
  [node["id"], node["key"], props["key"], props["testID"]]
1149
2147
  .compactMap { $0 as? String }
@@ -1186,7 +2184,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1186
2184
  for child in parent.children where !next.contains(where: { $0 === child }) {
1187
2185
  detach(child, from: stack)
1188
2186
  }
1189
- for (index, child) in next.enumerated() {
2187
+ for modal in stack.arrangedSubviews where modal is CraftNativeModalView {
2188
+ stack.removeArrangedSubview(modal)
2189
+ }
2190
+ let flowChildren = next.filter { $0.type != "Modal" }
2191
+ for (index, child) in flowChildren.enumerated() {
1190
2192
  if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view {
1191
2193
  stack.setLayoutStyle(child.style, for: child.view)
1192
2194
  continue
@@ -1195,6 +2197,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1195
2197
  stack.insertArrangedSubview(child.view, at: min(index, stack.arrangedSubviews.count))
1196
2198
  stack.setLayoutStyle(child.style, for: child.view)
1197
2199
  }
2200
+ for child in next where child.type == "Modal" {
2201
+ guard let modal = child.view as? CraftNativeModalView else { continue }
2202
+ if modal.superview !== stack {
2203
+ modal.removeFromSuperview()
2204
+ stack.addSubview(modal)
2205
+ }
2206
+ modal.frame = stack.bounds
2207
+ modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
2208
+ stack.bringSubviewToFront(modal)
2209
+ }
1198
2210
  parent.children = next
1199
2211
  addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
1200
2212
  }
@@ -1212,6 +2224,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1212
2224
  flatListOwners = flatListOwners.filter { $0.value != owner }
1213
2225
  for child in children { registerFlatListOwnership(child, owner: owner) }
1214
2226
  list.bounces = props["bounces"] as? Bool ?? true
2227
+ list.isScrollEnabled = props["scrollEnabled"] as? Bool != false
2228
+ list.onFittingHeightChanged = { [weak list] in
2229
+ guard let list else { return }
2230
+ CraftNativeFlexLayout.invalidateAncestors(of: list)
2231
+ }
2232
+ list.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
2233
+ list.setContentContainerStyle(props["contentContainerStyle"] as? [String: Any])
2234
+ list.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
2235
+ { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
2236
+ }, refreshing: props["refreshing"] as? Bool == true)
1215
2237
  list.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
1216
2238
  list.onScrollEvent = nonEmptyHandler(events["onScroll"]).map { handler in
1217
2239
  { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
@@ -1276,20 +2298,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1276
2298
 
1277
2299
  private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
1278
2300
  forgetHandlers(node)
2301
+ if node.type == "Modal" {
2302
+ node.view.removeFromSuperview()
2303
+ return
2304
+ }
1279
2305
  stack.removeLayoutStyle(for: node.view)
1280
2306
  stack.removeArrangedSubview(node.view)
1281
2307
  node.view.removeFromSuperview()
1282
2308
  }
1283
2309
 
1284
2310
  private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
2311
+ (node.view as? CraftNativeScrollView)?.setRefreshHandler(nil, refreshing: false)
1285
2312
  if let list = node.view as? CraftNativeFlatList {
1286
2313
  let listKey = ObjectIdentifier(list)
2314
+ list.setRefreshHandler(nil, refreshing: false)
1287
2315
  list.discardAll()
1288
2316
  flatListRows.removeValue(forKey: listKey)
1289
2317
  if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
1290
2318
  }
1291
2319
  let id = ObjectIdentifier(node.view)
1292
2320
  handlers.removeValue(forKey: id)
2321
+ pressInHandlers.removeValue(forKey: id)
2322
+ pressOutHandlers.removeValue(forKey: id)
1293
2323
  layoutHandlers.removeValue(forKey: id)
1294
2324
  lastLayoutFrames.removeValue(forKey: id)
1295
2325
  focusHandlers.removeValue(forKey: id)
@@ -1307,12 +2337,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1307
2337
  scrollMomentumEndHandlers.removeValue(forKey: id)
1308
2338
  sliderCompleteHandlers.removeValue(forKey: id)
1309
2339
  sliderSteps.removeValue(forKey: id)
2340
+ pickerOptions.removeValue(forKey: id)
2341
+ modalVisibility.removeValue(forKey: id)
1310
2342
  if let recognizer = tapRecognizers.removeValue(forKey: id) {
1311
2343
  node.view.removeGestureRecognizer(recognizer)
1312
2344
  }
1313
2345
  if let recognizer = longPressRecognizers.removeValue(forKey: id) {
1314
2346
  node.view.removeGestureRecognizer(recognizer)
1315
2347
  }
2348
+ if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) {
2349
+ node.view.removeGestureRecognizer(recognizer)
2350
+ }
1316
2351
  imageSources.removeValue(forKey: id)
1317
2352
  imageTasks.removeValue(forKey: id)?.cancel()
1318
2353
  imageErrors.removeValue(forKey: id)
@@ -1402,7 +2437,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1402
2437
  } else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
1403
2438
  view.removeGestureRecognizer(recognizer)
1404
2439
  }
1405
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil }
2440
+ // A container keeps receiving touches without a handler of its own,
2441
+ // or every control inside it (and the screen root) would be dead.
2442
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
2443
+ }
2444
+
2445
+ private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
2446
+ let id = ObjectIdentifier(view)
2447
+ if let pressIn, !pressIn.isEmpty { pressInHandlers[id] = pressIn } else { pressInHandlers.removeValue(forKey: id) }
2448
+ if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
2449
+ guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
2450
+ if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
2451
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
2452
+ return
2453
+ }
2454
+ if pressFeedbackRecognizers[id] == nil {
2455
+ let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewPressFeedback(_:)))
2456
+ recognizer.minimumPressDuration = 0
2457
+ recognizer.allowableMovement = 10
2458
+ recognizer.cancelsTouchesInView = false
2459
+ pressFeedbackRecognizers[id] = recognizer
2460
+ view.addGestureRecognizer(recognizer)
2461
+ }
2462
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
1406
2463
  }
1407
2464
 
1408
2465
  private func updateLongPressHandler(_ handler: String?, for view: UIView) {
@@ -1420,7 +2477,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1420
2477
  if let recognizer = longPressRecognizers.removeValue(forKey: id) {
1421
2478
  view.removeGestureRecognizer(recognizer)
1422
2479
  }
1423
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil }
2480
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || view is CraftNativeFlowView }
1424
2481
  }
1425
2482
  }
1426
2483
 
@@ -1504,6 +2561,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1504
2561
  }
1505
2562
  }
1506
2563
 
2564
+ /// `<Icon symbol="sun.max">`: an SF Symbol, tinted by `color`, sized by
2565
+ /// `fontSize` or else the icon's box, weighted by `fontWeight`. A name
2566
+ /// the system does not have draws `circle`, so a typo shows rather than
2567
+ /// leaving a hole.
2568
+ private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
2569
+ let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
2570
+ let box = [number(style["width"]), number(style["height"])].compactMap { $0 }.min()
2571
+ let fontSize = number(style["fontSize"])
2572
+ // Without a size of its own an icon is a 20 pt square (contract A2).
2573
+ (icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
2574
+ let pointSize = fontSize ?? (box ?? 20) * 0.84
2575
+ let weight: UIImage.SymbolWeight
2576
+ switch style["fontWeight"] as? String {
2577
+ case "100": weight = .ultraLight
2578
+ case "200": weight = .thin
2579
+ case "300": weight = .light
2580
+ case "500": weight = .medium
2581
+ case "600": weight = .semibold
2582
+ case "bold", "700": weight = .bold
2583
+ case "800": weight = .heavy
2584
+ case "900": weight = .black
2585
+ default: weight = .regular
2586
+ }
2587
+ let scale: UIImage.SymbolScale
2588
+ switch props["scale"] as? String {
2589
+ case "small": scale = .small
2590
+ case "large": scale = .large
2591
+ default: scale = .medium
2592
+ }
2593
+ let configuration = UIImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: weight, scale: scale)
2594
+ let image = UIImage(systemName: name, withConfiguration: configuration)
2595
+ ?? UIImage(systemName: "circle", withConfiguration: configuration)
2596
+ if icon.image != image { icon.image = image }
2597
+ icon.preferredSymbolConfiguration = configuration
2598
+ icon.tintColor = color(style["color"]) ?? color(props["color"]) ?? .label
2599
+ icon.contentMode = .scaleAspectFit
2600
+ }
2601
+
1507
2602
  private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
1508
2603
  let direction = style["flexDirection"] as? String
1509
2604
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
@@ -1517,15 +2612,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1517
2612
  stack.grid = style["display"] as? String == "grid"
1518
2613
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
1519
2614
  stack.gridAutoRows = number(style["gridAutoRows"])
1520
- let padding = number(style["padding"]) ?? 0
1521
- let horizontal = number(style["paddingHorizontal"]) ?? padding
1522
- let vertical = number(style["paddingVertical"]) ?? padding
1523
- stack.padding = UIEdgeInsets(
1524
- top: number(style["paddingTop"]) ?? vertical,
1525
- left: number(style["paddingLeft"]) ?? horizontal,
1526
- bottom: number(style["paddingBottom"]) ?? vertical,
1527
- right: number(style["paddingRight"]) ?? horizontal
1528
- )
2615
+ stack.padding = paddingInsets(style)
2616
+ stack.invalidateMeasurements()
1529
2617
  }
1530
2618
 
1531
2619
  private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
@@ -1538,7 +2626,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1538
2626
  view.backgroundColor = color(style["backgroundColor"]) ?? .clear
1539
2627
  view.alpha = number(style["opacity"]) ?? 1
1540
2628
  view.isHidden = style["display"] as? String == "none"
1541
- view.layer.cornerRadius = number(style["borderRadius"]) ?? 0
2629
+ view.craftRequestedCornerRadius = number(style["borderRadius"])
1542
2630
  view.layer.borderWidth = number(style["borderWidth"]) ?? 0
1543
2631
  view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
1544
2632
  let elevation = max(0, number(style["elevation"]) ?? 0)
@@ -1546,8 +2634,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1546
2634
  view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
1547
2635
  view.layer.shadowRadius = elevation * 0.5
1548
2636
  view.layer.shadowOffset = CGSize(width: 0, height: elevation * 0.25)
2637
+ // A label paints its background inside its own bounds, so a rounded
2638
+ // chip of text clips to its corners.
1549
2639
  view.clipsToBounds = style["overflow"] as? String == "hidden"
2640
+ || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
1550
2641
  view.layer.masksToBounds = view.clipsToBounds
2642
+ view.craftLayoutStyle = CraftNativeLayoutStyle(style)
2643
+ (view as? CraftNativeFlowView)?.invalidateMeasurements()
2644
+ (view as? CraftNativeScrollView)?.setContentNeedsLayout()
2645
+ }
2646
+
2647
+ private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
2648
+ let padding = number(style["padding"]) ?? 0
2649
+ let horizontal = number(style["paddingHorizontal"]) ?? padding
2650
+ let vertical = number(style["paddingVertical"]) ?? padding
2651
+ return UIEdgeInsets(
2652
+ top: number(style["paddingTop"]) ?? vertical,
2653
+ left: number(style["paddingLeft"]) ?? horizontal,
2654
+ bottom: number(style["paddingBottom"]) ?? vertical,
2655
+ right: number(style["paddingRight"]) ?? horizontal
2656
+ )
1551
2657
  }
1552
2658
 
1553
2659
  private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
@@ -1557,13 +2663,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1557
2663
  label.textAlignment = textAlignment(style["textAlign"])
1558
2664
  var attributes: [NSAttributedString.Key: Any] = [:]
1559
2665
  if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
1560
- if let lineHeight = number(style["lineHeight"]) {
1561
- let paragraph = NSMutableParagraphStyle()
1562
- paragraph.minimumLineHeight = lineHeight
1563
- paragraph.maximumLineHeight = lineHeight
1564
- paragraph.alignment = label.textAlignment
1565
- attributes[.paragraphStyle] = paragraph
1566
- }
1567
2666
  switch style["textDecorationLine"] as? String {
1568
2667
  case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
1569
2668
  case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
@@ -1572,8 +2671,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1572
2671
  attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
1573
2672
  default: break
1574
2673
  }
2674
+ if let lineHeight = number(style["lineHeight"]) {
2675
+ let paragraph = NSMutableParagraphStyle()
2676
+ paragraph.minimumLineHeight = lineHeight
2677
+ paragraph.maximumLineHeight = lineHeight
2678
+ paragraph.alignment = label.textAlignment
2679
+ attributes[.paragraphStyle] = paragraph
2680
+ }
2681
+ let alignment = label.textAlignment
2682
+ let lineBreakMode = label.lineBreakMode
1575
2683
  label.attributedText = attributes.isEmpty ? nil : NSAttributedString(string: transformed, attributes: attributes)
1576
2684
  if attributes.isEmpty { label.text = transformed }
2685
+ // Attributed text brings its own paragraph style, which resets the
2686
+ // label's alignment and truncation; set them again over the whole text.
2687
+ label.textAlignment = alignment
2688
+ label.lineBreakMode = lineBreakMode
1577
2689
  }
1578
2690
 
1579
2691
  private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
@@ -1808,7 +2920,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1808
2920
  view.accessibilityValue = nil
1809
2921
  }
1810
2922
  let role = props["accessibilityRole"] as? String
1811
- view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Switch", "Slider"].contains(type))
2923
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Picker", "Switch", "Slider"].contains(type))
1812
2924
  var traits: UIAccessibilityTraits = []
1813
2925
  switch role ?? type.lowercased() {
1814
2926
  case "button": traits.insert(.button)
@@ -1844,7 +2956,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1844
2956
  if let value = value {
1845
2957
  if let constraint = constraint { constraint.constant = value }
1846
2958
  else {
2959
+ // Below required: the flex layout owns frames, and a width
2960
+ // the layout stretches or shrinks must not log a conflict.
1847
2961
  constraint = anchor.constraint(equalToConstant: value)
2962
+ constraint?.priority = UILayoutPriority(999)
1848
2963
  constraint?.isActive = true
1849
2964
  }
1850
2965
  } else {
@@ -1936,6 +3051,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1936
3051
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
1937
3052
  }
1938
3053
 
3054
+ func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
3055
+
3056
+ func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
3057
+ pickerOptions[ObjectIdentifier(pickerView)]?.count ?? 0
3058
+ }
3059
+
3060
+ func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
3061
+ guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row) else { return nil }
3062
+ return options[row].label
3063
+ }
3064
+
3065
+ func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
3066
+ guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row),
3067
+ let handler = handlers[ObjectIdentifier(pickerView)] else { return }
3068
+ let option = options[row]
3069
+ send(type: "EVENT", payload: [
3070
+ "handlerName": handler,
3071
+ "nativeEvent": ["value": option.value, "index": row],
3072
+ ])
3073
+ }
3074
+
1939
3075
  @objc private func textFocused(_ sender: UITextField) {
1940
3076
  lastFocusedInput = sender
1941
3077
  guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
@@ -1960,9 +3096,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1960
3096
 
1961
3097
  @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
1962
3098
  guard let view = sender.view, let handler = handlers[ObjectIdentifier(view)] else { return }
3099
+ let id = ObjectIdentifier(view)
3100
+ if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
3101
+ let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
3102
+ UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
3103
+ UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity }
3104
+ }
3105
+ }
1963
3106
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
1964
3107
  }
1965
3108
 
3109
+ @objc private func viewPressFeedback(_ sender: UILongPressGestureRecognizer) {
3110
+ guard let view = sender.view else { return }
3111
+ let id = ObjectIdentifier(view)
3112
+ switch sender.state {
3113
+ case .began:
3114
+ if let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
3115
+ UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }
3116
+ }
3117
+ if let handler = pressInHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
3118
+ case .ended, .cancelled, .failed:
3119
+ if let pressable = view as? CraftNativeFlowView { UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity } }
3120
+ if let handler = pressOutHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
3121
+ default:
3122
+ break
3123
+ }
3124
+ }
3125
+
1966
3126
  @objc private func viewLongPressed(_ sender: UILongPressGestureRecognizer) {
1967
3127
  guard sender.state == .began,
1968
3128
  let view = sender.view,
@@ -2048,8 +3208,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2048
3208
  }
2049
3209
  }
2050
3210
 
3211
+ /// A style colour: CSS hex with or without alpha (`#10b9811a` is
3212
+ /// emerald at 10%), `rgb()`/`rgba()` and the few names; `UIColor(hex:)`
3213
+ /// for anything else it has always read.
2051
3214
  private func color(_ value: Any?) -> UIColor? {
2052
- guard let hex = value as? String else { return nil }
2053
- return UIColor(hex: hex)
3215
+ guard let text = value as? String else { return nil }
3216
+ return UIColor(css: text) ?? UIColor(hex: text)
3217
+ }
3218
+ }
3219
+
3220
+ /// `console` for native screens: the unified log, subsystem `craft.native`,
3221
+ /// category the screen's name. Read it with
3222
+ /// `log stream --predicate 'subsystem == "craft.native"'`.
3223
+ enum CraftNativeConsole {
3224
+ private static var loggers: [String: Logger] = [:]
3225
+ private static let lock = NSLock()
3226
+
3227
+ static func write(_ level: String, category: String, message: String) {
3228
+ lock.lock()
3229
+ let logger = loggers[category] ?? Logger(subsystem: "craft.native", category: category)
3230
+ loggers[category] = logger
3231
+ lock.unlock()
3232
+ switch level {
3233
+ case "debug": logger.debug("\(message, privacy: .public)")
3234
+ case "info": logger.info("\(message, privacy: .public)")
3235
+ case "warn": logger.warning("\(message, privacy: .public)")
3236
+ case "error": logger.error("\(message, privacy: .public)")
3237
+ default: logger.notice("\(message, privacy: .public)")
3238
+ }
2054
3239
  }
2055
3240
  }