craft-native 0.0.124 → 0.0.126

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,154 @@ 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 size rounded up to whole pixels, never below `atLeast`.
217
+ static func ceiled(_ size: CGSize, scale: CGFloat, atLeast floor: CGSize) -> CGSize {
218
+ let scale = max(1, scale)
219
+ // Less a hair, so a size already on the pixel grid stays put.
220
+ func up(_ value: CGFloat) -> CGFloat { (value * scale - 0.001).rounded(.up) / scale }
221
+ return CGSize(width: max(floor.width, up(size.width)), height: max(floor.height, up(size.height)))
222
+ }
223
+
224
+ /// A frame snapped to the screen's pixels edge by edge, so neighbours
225
+ /// neither overlap nor leave a hairline between them.
226
+ static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
227
+ let scale = max(1, scale)
228
+ func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
229
+ let minX = snap(rect.minX), minY = snap(rect.minY)
230
+ return CGRect(x: minX, y: minY, width: max(0, snap(rect.maxX) - minX), height: max(0, snap(rect.maxY) - minY))
231
+ }
232
+ }
233
+
234
+ /// A flexbox container: rows and columns with grow, shrink and basis, wrap,
235
+ /// gaps, alignment, margins, percentages, absolute children and a simple
236
+ /// grid. It is a UIStackView in name only, so existing callers and tests keep
237
+ /// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
238
+ /// constraints are never created, and every frame comes from `layout`.
87
239
  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() } }
240
+ var pressActiveOpacity: CGFloat?
241
+ var pressBaseOpacity: CGFloat = 1
242
+ var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
243
+ var alignItems = "stretch" { didSet { alignment = alignmentValue; if alignItems != oldValue { invalidateMeasurements() } } }
244
+ var justifyContent = "flex-start" { didSet { distribution = distributionValue; if justifyContent != oldValue { invalidateMeasurements() } } }
245
+ var gap: CGFloat = 0 { didSet { spacing = gap; if gap != oldValue { invalidateMeasurements() } } }
246
+ var rowGap: CGFloat? { didSet { if rowGap != oldValue { invalidateMeasurements() } } }
247
+ var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
248
+ var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
249
+ var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
250
+ var gridAutoRows: CGFloat? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
97
251
  var padding = UIEdgeInsets.zero {
98
252
  didSet {
99
253
  layoutMargins = padding
100
254
  isLayoutMarginsRelativeArrangement = true
101
- invalidateIntrinsicContentSize()
102
- setNeedsLayout()
255
+ if padding != oldValue { invalidateMeasurements() }
103
256
  }
104
257
  }
105
- private var childStyles: [ObjectIdentifier: CraftNativeLayoutStyle] = [:]
258
+ override var axis: NSLayoutConstraint.Axis {
259
+ didSet { if axis != oldValue { invalidateMeasurements() } }
260
+ }
261
+
262
+ private var flowChildren: [UIView] = []
263
+ private var measurements: [MeasureKey: CGSize] = [:]
264
+
265
+ private struct MeasureKey: Hashable {
266
+ let width: CGFloat?
267
+ let height: CGFloat?
268
+ let maxWidth: CGFloat
269
+ let maxHeight: CGFloat
270
+ }
106
271
 
107
272
  override init(frame: CGRect) {
108
273
  super.init(frame: frame)
109
274
  alignment = .fill
110
275
  distribution = .fill
111
276
  isLayoutMarginsRelativeArrangement = true
277
+ insetsLayoutMarginsFromSafeArea = false
112
278
  }
113
279
 
114
280
  required init(coder: NSCoder) { super.init(coder: coder) }
@@ -131,304 +297,818 @@ private final class CraftNativeFlowView: UIStackView {
131
297
  }
132
298
  }
133
299
 
134
- func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
135
- childStyles[ObjectIdentifier(view)] = CraftNativeLayoutStyle(raw)
136
- invalidateIntrinsicContentSize()
137
- setNeedsLayout()
300
+ // MARK: Children
301
+
302
+ override var arrangedSubviews: [UIView] { flowChildren }
303
+
304
+ override func addArrangedSubview(_ view: UIView) {
305
+ insertArrangedSubview(view, at: flowChildren.count)
138
306
  }
139
307
 
140
- func removeLayoutStyle(for view: UIView) { childStyles.removeValue(forKey: ObjectIdentifier(view)) }
308
+ override func insertArrangedSubview(_ view: UIView, at stackIndex: Int) {
309
+ flowChildren.removeAll { $0 === view }
310
+ flowChildren.insert(view, at: min(max(0, stackIndex), flowChildren.count))
311
+ if view.superview !== self {
312
+ view.removeFromSuperview()
313
+ addSubview(view)
314
+ }
315
+ view.translatesAutoresizingMaskIntoConstraints = true
316
+ invalidateMeasurements()
317
+ }
141
318
 
142
- override func didAddSubview(_ subview: UIView) {
143
- super.didAddSubview(subview)
144
- setNeedsLayout()
319
+ override func removeArrangedSubview(_ view: UIView) {
320
+ flowChildren.removeAll { $0 === view }
321
+ invalidateMeasurements()
145
322
  }
146
323
 
147
324
  override func willRemoveSubview(_ subview: UIView) {
148
- childStyles.removeValue(forKey: ObjectIdentifier(subview))
325
+ flowChildren.removeAll { $0 === subview }
149
326
  super.willRemoveSubview(subview)
327
+ invalidateMeasurements()
328
+ }
329
+
330
+ func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
331
+ view.craftLayoutStyle = CraftNativeLayoutStyle(raw)
332
+ invalidateMeasurements()
333
+ }
334
+
335
+ func removeLayoutStyle(for view: UIView) {
336
+ view.craftLayoutStyle = .empty
150
337
  }
151
338
 
339
+ /// Forgets measured sizes and lays out again on the next pass.
340
+ func invalidateMeasurements() {
341
+ measurements.removeAll(keepingCapacity: true)
342
+ setNeedsLayout()
343
+ }
344
+
345
+ // MARK: Measuring and layout
346
+
152
347
  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)
348
+ CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
349
+ }
350
+
351
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
352
+ measure(width: nil, height: nil, maxWidth: size.width, maxHeight: size.height)
353
+ }
354
+
355
+ func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
356
+ let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
357
+ if let cached = measurements[key] { return cached }
358
+ let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
359
+ measurements[key] = size
360
+ return size
172
361
  }
173
362
 
174
363
  override func layoutSubviews() {
175
364
  super.layoutSubviews()
176
- let content = bounds.inset(by: padding)
177
- if grid {
178
- layoutGrid(content: content)
179
- return
365
+ craftApplyCornerRadius()
366
+ let scale = window?.screen.scale ?? traitCollection.displayScale
367
+ let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
368
+ for (child, frame) in result.frames {
369
+ var snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
370
+ // Snapping each edge can take a pixel off a label (15.83…33.83
371
+ // becomes 16…33.67), and a label a pixel narrower than its text
372
+ // truncates it ("13" drew as "…"). Text keeps its whole size.
373
+ if child is UILabel {
374
+ snapped.size = CraftNativeFlexLayout.ceiled(frame.size, scale: scale, atLeast: snapped.size)
375
+ }
376
+ if child.transform.isIdentity {
377
+ if child.frame != snapped { child.frame = snapped }
378
+ } else {
379
+ // A transformed child (a sticky header, a scaled spinner) is
380
+ // placed by bounds and center: its frame is undefined then.
381
+ let size = CGRect(origin: child.bounds.origin, size: snapped.size)
382
+ let center = CGPoint(x: snapped.midX, y: snapped.midY)
383
+ if child.bounds != size { child.bounds = size }
384
+ if child.center != center { child.center = center }
385
+ }
386
+ child.craftApplyCornerRadius()
180
387
  }
181
- let availableMain = axis == .horizontal ? content.width : content.height
182
- var lines: [([UIView], [CGSize], CGFloat, CGFloat)] = []
183
- var items: [UIView] = []
184
- var sizes: [CGSize] = []
388
+ }
389
+
390
+ private struct Item {
391
+ let view: UIView
392
+ let style: CraftNativeLayoutStyle
393
+ let align: String
394
+ let mainLeading: CGFloat
395
+ let mainTrailing: CGFloat
396
+ let crossLeading: CGFloat
397
+ let crossTrailing: CGFloat
398
+ let minMain: CGFloat?
399
+ let maxMain: CGFloat?
400
+ let minCross: CGFloat?
401
+ let maxCross: CGFloat?
402
+ let fixedCross: CGFloat?
403
+ let grow: CGFloat
404
+ let shrink: CGFloat
405
+ var basis: CGFloat
185
406
  var main: CGFloat = 0
186
407
  var cross: CGFloat = 0
187
408
 
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
409
+ var mainMargins: CGFloat { mainLeading + mainTrailing }
410
+ var crossMargins: CGFloat { crossLeading + crossTrailing }
411
+ }
412
+
413
+ private func clamp(_ value: CGFloat, _ lower: CGFloat?, _ upper: CGFloat?) -> CGFloat {
414
+ var result = value
415
+ if let upper { result = min(result, upper) }
416
+ if let lower { result = max(result, lower) }
417
+ return max(0, result)
418
+ }
419
+
420
+ /// Children that take part in the flow: shown, not a modal overlay.
421
+ private var visibleChildren: [UIView] {
422
+ flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
423
+ }
424
+
425
+ /// The flexbox algorithm, single pass. `width`/`height` are this view's
426
+ /// outer size when its parent fixes it, nil when it sizes to its content
427
+ /// within `maxWidth`/`maxHeight`. Returns its outer size and, when
428
+ /// placing, every child's frame in its own coordinates.
429
+ private func layout(
430
+ width: CGFloat?,
431
+ height: CGFloat?,
432
+ maxWidth: CGFloat,
433
+ maxHeight: CGFloat,
434
+ place: Bool
435
+ ) -> (size: CGSize, frames: [(UIView, CGRect)]) {
436
+ let children = visibleChildren
437
+ let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
438
+ let horizontal = axis == .horizontal
439
+ let paddingWidth = padding.left + padding.right
440
+ let paddingHeight = padding.top + padding.bottom
441
+ let innerWidth = width.map { max(0, $0 - paddingWidth) }
442
+ let innerHeight = height.map { max(0, $0 - paddingHeight) }
443
+ let roomWidth = max(0, (width ?? maxWidth) - paddingWidth)
444
+ let roomHeight = max(0, (height ?? maxHeight) - paddingHeight)
445
+
446
+ var frames: [(UIView, CGRect)] = []
447
+ var contentSize: CGSize
448
+ if grid {
449
+ contentSize = layoutGrid(flow, innerWidth: innerWidth, roomWidth: roomWidth, innerHeight: innerHeight, place: place, frames: &frames)
450
+ } else {
451
+ contentSize = layoutFlex(
452
+ flow,
453
+ horizontal: horizontal,
454
+ innerWidth: innerWidth,
455
+ innerHeight: innerHeight,
456
+ roomWidth: roomWidth,
457
+ roomHeight: roomHeight,
458
+ place: place,
459
+ frames: &frames
460
+ )
461
+ }
462
+ let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
463
+
464
+ if place {
465
+ let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
466
+ for child in children where child.craftLayoutStyle.position == "absolute" {
467
+ let style = child.craftLayoutStyle
468
+ let fixedWidth = style.left != nil && style.right != nil
469
+ ? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
470
+ : style.width?.resolve(content.width)
471
+ let fixedHeight = style.top != nil && style.bottom != nil
472
+ ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
473
+ : style.height?.resolve(content.height)
474
+ let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
475
+ let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
476
+ let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
477
+ frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height)))
243
478
  }
244
- crossOffset += lineCross + crossGapValue
245
479
  }
480
+ return (size, frames)
481
+ }
482
+
483
+ private func layoutFlex(
484
+ _ children: [UIView],
485
+ horizontal: Bool,
486
+ innerWidth: CGFloat?,
487
+ innerHeight: CGFloat?,
488
+ roomWidth: CGFloat,
489
+ roomHeight: CGFloat,
490
+ place: Bool,
491
+ frames: inout [(UIView, CGRect)]
492
+ ) -> CGSize {
493
+ let definiteMain = horizontal ? innerWidth : innerHeight
494
+ let definiteCross = horizontal ? innerHeight : innerWidth
495
+ let roomMain = horizontal ? roomWidth : roomHeight
496
+ let roomCross = horizontal ? roomHeight : roomWidth
497
+ let mainGap = horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
498
+ let crossGap = horizontal ? (rowGap ?? 0) : (columnGap ?? 0)
499
+
500
+ func measure(_ view: UIView, main: CGFloat?, cross: CGFloat?, mainLimit: CGFloat, crossLimit: CGFloat) -> (main: CGFloat, cross: CGFloat) {
501
+ let size = horizontal
502
+ ? CraftNativeFlexLayout.measure(view, width: main, height: cross, maxWidth: mainLimit, maxHeight: crossLimit)
503
+ : CraftNativeFlexLayout.measure(view, width: cross, height: main, maxWidth: crossLimit, maxHeight: mainLimit)
504
+ return horizontal ? (size.width, size.height) : (size.height, size.width)
505
+ }
506
+
507
+ var items: [Item] = children.map { view in
508
+ let style = view.craftLayoutStyle
509
+ let mainLeading = horizontal ? style.marginLeft : style.marginTop
510
+ let mainTrailing = horizontal ? style.marginRight : style.marginBottom
511
+ let crossLeading = horizontal ? style.marginTop : style.marginLeft
512
+ let crossTrailing = horizontal ? style.marginBottom : style.marginRight
513
+ let align = style.alignSelf ?? alignItems
514
+ let explicitMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
515
+ let explicitCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
516
+ let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
517
+ let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
518
+ let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
519
+ let maxCross = (horizontal ? style.maxHeight : style.maxWidth)?.resolve(definiteCross)
520
+ var fixedCross = explicitCross
521
+ if fixedCross == nil, align == "stretch", !wrap, let definiteCross {
522
+ fixedCross = definiteCross - crossLeading - crossTrailing
523
+ }
524
+ fixedCross = fixedCross.map { clamp($0, minCross, maxCross) }
525
+ let defaultShrink: CGFloat
526
+ if view is UIScrollView {
527
+ defaultShrink = 1
528
+ } else if view is UILabel {
529
+ defaultShrink = horizontal ? 1 : 0
530
+ } else {
531
+ defaultShrink = 0
532
+ }
246
533
 
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
534
+ // The basis: a flex-basis, else the explicit size, else the
535
+ // content's size. A zero basis (`flex-1`) means "share the free
536
+ // space" only when there is a free space to share; in a container
537
+ // sized by its content, the content is the basis, as on the web.
538
+ let basis: CGFloat
539
+ if let flexBasis = style.flexBasis?.resolve(definiteMain), definiteMain != nil || flexBasis > 0 {
540
+ basis = flexBasis
541
+ } else if let explicitMain {
542
+ basis = explicitMain
543
+ } else {
544
+ basis = measure(
545
+ view,
546
+ main: nil,
547
+ cross: fixedCross,
548
+ mainLimit: max(0, (maxMain ?? roomMain) - mainLeading - mainTrailing),
549
+ crossLimit: max(0, (fixedCross ?? (maxCross ?? roomCross - crossLeading - crossTrailing)))
550
+ ).main
551
+ }
552
+ return Item(
553
+ view: view,
554
+ style: style,
555
+ align: align,
556
+ mainLeading: mainLeading,
557
+ mainTrailing: mainTrailing,
558
+ crossLeading: crossLeading,
559
+ crossTrailing: crossTrailing,
560
+ minMain: minMain,
561
+ maxMain: maxMain,
562
+ minCross: minCross,
563
+ maxCross: maxCross,
564
+ fixedCross: fixedCross,
565
+ grow: style.flexGrow,
566
+ shrink: style.flexShrink ?? defaultShrink,
567
+ basis: clamp(basis, minMain, maxMain)
568
+ )
256
569
  }
257
- }
258
570
 
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"
571
+ // Lines: one, or as many as wrapping needs.
572
+ var lines: [Range<Int>] = []
573
+ let lineLimit = definiteMain ?? roomMain
574
+ var start = 0
575
+ var used: CGFloat = 0
576
+ for index in items.indices {
577
+ let size = items[index].basis + items[index].mainMargins
578
+ let next = index == start ? size : used + mainGap + size
579
+ if wrap, index > start, next > lineLimit {
580
+ lines.append(start..<index)
581
+ start = index
582
+ used = size
583
+ } else {
584
+ used = next
585
+ }
267
586
  }
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)
587
+ if start < items.count { lines.append(start..<items.count) }
588
+
589
+ var lineMains: [CGFloat] = []
590
+ var lineCrosses: [CGFloat] = []
591
+ for line in lines {
592
+ let gaps = CGFloat(max(0, line.count - 1)) * mainGap
593
+ let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + gaps
594
+ // A row sized by its content is as wide as that content, up to the
595
+ // room it has; a column sized by its content is as tall as it is.
596
+ let lineMain = definiteMain ?? (horizontal ? min(hypothetical, roomMain) : hypothetical)
597
+ let free = lineMain - hypothetical
598
+ if free > 0 {
599
+ let total = line.reduce(0) { $0 + items[$1].grow }
600
+ for index in line {
601
+ let share = total > 0 ? free * items[index].grow / total : 0
602
+ items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
603
+ }
604
+ } else if free < 0 {
605
+ let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
606
+ for index in line {
607
+ let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
608
+ items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
609
+ }
610
+ } else {
611
+ for index in line { items[index].main = items[index].basis }
612
+ }
613
+
614
+ var lineCross: CGFloat = 0
615
+ for index in line {
616
+ let item = items[index]
617
+ if let fixedCross = item.fixedCross {
618
+ items[index].cross = fixedCross
619
+ } else {
620
+ let measured = measure(
621
+ item.view,
622
+ main: item.main,
623
+ cross: nil,
624
+ mainLimit: item.main,
625
+ crossLimit: max(0, (item.maxCross ?? roomCross) - item.crossMargins)
626
+ )
627
+ items[index].cross = clamp(measured.cross, item.minCross, item.maxCross)
628
+ }
629
+ lineCross = max(lineCross, items[index].cross + item.crossMargins)
630
+ }
631
+ // A single line is as thick as the container when the container's
632
+ // size is known: stretched children fill it, others align in it.
633
+ if !wrap, let definiteCross { lineCross = definiteCross }
634
+ for index in line where items[index].fixedCross == nil && items[index].align == "stretch" {
635
+ let item = items[index]
636
+ if case .some = (horizontal ? item.style.height : item.style.width) { continue }
637
+ items[index].cross = clamp(lineCross - item.crossMargins, item.minCross, item.maxCross)
638
+ }
639
+ lineMains.append(line.reduce(0) { $0 + items[$1].main + items[$1].mainMargins } + gaps)
640
+ lineCrosses.append(lineCross)
641
+ }
642
+
643
+ let contentMain = definiteMain ?? (lineMains.max() ?? 0)
644
+ let contentCross = definiteCross
645
+ ?? (lineCrosses.reduce(0, +) + CGFloat(max(0, lineCrosses.count - 1)) * crossGap)
646
+
647
+ if place {
648
+ var crossOffset: CGFloat = 0
649
+ for (lineIndex, line) in lines.enumerated() {
650
+ let free = max(0, contentMain - lineMains[lineIndex])
651
+ let count = line.count
652
+ let leading: CGFloat
653
+ let between: CGFloat
654
+ switch justifyContent {
655
+ case "center": leading = free / 2; between = 0
656
+ case "flex-end": leading = free; between = 0
657
+ case "space-between": leading = 0; between = count > 1 ? free / CGFloat(count - 1) : 0
658
+ case "space-around": leading = free / CGFloat(count * 2); between = free / CGFloat(count)
659
+ case "space-evenly": leading = free / CGFloat(count + 1); between = free / CGFloat(count + 1)
660
+ default: leading = 0; between = 0
661
+ }
662
+ let lineCross = lineCrosses[lineIndex]
663
+ var mainOffset = leading
664
+ for index in line {
665
+ let item = items[index]
666
+ let crossPosition: CGFloat
667
+ switch item.align {
668
+ case "center": crossPosition = item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
669
+ case "flex-end": crossPosition = lineCross - item.crossTrailing - item.cross
670
+ default: crossPosition = item.crossLeading
671
+ }
672
+ let mainPosition = mainOffset + item.mainLeading
673
+ let frame = horizontal
674
+ ? CGRect(x: padding.left + mainPosition, y: padding.top + crossOffset + crossPosition, width: item.main, height: item.cross)
675
+ : CGRect(x: padding.left + crossOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
676
+ frames.append((item.view, frame))
677
+ mainOffset += item.mainMargins + item.main + mainGap + between
678
+ }
679
+ crossOffset += lineCross + crossGap
680
+ }
275
681
  }
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)
682
+ return horizontal ? CGSize(width: contentMain, height: contentCross) : CGSize(width: contentCross, height: contentMain)
279
683
  }
280
684
 
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
- }
685
+ private func layoutGrid(
686
+ _ children: [UIView],
687
+ innerWidth: CGFloat?,
688
+ roomWidth: CGFloat,
689
+ innerHeight: CGFloat?,
690
+ place: Bool,
691
+ frames: inout [(UIView, CGRect)]
692
+ ) -> CGSize {
286
693
  let columns = max(1, gridColumns)
287
694
  let horizontalGap = columnGap ?? gap
288
695
  let verticalGap = rowGap ?? gap
289
- let cellWidth = max(0, (content.width - CGFloat(max(0, columns - 1)) * horizontalGap) / CGFloat(columns))
696
+ let gaps = CGFloat(columns - 1) * horizontalGap
697
+ var cellWidth: CGFloat
698
+ if let innerWidth {
699
+ cellWidth = max(0, (innerWidth - gaps) / CGFloat(columns))
700
+ } else {
701
+ // Sized by content: the widest cell, as long as the row fits.
702
+ let widest = children.map { child -> CGFloat in
703
+ let style = child.craftLayoutStyle
704
+ return CraftNativeFlexLayout.measure(child, width: style.width?.resolve(nil), height: nil, maxWidth: roomWidth, maxHeight: CraftNativeFlexLayout.unbounded).width
705
+ + style.marginLeft + style.marginRight
706
+ }.max() ?? 0
707
+ cellWidth = min(widest, max(0, (roomWidth - gaps) / CGFloat(columns)))
708
+ }
290
709
  var rowHeights: [CGFloat] = []
291
710
  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
711
+ for (index, child) in children.enumerated() {
712
+ let style = child.craftLayoutStyle
713
+ let available = max(0, cellWidth - style.marginLeft - style.marginRight)
714
+ let align = style.alignSelf ?? alignItems
715
+ let fixedWidth = style.width?.resolve(cellWidth) ?? (align == "stretch" ? available : nil)
716
+ let size = CraftNativeFlexLayout.measure(
717
+ child,
718
+ width: fixedWidth.map { min($0, available) },
719
+ height: style.height?.resolve(nil),
720
+ maxWidth: available,
721
+ maxHeight: CraftNativeFlexLayout.unbounded
298
722
  )
299
723
  sizes.append(size)
300
724
  let row = index / columns
301
725
  while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
302
726
  rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
303
727
  }
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)
728
+ let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
729
+ let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
730
+ if place {
731
+ var rowTop = padding.top
732
+ for (index, child) in children.enumerated() {
733
+ let style = child.craftLayoutStyle
734
+ let row = index / columns
735
+ let column = index % columns
736
+ let rowHeight = rowHeights[row]
737
+ let size = sizes[index]
738
+ let align = style.alignSelf ?? alignItems
739
+ let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
740
+ let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
741
+ let childWidth = min(availableWidth, size.width)
742
+ let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
743
+ let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
744
+ + (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
745
+ let y = rowTop + style.marginTop
746
+ + (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
747
+ frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
748
+ if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + verticalGap }
374
749
  }
375
- return CGSize(width: size.width, height: clamp(max(0, size.height + delta), min: style.minHeight, max: style.maxHeight))
376
750
  }
751
+ return CGSize(width: width, height: innerHeight ?? height)
377
752
  }
753
+ }
378
754
 
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
755
+ private final class CraftNativeModalView: UIView {
756
+ let contentStack = CraftNativeFlowView()
757
+ private let blocker = UIView()
758
+ private var renderedVisible = false
759
+ var onRequestClose: (() -> Void)?
760
+
761
+ var transparent = false {
762
+ didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
384
763
  }
385
764
 
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)
765
+ override init(frame: CGRect) {
766
+ super.init(frame: frame)
767
+ isUserInteractionEnabled = true
768
+ blocker.isUserInteractionEnabled = true
769
+ blocker.backgroundColor = UIColor.black.withAlphaComponent(0.32)
770
+ blocker.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blockerTapped)))
771
+ addSubview(blocker)
772
+ contentStack.backgroundColor = .clear
773
+ addSubview(contentStack)
774
+ }
775
+
776
+ required init?(coder: NSCoder) { nil }
777
+
778
+ func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
779
+ guard renderedVisible != visible || isHidden != !visible else { return }
780
+ renderedVisible = visible
781
+ guard visible else {
782
+ isHidden = true
783
+ alpha = 0
784
+ return
785
+ }
786
+ isHidden = false
787
+ if animationType == "none" {
788
+ alpha = opacity
789
+ } else {
790
+ alpha = 0
791
+ UIView.animate(withDuration: animationType == "slide" ? 0.24 : 0.18) { self.alpha = opacity }
395
792
  }
396
793
  }
794
+
795
+ @objc private func blockerTapped() { onRequestClose?() }
796
+
797
+ override func layoutSubviews() {
798
+ super.layoutSubviews()
799
+ blocker.frame = bounds
800
+ contentStack.frame = bounds
801
+ }
397
802
  }
398
803
 
399
- private final class CraftNativeScrollView: UIScrollView {
804
+ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
400
805
  let contentStack = CraftNativeFlowView()
401
- private var crossAxisConstraint: NSLayoutConstraint?
806
+ private let keyboardTapGesture = UITapGestureRecognizer()
807
+ private var keyboardShouldPersistTaps = "never"
808
+ private var contentNeedsLayout = true
809
+ private var laidOutSize: CGSize = .zero
810
+ private let pullToRefresh = UIRefreshControl()
811
+ private var refreshHandler: (() -> Void)?
402
812
 
403
813
  override init(frame: CGRect) {
404
814
  super.init(frame: frame)
405
- contentStack.translatesAutoresizingMaskIntoConstraints = false
815
+ keyboardTapGesture.cancelsTouchesInView = false
816
+ keyboardTapGesture.delegate = self
817
+ keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
818
+ addGestureRecognizer(keyboardTapGesture)
819
+ pullToRefresh.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
406
820
  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
821
  setAxis(.vertical)
414
822
  }
415
823
 
416
824
  required init?(coder: NSCoder) { nil }
417
825
 
826
+ var isVertical: Bool { contentStack.axis == .vertical }
827
+
828
+ /// Lays the content out again on the next pass. Scrolling alone does not:
829
+ /// a scroll view lays out on every frame of a scroll.
830
+ func setContentNeedsLayout() {
831
+ contentNeedsLayout = true
832
+ setNeedsLayout()
833
+ }
834
+
835
+ /// The size of the content laid out across the visible width (or, sideways,
836
+ /// height), which is also the scroll view's own size when nothing fixes it.
837
+ func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
838
+ if isVertical {
839
+ let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
840
+ return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
841
+ }
842
+ let contentHeight = height.map { max(0, $0 - adjustedContentInset.top - adjustedContentInset.bottom) }
843
+ return contentStack.measure(width: nil, height: contentHeight, maxWidth: CraftNativeFlexLayout.unbounded, maxHeight: contentHeight ?? CraftNativeFlexLayout.unbounded)
844
+ }
845
+
846
+ func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
847
+ if isVertical {
848
+ let resolvedWidth = width ?? maxWidth
849
+ return CGSize(width: resolvedWidth, height: height ?? contentFitting(width: resolvedWidth, height: nil).height)
850
+ }
851
+ let content = contentFitting(width: nil, height: height)
852
+ return CGSize(width: width ?? min(content.width, maxWidth), height: height ?? content.height)
853
+ }
854
+
855
+ override func layoutSubviews() {
856
+ super.layoutSubviews()
857
+ craftApplyCornerRadius()
858
+ if contentNeedsLayout || laidOutSize != bounds.size {
859
+ contentNeedsLayout = false
860
+ laidOutSize = bounds.size
861
+ var size = contentFitting(width: bounds.width, height: bounds.height)
862
+ if isVertical {
863
+ size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
864
+ } else {
865
+ size.height = max(0, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
866
+ }
867
+ let frame = CGRect(origin: .zero, size: size)
868
+ if contentStack.frame != frame { contentStack.frame = frame }
869
+ if contentSize != size { contentSize = size }
870
+ }
871
+ // A scroll view lays out on every frame of a scroll: the sticky
872
+ // headers follow it here, over content placed by this same pass.
873
+ if !stickyIndices.isEmpty || pendingScrollTarget != nil { contentStack.layoutIfNeeded() }
874
+ updateStickyHeaders()
875
+ scrollToPendingTarget()
876
+ }
877
+
878
+ // MARK: Sticky headers
879
+
880
+ private var stickyIndices: [Int] = []
881
+ private var stickyViews: [UIView] = []
882
+
883
+ /// `stickyHeaderIndices`, as in React Native: the content's children at
884
+ /// these indices stay at the top of the visible area once scrolled to,
885
+ /// each pushed up by the next. Vertical scroll views only.
886
+ func setStickyHeaderIndices(_ value: Any?) {
887
+ let next = Array(Set(((value as? [Any]) ?? []).compactMap { ($0 as? NSNumber)?.intValue }.filter { $0 >= 0 })).sorted()
888
+ guard next != stickyIndices else { return }
889
+ stickyIndices = next
890
+ setNeedsLayout()
891
+ }
892
+
893
+ /// Where a header sits in the content when it is not stuck.
894
+ private func naturalTop(_ view: UIView) -> CGFloat {
895
+ view.center.y - view.bounds.height / 2
896
+ }
897
+
898
+ private func updateStickyHeaders() {
899
+ let children = contentStack.arrangedSubviews
900
+ let headers = isVertical ? stickyIndices.compactMap { $0 < children.count ? children[$0] : nil }.filter { !$0.isHidden } : []
901
+ for view in stickyViews where !headers.contains(where: { $0 === view }) {
902
+ view.transform = .identity
903
+ view.layer.zPosition = 0
904
+ }
905
+ stickyViews = headers
906
+ guard !headers.isEmpty else { return }
907
+ let visibleTop = contentOffset.y + adjustedContentInset.top
908
+ for (index, header) in headers.enumerated() {
909
+ let natural = naturalTop(header)
910
+ var top = max(natural, visibleTop)
911
+ if index + 1 < headers.count {
912
+ top = max(natural, min(top, naturalTop(headers[index + 1]) - header.bounds.height))
913
+ }
914
+ let shift = CGAffineTransform(translationX: 0, y: top - natural)
915
+ if header.transform != shift { header.transform = shift }
916
+ // Above the content it covers, for drawing and for touches.
917
+ header.layer.zPosition = 1
918
+ if contentStack.subviews.last !== header { contentStack.bringSubviewToFront(header) }
919
+ }
920
+ }
921
+
922
+ /// The height a stuck header covers above a point of the content.
923
+ private func stickyHeight(above y: CGFloat) -> CGFloat {
924
+ stickyViews.last(where: { naturalTop($0) <= y })?.bounds.height ?? 0
925
+ }
926
+
927
+ // MARK: Scroll targets
928
+
929
+ private var scrollTargetKey: String?
930
+ private var pendingScrollTarget: (id: String, animated: Bool, attempts: Int)?
931
+
932
+ /// `scrollTarget`: `"<testID>"` or `{ id, animated?, key? }`. When it
933
+ /// changes, the content scrolls so the view with that `testID` sits at
934
+ /// the top of the visible area, below any sticky header. A new `key`
935
+ /// scrolls to the same view again; `null` forgets the last target.
936
+ func setScrollTarget(_ value: Any?) {
937
+ var id: String?
938
+ var animated = true
939
+ if let text = value as? String {
940
+ id = text
941
+ } else if let target = value as? [String: Any] {
942
+ id = target["id"] as? String
943
+ animated = target["animated"] as? Bool ?? true
944
+ }
945
+ guard let id, !id.isEmpty else {
946
+ scrollTargetKey = nil
947
+ pendingScrollTarget = nil
948
+ return
949
+ }
950
+ let key: String
951
+ if let object = value as? [String: Any],
952
+ let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) {
953
+ key = String(decoding: data, as: UTF8.self)
954
+ } else {
955
+ key = id
956
+ }
957
+ guard key != scrollTargetKey else { return }
958
+ scrollTargetKey = key
959
+ pendingScrollTarget = (id, animated, 0)
960
+ setNeedsLayout()
961
+ }
962
+
963
+ private func descendant(withIdentifier id: String, in view: UIView) -> UIView? {
964
+ for child in view.subviews {
965
+ if child.accessibilityIdentifier == id { return child }
966
+ if let match = descendant(withIdentifier: id, in: child) { return match }
967
+ }
968
+ return nil
969
+ }
970
+
971
+ private func scrollToPendingTarget() {
972
+ guard let target = pendingScrollTarget, bounds.height > 0 else { return }
973
+ pendingScrollTarget = nil
974
+ let view = descendant(withIdentifier: target.id, in: contentStack)
975
+ // A target the same render created may not be in place yet (this pass
976
+ // can run while the render is applied): try again on the next passes.
977
+ guard let view, view.bounds.size != .zero else {
978
+ if target.attempts < 3 {
979
+ pendingScrollTarget = (target.id, target.animated, target.attempts + 1)
980
+ DispatchQueue.main.async { [weak self] in self?.setNeedsLayout() }
981
+ }
982
+ return
983
+ }
984
+ let rect = view.convert(view.bounds, to: contentStack)
985
+ if isVertical {
986
+ let lowest = -adjustedContentInset.top
987
+ let highest = max(lowest, contentSize.height + adjustedContentInset.bottom - bounds.height)
988
+ let y = min(highest, max(lowest, rect.minY - adjustedContentInset.top - stickyHeight(above: rect.minY)))
989
+ setContentOffset(CGPoint(x: contentOffset.x, y: y), animated: target.animated)
990
+ } else {
991
+ let lowest = -adjustedContentInset.left
992
+ let highest = max(lowest, contentSize.width + adjustedContentInset.right - bounds.width)
993
+ let x = min(highest, max(lowest, rect.minX - adjustedContentInset.left))
994
+ setContentOffset(CGPoint(x: x, y: contentOffset.y), animated: target.animated)
995
+ }
996
+ }
997
+
998
+ override func adjustedContentInsetDidChange() {
999
+ super.adjustedContentInsetDidChange()
1000
+ setContentNeedsLayout()
1001
+ }
1002
+
1003
+ private var refreshingProp = false
1004
+
1005
+ /// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
1006
+ /// as in React Native. The spinner shows while `refreshing` is true; a
1007
+ /// pull that the screen does not answer with `refreshing` true ends at
1008
+ /// once instead of spinning forever.
1009
+ func setRefreshHandler(_ handler: (() -> Void)?, refreshing: Bool) {
1010
+ refreshHandler = handler
1011
+ refreshingProp = refreshing
1012
+ if handler == nil {
1013
+ if pullToRefresh.isRefreshing { pullToRefresh.endRefreshing() }
1014
+ if refreshControl === pullToRefresh { refreshControl = nil }
1015
+ return
1016
+ }
1017
+ if refreshControl !== pullToRefresh { refreshControl = pullToRefresh }
1018
+ if refreshing, !pullToRefresh.isRefreshing {
1019
+ pullToRefresh.beginRefreshing()
1020
+ } else if !refreshing, pullToRefresh.isRefreshing {
1021
+ pullToRefresh.endRefreshing()
1022
+ }
1023
+ }
1024
+
1025
+ var isRefreshingNow: Bool { pullToRefresh.isRefreshing }
1026
+
1027
+ @objc private func refreshPulled() {
1028
+ refreshHandler?()
1029
+ // The handler's synchronous render has landed by now.
1030
+ DispatchQueue.main.async { [weak self] in
1031
+ guard let self, !self.refreshingProp, self.pullToRefresh.isRefreshing else { return }
1032
+ self.pullToRefresh.endRefreshing()
1033
+ }
1034
+ }
1035
+
1036
+ func setKeyboardShouldPersistTaps(_ value: Any?) {
1037
+ let requested = value as? String
1038
+ keyboardShouldPersistTaps = ["always", "handled", "never"].contains(requested) ? requested! : "never"
1039
+ keyboardTapGesture.isEnabled = keyboardShouldPersistTaps != "always"
1040
+ }
1041
+
1042
+ @objc private func keyboardTap() {
1043
+ guard keyboardShouldPersistTaps != "always" else { return }
1044
+ if keyboardShouldPersistTaps == "handled" {
1045
+ let point = keyboardTapGesture.location(in: self)
1046
+ var hit = hitTest(point, with: nil)
1047
+ while let view = hit, view !== self {
1048
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
1049
+ hit = view.superview
1050
+ }
1051
+ }
1052
+ window?.endEditing(true)
1053
+ }
1054
+
1055
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
1056
+ true
1057
+ }
1058
+
418
1059
  func setAxis(_ axis: NSLayoutConstraint.Axis) {
1060
+ if contentStack.axis != axis { setContentNeedsLayout() }
419
1061
  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
1062
  alwaysBounceVertical = axis == .vertical
426
1063
  alwaysBounceHorizontal = axis == .horizontal
427
1064
  }
428
1065
  }
429
1066
 
1067
+ /// Text with padding of its own: the padding is inside the label, around
1068
+ /// the text, and counts in its measured size.
1069
+ private final class CraftNativeLabel: UILabel {
1070
+ var insets = UIEdgeInsets.zero {
1071
+ didSet {
1072
+ guard insets != oldValue else { return }
1073
+ invalidateIntrinsicContentSize()
1074
+ setNeedsDisplay()
1075
+ }
1076
+ }
1077
+
1078
+ override func drawText(in rect: CGRect) {
1079
+ super.drawText(in: rect.inset(by: insets))
1080
+ }
1081
+
1082
+ override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect {
1083
+ guard insets != .zero else { return super.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines) }
1084
+ let inner = super.textRect(forBounds: bounds.inset(by: insets), limitedToNumberOfLines: numberOfLines)
1085
+ return CGRect(
1086
+ x: inner.minX - insets.left,
1087
+ y: inner.minY - insets.top,
1088
+ width: inner.width + insets.left + insets.right,
1089
+ height: inner.height + insets.top + insets.bottom
1090
+ )
1091
+ }
1092
+
1093
+ override func layoutSubviews() {
1094
+ super.layoutSubviews()
1095
+ craftApplyCornerRadius()
1096
+ }
1097
+ }
1098
+
430
1099
  private final class CraftNativeFlexSpacer: UIView {}
431
1100
 
1101
+ /// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
1102
+ /// when the style gives it no size at all.
1103
+ private final class CraftNativeIconView: UIImageView {
1104
+ var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
1105
+
1106
+ override var intrinsicContentSize: CGSize {
1107
+ if let defaultBox { return CGSize(width: defaultBox, height: defaultBox) }
1108
+ return super.intrinsicContentSize
1109
+ }
1110
+ }
1111
+
432
1112
  private final class CraftNativeTextView: UITextView {
433
1113
  private let placeholderLabel = UILabel()
434
1114
  var placeholder: String? {
@@ -482,7 +1162,7 @@ private final class CraftNativeTextView: UITextView {
482
1162
  }
483
1163
  }
484
1164
 
485
- final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate {
1165
+ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
486
1166
  private final class RenderedNode {
487
1167
  let identity: String
488
1168
  let type: String
@@ -506,6 +1186,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
506
1186
  private let jsContext = JSContext()!
507
1187
  private let rootStack = CraftNativeFlowView()
508
1188
  private var handlers: [ObjectIdentifier: String] = [:]
1189
+ private var pressInHandlers: [ObjectIdentifier: String] = [:]
1190
+ private var pressOutHandlers: [ObjectIdentifier: String] = [:]
509
1191
  private var focusHandlers: [ObjectIdentifier: String] = [:]
510
1192
  private var blurHandlers: [ObjectIdentifier: String] = [:]
511
1193
  private var endEditingHandlers: [ObjectIdentifier: String] = [:]
@@ -521,10 +1203,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
521
1203
  private var scrollMomentumEndHandlers: [ObjectIdentifier: String] = [:]
522
1204
  private var sliderCompleteHandlers: [ObjectIdentifier: String] = [:]
523
1205
  private var sliderSteps: [ObjectIdentifier: Float] = [:]
1206
+ private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
1207
+ private var modalVisibility: [ObjectIdentifier: Bool] = [:]
524
1208
  private var layoutHandlers: [ObjectIdentifier: String] = [:]
525
1209
  private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
526
1210
  private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
527
1211
  private var longPressRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
1212
+ private var pressFeedbackRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
528
1213
  private var imageSources: [ObjectIdentifier: String] = [:]
529
1214
  private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
530
1215
  private var imageErrors: [ObjectIdentifier: String] = [:]
@@ -541,8 +1226,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
541
1226
  private let capabilityScope = UUID().uuidString
542
1227
  private var pendingCapabilityRequests = Set<String>()
543
1228
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
1229
+ private var pendingTimers: [Int: DispatchWorkItem] = [:]
544
1230
  private var lifecycleObservers: [NSObjectProtocol] = []
545
1231
  private var deepLinkListener: UUID?
1232
+ private var keyboardSafeAreaInset: CGFloat = 0
1233
+ /// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
1234
+ /// app's native screens: told of the first frame, of a first frame that
1235
+ /// could not be drawn, and of `craft.navigation.open(path)`.
1236
+ weak var hybridEvents: CraftHybridScreenEvents?
1237
+ private var hybridRendered = false
546
1238
 
547
1239
  init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
548
1240
  self.config = config
@@ -558,6 +1250,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
558
1250
  deinit {
559
1251
  imageTasks.values.forEach { $0.cancel() }
560
1252
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
1253
+ pendingTimers.values.forEach { $0.cancel() }
561
1254
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
562
1255
  lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
563
1256
  if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
@@ -571,8 +1264,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
571
1264
  rootStack.alignItems = "stretch"
572
1265
  rootStack.translatesAutoresizingMaskIntoConstraints = false
573
1266
  view.addSubview(rootStack)
1267
+ rootTopToSafeArea = rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
1268
+ rootTopToView = rootStack.topAnchor.constraint(equalTo: view.topAnchor)
574
1269
  NSLayoutConstraint.activate([
575
- rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
1270
+ rootTopToSafeArea!,
576
1271
  rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
577
1272
  rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
578
1273
  rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
@@ -586,10 +1281,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
586
1281
  super.traitCollectionDidChange(previousTraitCollection)
587
1282
  guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
588
1283
  refreshTraitDefaults()
1284
+ send(type: "APPEARANCE", payload: ["colorScheme": colorScheme])
589
1285
  }
590
1286
 
591
1287
  override func viewWillAppear(_ animated: Bool) {
592
1288
  super.viewWillAppear(animated)
1289
+ if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
1290
+ navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
1291
+ } else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
1292
+ navigationController?.viewControllers.first !== self || wantsNavigationBar {
1293
+ // A screen that did not ask for a hidden bar shows one, even after
1294
+ // a screen that hid its own.
1295
+ navigationController?.setNavigationBarHidden(false, animated: animated)
1296
+ }
593
1297
  refreshTraitDefaults()
594
1298
  }
595
1299
 
@@ -619,13 +1323,34 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
619
1323
  jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
620
1324
  }
621
1325
  jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
622
- jsContext.exceptionHandler = { _, exception in
623
- NSLog("[craft native] JavaScript exception: %@", exception?.toString() ?? "unknown")
1326
+ jsContext.exceptionHandler = { [weak self] context, exception in
1327
+ let message = exception?.toString() ?? "unknown"
1328
+ let stack = exception?.objectForKeyedSubscript("stack")?.toString() ?? ""
1329
+ CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
1330
+ self?.hybridFailed(message)
624
1331
  }
625
1332
  let postMessage: @convention(block) (String) -> Void = { [weak self] json in
626
1333
  self?.receive(json)
627
1334
  }
628
1335
  jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
1336
+ // JavaScriptCore has no timers of its own. The callbacks stay in
1337
+ // JavaScript; native only owns the main-queue deadline, so route
1338
+ // teardown cancels every pending timer with the controller.
1339
+ let scheduleTimer: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
1340
+ guard let self = self else { return }
1341
+ let work = DispatchWorkItem { [weak self] in
1342
+ guard let self = self, self.pendingTimers.removeValue(forKey: id) != nil else { return }
1343
+ self.jsContext.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
1344
+ }
1345
+ self.pendingTimers[id] = work
1346
+ let milliseconds = delay.isFinite ? Int(min(max(delay, 0), 2_147_483_647)) : 0
1347
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
1348
+ }
1349
+ let cancelTimer: @convention(block) (Int) -> Void = { [weak self] id in
1350
+ self?.pendingTimers.removeValue(forKey: id)?.cancel()
1351
+ }
1352
+ jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
1353
+ jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
629
1354
  let capabilities = CraftNativeActions.capabilities(config: config)
630
1355
  let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
631
1356
  let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
@@ -638,12 +1363,212 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
638
1363
  capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
639
1364
  capabilities: \(capabilityJSON),
640
1365
  initialAppState: "\(CraftNativeActions.currentAppState())",
1366
+ colorScheme: "\(colorScheme)",
641
1367
  postMessage: function(message) { craftNativePostMessage(message); },
642
1368
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
643
1369
  };
644
- globalThis.console = {
645
- log: function() {}, warn: function() {}, error: function() {}
646
- };
1370
+ """)
1371
+ installHostAPIs()
1372
+ jsContext.evaluateScript("""
1373
+ if (typeof globalThis.setTimeout !== 'function') {
1374
+ (function() {
1375
+ var timers = new Map();
1376
+ var nextTimer = 0;
1377
+ globalThis.setTimeout = function(callback, delay) {
1378
+ var args = Array.prototype.slice.call(arguments, 2);
1379
+ var id = ++nextTimer;
1380
+ timers.set(id, function() {
1381
+ if (typeof callback === 'function') callback.apply(undefined, args);
1382
+ });
1383
+ craftNativeScheduleTimer(id, Number(delay) || 0);
1384
+ return id;
1385
+ };
1386
+ globalThis.clearTimeout = function(id) {
1387
+ if (timers.delete(id)) craftNativeCancelTimer(id);
1388
+ };
1389
+ globalThis.__craftNativeFireTimer = function(id) {
1390
+ var callback = timers.get(id);
1391
+ if (!callback) return;
1392
+ timers.delete(id);
1393
+ callback();
1394
+ };
1395
+ })();
1396
+ }
1397
+ // Repeating timers, on the one-shot ones: each tick schedules the
1398
+ // next before running, so a callback that throws or clears itself
1399
+ // still behaves, and a screen that closes cancels them with the rest.
1400
+ if (typeof globalThis.setInterval !== 'function') {
1401
+ (function() {
1402
+ var intervals = new Map();
1403
+ var nextInterval = 0;
1404
+ globalThis.setInterval = function(callback, delay) {
1405
+ var args = Array.prototype.slice.call(arguments, 2);
1406
+ var id = ++nextInterval;
1407
+ var ms = Math.max(Number(delay) || 0, 1);
1408
+ var tick = function() {
1409
+ if (!intervals.has(id)) return;
1410
+ intervals.set(id, globalThis.setTimeout(tick, ms));
1411
+ if (typeof callback === 'function') callback.apply(undefined, args);
1412
+ };
1413
+ intervals.set(id, globalThis.setTimeout(tick, ms));
1414
+ return id;
1415
+ };
1416
+ globalThis.clearInterval = function(id) {
1417
+ var timer = intervals.get(id);
1418
+ if (timer === undefined) return;
1419
+ intervals.delete(id);
1420
+ globalThis.clearTimeout(timer);
1421
+ };
1422
+ })();
1423
+ }
1424
+ """)
1425
+ }
1426
+
1427
+ /// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
1428
+ /// the window's appearance once there is one, else the app's setting.
1429
+ private var colorScheme: String {
1430
+ let traits = [view.window?.traitCollection, CraftPresenter.keyWindow()?.traitCollection, traitCollection]
1431
+ if let style = traits.compactMap({ $0 }).first(where: { $0.userInterfaceStyle != .unspecified })?.userInterfaceStyle {
1432
+ return style == .dark ? "dark" : "light"
1433
+ }
1434
+ return config.appearance == "dark" ? "dark" : "light"
1435
+ }
1436
+
1437
+ /// The screen's name as its script knows it: the route the host opened,
1438
+ /// else the one a route bundle selected for itself.
1439
+ private func screenName(in context: JSContext? = nil) -> String {
1440
+ if let routeName, !routeName.isEmpty { return routeName }
1441
+ if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
1442
+ return selected.toString()
1443
+ }
1444
+ return "screen"
1445
+ }
1446
+
1447
+ /// Host functions every screen script can call synchronously, installed
1448
+ /// before the script runs (contract sections 3, 4 and 5):
1449
+ ///
1450
+ /// - `console.*` writes to the unified log, subsystem `craft.native`,
1451
+ /// category the screen's name.
1452
+ /// - `craft.storage.getSync(key)` / `setSync(key, value)`: the store the
1453
+ /// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
1454
+ /// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
1455
+ /// promise): `Application Support/craft-snapshots/<name>.json`.
1456
+ /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
1457
+ /// `craft.secureStorage.set` wrote.
1458
+ /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
1459
+ /// rightButtons })`; a right button's tap is the event `navButton`.
1460
+ ///
1461
+ /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
1462
+ /// these: each namespace is a property whose setter adds them back to the
1463
+ /// object it is given.
1464
+ private func installHostAPIs() {
1465
+ let log: @convention(block) (String, String, String) -> Void = { level, category, message in
1466
+ CraftNativeConsole.write(level, category: category, message: message)
1467
+ }
1468
+ let storageGet: @convention(block) (String) -> String? = { key in
1469
+ CraftNativeActions.storageJSON(forKey: key)
1470
+ }
1471
+ let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
1472
+ CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
1473
+ }
1474
+ let snapshotGet: @convention(block) (String) -> String? = { name in
1475
+ CraftSnapshots.read(name)
1476
+ }
1477
+ let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
1478
+ CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
1479
+ done.call(withArguments: [ok])
1480
+ }
1481
+ }
1482
+ let config = self.config
1483
+ let secureGet: @convention(block) (String) -> String? = { key in
1484
+ config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
1485
+ }
1486
+ let setOptions: @convention(block) (String) -> Void = { [weak self] json in
1487
+ guard let data = json.data(using: .utf8),
1488
+ let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
1489
+ self?.applyNavigationOptions(options)
1490
+ }
1491
+ jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
1492
+ jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
1493
+ jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
1494
+ jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
1495
+ jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
1496
+ jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
1497
+ jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
1498
+ jsContext.evaluateScript("""
1499
+ (function() {
1500
+ function text(value) {
1501
+ if (typeof value === 'string') return value;
1502
+ if (value === undefined) return 'undefined';
1503
+ if (value instanceof Error) return value.stack ? value.message + '\\n' + value.stack : String(value);
1504
+ try { var json = JSON.stringify(value); return json === undefined ? String(value) : json; }
1505
+ catch (error) { return String(value); }
1506
+ }
1507
+ function screen() {
1508
+ var route = globalThis.__stxNativeRoute;
1509
+ return typeof route === 'string' && route ? route : 'screen';
1510
+ }
1511
+ function writer(level) {
1512
+ return function() {
1513
+ craftNativeLog(level, screen(), Array.prototype.map.call(arguments, text).join(' '));
1514
+ };
1515
+ }
1516
+ globalThis.console = {
1517
+ log: writer('log'), info: writer('info'), debug: writer('debug'), trace: writer('debug'),
1518
+ warn: writer('warn'), error: writer('error')
1519
+ };
1520
+
1521
+ function parsed(json) {
1522
+ if (json === null || json === undefined) return null;
1523
+ try { return JSON.parse(json); } catch (error) { return null; }
1524
+ }
1525
+ function encoded(value) {
1526
+ return value === null || value === undefined ? null : JSON.stringify(value);
1527
+ }
1528
+ var craft = globalThis.craft = globalThis.craft || {};
1529
+ function keep(name, extras) {
1530
+ var current = {};
1531
+ function adopt(next) {
1532
+ current = next && typeof next === 'object' ? next : {};
1533
+ Object.keys(extras).forEach(function(key) {
1534
+ if (!(key in current)) current[key] = extras[key];
1535
+ });
1536
+ }
1537
+ adopt(craft[name]);
1538
+ Object.defineProperty(craft, name, {
1539
+ configurable: true,
1540
+ enumerable: true,
1541
+ get: function() { return current; },
1542
+ set: adopt
1543
+ });
1544
+ }
1545
+ keep('storage', {
1546
+ getSync: function(key) { return parsed(craftNativeStorageGet(String(key))); },
1547
+ setSync: function(key, value) {
1548
+ if (!craftNativeStorageSet(String(key), encoded(value))) throw new Error('craft.storage.setSync could not store ' + key);
1549
+ }
1550
+ });
1551
+ keep('snapshots', {
1552
+ get: function(name) { return parsed(craftNativeSnapshotGet(String(name))); },
1553
+ set: function(name, value) {
1554
+ return new Promise(function(resolve, reject) {
1555
+ craftNativeSnapshotSet(String(name), encoded(value), function(ok) {
1556
+ if (ok) resolve();
1557
+ else reject(new Error('craft.snapshots.set could not write ' + name));
1558
+ });
1559
+ });
1560
+ }
1561
+ });
1562
+ keep('secureStorage', {
1563
+ getSync: function(key) {
1564
+ var value = craftNativeSecureGet(String(key));
1565
+ return value === undefined ? null : value;
1566
+ }
1567
+ });
1568
+ keep('navigation', {
1569
+ setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
1570
+ });
1571
+ })();
647
1572
  """)
648
1573
  }
649
1574
 
@@ -651,15 +1576,123 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
651
1576
  guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
652
1577
  ?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
653
1578
  let script = try? String(contentsOf: url, encoding: .utf8) else {
654
- showError("Missing dist/native-screen.js. Compile a .stx screen with stx-native first.")
1579
+ showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
655
1580
  return
656
1581
  }
657
1582
  jsContext.evaluateScript(script)
658
- if routeName == nil, let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute")?.toString() {
659
- navigationItem.title = selected
1583
+ // A bundle that names no route leaves `__stxNativeRoute` undefined;
1584
+ // its title is the app's, not the word "undefined". A title the
1585
+ // script set with `navigation.setOptions` wins over both.
1586
+ if routeName == nil, !titleFromOptions,
1587
+ let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
1588
+ let name = selected.toString(), !name.isEmpty {
1589
+ navigationItem.title = name
1590
+ }
1591
+ }
1592
+
1593
+ // MARK: Navigation bar options
1594
+
1595
+ private var titleFromOptions = false
1596
+ private var rootTopToSafeArea: NSLayoutConstraint?
1597
+ private var rootTopToView: NSLayoutConstraint?
1598
+ private var navigationBarHidden: Bool?
1599
+ private var prefersLargeTitle: Bool?
1600
+
1601
+ /// Whether this screen asked for a bar of its own through `setOptions`: a
1602
+ /// title, a large title or buttons, and not `hidden`. A tab's root screen
1603
+ /// in a hybrid app is shown bare unless it did.
1604
+ var wantsNavigationBar: Bool {
1605
+ if let navigationBarHidden { return !navigationBarHidden }
1606
+ return titleFromOptions || prefersLargeTitle == true || !(navigationItem.rightBarButtonItems ?? []).isEmpty
1607
+ }
1608
+ private weak var trackedScrollView: UIScrollView?
1609
+
1610
+ /// `craft.navigation.setOptions`: this screen's bar. Every key is
1611
+ /// optional and only the keys given change.
1612
+ private func applyNavigationOptions(_ options: [String: Any]) {
1613
+ if options.keys.contains("title") {
1614
+ navigationItem.title = options["title"] as? String
1615
+ titleFromOptions = true
1616
+ }
1617
+ if options.keys.contains("backTitle") {
1618
+ navigationItem.backButtonTitle = options["backTitle"] as? String
1619
+ }
1620
+ if let largeTitle = options["largeTitle"] as? Bool {
1621
+ prefersLargeTitle = largeTitle
1622
+ navigationItem.largeTitleDisplayMode = largeTitle ? .always : .never
1623
+ if largeTitle { navigationController?.navigationBar.prefersLargeTitles = true }
1624
+ trackContentScrollView()
1625
+ }
1626
+ if let hidden = options["hidden"] as? Bool {
1627
+ navigationBarHidden = hidden
1628
+ if navigationController?.topViewController === self {
1629
+ navigationController?.setNavigationBarHidden(hidden, animated: view.window != nil)
1630
+ }
1631
+ }
1632
+ if options.keys.contains("rightButtons") {
1633
+ let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
1634
+ navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
1635
+ }
1636
+ // Asked for after the bar was hidden for a bare root screen.
1637
+ if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
1638
+ navigationController?.isNavigationBarHidden == true {
1639
+ navigationController?.setNavigationBarHidden(false, animated: view.window != nil)
660
1640
  }
661
1641
  }
662
1642
 
1643
+ /// A bar button from `{ id, symbol | title }`. Its tap is the event
1644
+ /// `navButton` with `{ id }`.
1645
+ private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
1646
+ guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
1647
+ let action = UIAction { [weak self] _ in
1648
+ self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": id]])
1649
+ }
1650
+ let item: UIBarButtonItem
1651
+ if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
1652
+ item = UIBarButtonItem(image: image, primaryAction: action)
1653
+ } else if let title = spec["title"] as? String {
1654
+ item = UIBarButtonItem(title: title, primaryAction: action)
1655
+ } else {
1656
+ return nil
1657
+ }
1658
+ item.accessibilityIdentifier = "nav-\(id)"
1659
+ if let label = (spec["accessibilityLabel"] as? String) ?? (spec["title"] as? String) {
1660
+ item.accessibilityLabel = label
1661
+ } else {
1662
+ item.accessibilityLabel = id
1663
+ }
1664
+ if let tint = (spec["color"] as? String).flatMap({ UIColor(hex: $0) }) { item.tintColor = tint }
1665
+ return item
1666
+ }
1667
+
1668
+ /// The first vertical scroll view of the screen drives the bar: a large
1669
+ /// title collapses as it scrolls, and the bar's background follows it.
1670
+ private func trackContentScrollView() {
1671
+ guard let renderedRoot else { return }
1672
+ func find(_ node: RenderedNode) -> UIScrollView? {
1673
+ if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical, scroll.isScrollEnabled { return scroll }
1674
+ if let list = node.view as? CraftNativeFlatList, list.isScrollEnabled,
1675
+ (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { return list }
1676
+ for child in node.children {
1677
+ if let match = find(child) { return match }
1678
+ }
1679
+ return nil
1680
+ }
1681
+ let scroll = find(renderedRoot)
1682
+ // Under a large title the screen runs edge to edge, as UIKit's own
1683
+ // screens do: the scroll view starts under the bar and insets its
1684
+ // content by it, so the title collapses and pull to refresh draws
1685
+ // in the bar's space. Other screens stay inside the safe area.
1686
+ let edgeToEdge = scroll != nil && prefersLargeTitle == true
1687
+ if rootTopToView?.isActive != edgeToEdge {
1688
+ rootTopToSafeArea?.isActive = !edgeToEdge
1689
+ rootTopToView?.isActive = edgeToEdge
1690
+ }
1691
+ guard scroll !== trackedScrollView else { return }
1692
+ trackedScrollView = scroll
1693
+ setContentScrollView(scroll, for: .top)
1694
+ }
1695
+
663
1696
  private func observeNativeEvents() {
664
1697
  func observe(_ name: Notification.Name, state: String) {
665
1698
  lifecycleObservers.append(NotificationCenter.default.addObserver(
@@ -671,6 +1704,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
671
1704
  observe(UIApplication.didBecomeActiveNotification, state: "active")
672
1705
  observe(UIApplication.willResignActiveNotification, state: "inactive")
673
1706
  observe(UIApplication.didEnterBackgroundNotification, state: "background")
1707
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
1708
+ forName: UIResponder.keyboardWillChangeFrameNotification,
1709
+ object: nil,
1710
+ queue: .main
1711
+ ) { [weak self] notification in
1712
+ self?.applyKeyboardSafeArea(notification)
1713
+ })
1714
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
1715
+ forName: UIResponder.keyboardWillHideNotification,
1716
+ object: nil,
1717
+ queue: .main
1718
+ ) { [weak self] notification in
1719
+ self?.applyKeyboardSafeArea(notification, hidden: true)
1720
+ })
674
1721
  sendAppState(CraftNativeActions.currentAppState())
675
1722
  deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
676
1723
  guard let self = self, self.navigationController?.topViewController === self else { return }
@@ -680,6 +1727,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
680
1727
  }
681
1728
  }
682
1729
 
1730
+ private func applyKeyboardSafeArea(_ notification: Notification, hidden: Bool = false) {
1731
+ let inset: CGFloat
1732
+ if hidden {
1733
+ inset = 0
1734
+ } else if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
1735
+ let keyboardFrame = view.convert(frame.cgRectValue, from: nil)
1736
+ inset = max(0, view.bounds.intersection(keyboardFrame).height - view.safeAreaInsets.bottom)
1737
+ } else {
1738
+ return
1739
+ }
1740
+ guard abs(inset - keyboardSafeAreaInset) > 0.5 else { return }
1741
+ keyboardSafeAreaInset = inset
1742
+ let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
1743
+ let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
1744
+ UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
1745
+ self.additionalSafeAreaInsets.bottom = inset
1746
+ self.view.layoutIfNeeded()
1747
+ }
1748
+ }
1749
+
683
1750
  private func sendAppState(_ state: String) {
684
1751
  guard navigationController?.topViewController === self else { return }
685
1752
  send(type: "APP_STATE", payload: ["state": state])
@@ -694,6 +1761,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
694
1761
  rootStack.addArrangedSubview(label)
695
1762
  rootStack.setLayoutStyle(["alignSelf": "stretch"], for: label)
696
1763
  NSLog("[craft native] %@", message)
1764
+ hybridFailed(message)
1765
+ }
1766
+
1767
+ /// Only before the first frame: later exceptions belong to a screen that
1768
+ /// is up, and are logged.
1769
+ private func hybridFailed(_ message: String) {
1770
+ guard !hybridRendered else { return }
1771
+ hybridEvents?.nativeScreen(self, didFail: message)
697
1772
  }
698
1773
 
699
1774
  private func receive(_ raw: String) {
@@ -737,11 +1812,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
737
1812
  let navigation = navigationController else { return }
738
1813
  let params = payload["params"] as? [String: Any] ?? [:]
739
1814
  let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
1815
+ next.hybridEvents = hybridEvents
740
1816
  if type == "NAVIGATE" {
741
1817
  navigation.pushViewController(next, animated: true)
742
1818
  } else {
743
1819
  navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
744
1820
  }
1821
+ case "NAVIGATION_SET_OPTIONS":
1822
+ applyNavigationOptions(payload)
1823
+ case "NAVIGATE_OPEN":
1824
+ // A path rather than a screen: the hybrid shell opens it, natively
1825
+ // or in the web view.
1826
+ guard let path = payload["path"] as? String, !path.isEmpty else { return }
1827
+ hybridEvents?.nativeScreen(self, open: path)
745
1828
  case "NAVIGATE_BACK":
746
1829
  if navigationController?.topViewController === self {
747
1830
  navigationController?.popViewController(animated: true)
@@ -804,10 +1887,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
804
1887
  }
805
1888
  }
806
1889
 
1890
+ /// Runs script in the screen's JavaScript context. Internal for
1891
+ /// simulator-hosted XCTest of the host APIs.
1892
+ @discardableResult
1893
+ func evaluateScript(_ script: String) -> JSValue? {
1894
+ jsContext.evaluateScript(script)
1895
+ }
1896
+
807
1897
  // Internal so simulator-hosted XCTest can assert actual UIView identity.
808
1898
  func render(_ document: [String: Any]) {
809
1899
  mutationDocument.replace(with: document)
810
1900
  renderCommitted(document)
1901
+ if !hybridRendered {
1902
+ hybridRendered = true
1903
+ hybridEvents?.nativeScreenDidRender(self)
1904
+ }
811
1905
  }
812
1906
 
813
1907
  @discardableResult
@@ -825,7 +1919,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
825
1919
  let target = flatListOwners[id] ?? id
826
1920
  guard applied.insert(target).inserted else { continue }
827
1921
  guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
828
- _ = reconcile(document, identity: previous.identity, path: target, previous: previous)
1922
+ let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
1923
+ // A style patch is applied in place, layout included: the parent
1924
+ // reads the node's new layout style on its next pass.
1925
+ CraftNativeFlexLayout.invalidateAncestors(of: next.view)
829
1926
  }
830
1927
  restoreFocus(focused)
831
1928
  return true
@@ -846,10 +1943,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
846
1943
  let next = reconcile(document, identity: "root", path: "root", previous: previous)
847
1944
  if previous?.view !== next.view {
848
1945
  if let old = previous { detach(old, from: rootStack) }
849
- rootStack.addArrangedSubview(next.view)
850
1946
  }
1947
+ // A failed or interrupted reconciliation can leave an older root
1948
+ // attached even though `renderedRoot` points at the latest tree. Keep
1949
+ // the host single-rooted so accessibility queries and event routing
1950
+ // never see stale copies of the native screen.
1951
+ for child in rootStack.arrangedSubviews where child !== next.view {
1952
+ rootStack.removeArrangedSubview(child)
1953
+ child.removeFromSuperview()
1954
+ }
1955
+ if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }
851
1956
  rootStack.setLayoutStyle(next.style, for: next.view)
1957
+ rootStack.invalidateMeasurements()
852
1958
  renderedRoot = next
1959
+ trackContentScrollView()
853
1960
  restoreFocus(focused)
854
1961
  }
855
1962
 
@@ -890,7 +1997,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
890
1997
  current.protocolId = node["id"] as? String
891
1998
  current.style = style
892
1999
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
893
- if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" {
2000
+ if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
894
2001
  result.setContentHuggingPriority(.required, for: .vertical)
895
2002
  }
896
2003
  applyViewStyle(style, to: result, node: current)
@@ -901,6 +2008,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
901
2008
  let label = result as! UILabel
902
2009
  label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
903
2010
  label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
2011
+ (label as? CraftNativeLabel)?.insets = paddingInsets(style)
904
2012
  configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
905
2013
  case "Button", "Link":
906
2014
  let button = result as! UIButton
@@ -911,6 +2019,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
911
2019
  style,
912
2020
  default: .systemFont(ofSize: 16)
913
2021
  )
2022
+ // The plain title too, so `title(for:)` and accessibility read it.
2023
+ if button.title(for: .normal) != transformedTitle { button.setTitle(transformedTitle, for: .normal) }
914
2024
  button.setAttributedTitle(NSAttributedString(
915
2025
  string: transformedTitle,
916
2026
  attributes: buttonTitleAttributes(style, color: titleColor, font: font, forceUnderline: type == "Link")
@@ -962,10 +2072,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
962
2072
  if field.returnKeyType != desiredReturnKeyType { field.returnKeyType = desiredReturnKeyType }
963
2073
  let desiredAutocorrection: UITextAutocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
964
2074
  if field.autocorrectionType != desiredAutocorrection { field.autocorrectionType = desiredAutocorrection }
965
- let desiredCapitalization = capitalizationType(props["autoCapitalize"])
966
- if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
2075
+ // Secure entry first: turning it on resets the capitalization.
967
2076
  let desiredSecureEntry = props["secureTextEntry"] as? Bool == true
968
2077
  if field.isSecureTextEntry != desiredSecureEntry { field.isSecureTextEntry = desiredSecureEntry }
2078
+ let desiredCapitalization = capitalizationType(props["autoCapitalize"])
2079
+ if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
969
2080
  let desiredEnabled = props["editable"] as? Bool != false
970
2081
  if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
971
2082
  if let value = props["value"] as? String {
@@ -981,6 +2092,52 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
981
2092
  if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
982
2093
  DispatchQueue.main.async { _ = field.becomeFirstResponder() }
983
2094
  }
2095
+ case "Picker":
2096
+ let picker = result as! UIPickerView
2097
+ let options = children.compactMap { child -> (value: String, label: String)? in
2098
+ guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
2099
+ let optionProps = node["props"] as? [String: Any] ?? [:]
2100
+ let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
2101
+ guard !label.isEmpty else { return nil }
2102
+ return (value: optionProps["value"] as? String ?? label, label: label)
2103
+ }
2104
+ let pickerID = ObjectIdentifier(picker)
2105
+ pickerOptions[pickerID] = options
2106
+ picker.reloadAllComponents()
2107
+ if let selected = (props["selectedValue"] as? String) ?? (props["value"] as? String),
2108
+ let row = options.firstIndex(where: { $0.value == selected }) {
2109
+ picker.selectRow(row, inComponent: 0, animated: false)
2110
+ } else if !options.isEmpty {
2111
+ picker.selectRow(0, inComponent: 0, animated: false)
2112
+ }
2113
+ picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
2114
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
2115
+ case "Modal":
2116
+ let modal = result as! CraftNativeModalView
2117
+ modal.transparent = props["transparent"] as? Bool == true
2118
+ let visible = props["visible"] as? Bool == true
2119
+ let modalID = ObjectIdentifier(modal)
2120
+ let wasVisible = modalVisibility[modalID]
2121
+ modalVisibility[modalID] = visible
2122
+ modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
2123
+ modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
2124
+ { [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
2125
+ }
2126
+ if wasVisible != visible, visible || wasVisible == true {
2127
+ let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
2128
+ if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
2129
+ send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
2130
+ }
2131
+ }
2132
+ configureStack(modal.contentStack, style: style)
2133
+ reconcileChildren(children, in: modal.contentStack, parent: current, path: path, style: style)
2134
+ case "TouchableOpacity", "TouchableHighlight", "Pressable":
2135
+ let pressable = result as! CraftNativeFlowView
2136
+ configureStack(pressable, style: style)
2137
+ reconcileChildren(children, in: pressable, parent: current, path: path, style: style)
2138
+ pressable.pressActiveOpacity = props["disabled"] as? Bool == true
2139
+ ? nil : number(props["activeOpacity"]) ?? 0.5
2140
+ pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
984
2141
  case "Switch":
985
2142
  let toggle = result as! UISwitch
986
2143
  toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
@@ -1029,6 +2186,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1029
2186
  } else {
1030
2187
  indicator.startAnimating()
1031
2188
  }
2189
+ case "Icon":
2190
+ configureIcon(result as! UIImageView, props: props, style: style)
1032
2191
  case "Image":
1033
2192
  let image = result as! UIImageView
1034
2193
  let imageID = ObjectIdentifier(image)
@@ -1061,17 +2220,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1061
2220
  scroll.bounces = props["bounces"] as? Bool ?? true
1062
2221
  scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
1063
2222
  scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
2223
+ scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
1064
2224
  scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
1065
2225
  scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
1066
2226
  scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
1067
2227
  scroll.alwaysBounceHorizontal = props["alwaysBounceHorizontal"] as? Bool ?? (direction == .horizontal)
2228
+ scroll.setStickyHeaderIndices(props["stickyHeaderIndices"])
2229
+ scroll.setScrollTarget(props["scrollTarget"])
1068
2230
  updateAuxiliaryHandler(events["onScroll"], in: &scrollHandlers, for: scroll)
1069
2231
  updateAuxiliaryHandler(events["onScrollBeginDrag"], in: &scrollBeginHandlers, for: scroll)
1070
2232
  updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
1071
2233
  updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
1072
2234
  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)
2235
+ scroll.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
2236
+ { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
2237
+ }, refreshing: props["refreshing"] as? Bool == true)
2238
+ var contentStyle = style
2239
+ if let override = props["contentContainerStyle"] as? [String: Any] {
2240
+ contentStyle.merge(override) { _, next in next }
2241
+ }
2242
+ configureStack(scroll.contentStack, style: contentStyle)
2243
+ reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
1075
2244
  case "FlatList":
1076
2245
  let list = result as! CraftNativeFlatList
1077
2246
  reconcileFlatList(
@@ -1087,9 +2256,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1087
2256
  configureStack(stack, style: style)
1088
2257
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
1089
2258
  }
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)
2259
+ updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
2260
+ if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
2261
+ let disabled = props["disabled"] as? Bool == true
2262
+ updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
2263
+ updatePressFeedback(
2264
+ disabled ? nil : nonEmptyHandler(events["onPressIn"]),
2265
+ disabled ? nil : nonEmptyHandler(events["onPressOut"]),
2266
+ for: result
2267
+ )
2268
+ if disabled { result.isUserInteractionEnabled = false }
1093
2269
  }
1094
2270
  applyAccessibility(props, type: type, to: result)
1095
2271
  return current
@@ -1098,7 +2274,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1098
2274
  private func makeView(_ type: String, props: [String: Any]) -> UIView {
1099
2275
  switch type {
1100
2276
  case "Text":
1101
- let label = UILabel()
2277
+ let label = CraftNativeLabel()
1102
2278
  label.numberOfLines = 0
1103
2279
  return label
1104
2280
  case "Button", "Link":
@@ -1118,6 +2294,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1118
2294
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
1119
2295
  field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
1120
2296
  return field
2297
+ case "Picker":
2298
+ let picker = UIPickerView()
2299
+ picker.dataSource = self
2300
+ picker.delegate = self
2301
+ return picker
2302
+ case "Modal":
2303
+ return CraftNativeModalView()
1121
2304
  case "Switch":
1122
2305
  let toggle = UISwitch()
1123
2306
  toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
@@ -1131,19 +2314,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1131
2314
  return UIActivityIndicatorView(style: .medium)
1132
2315
  case "Image":
1133
2316
  return UIImageView()
2317
+ case "Icon":
2318
+ let icon = CraftNativeIconView()
2319
+ icon.contentMode = .scaleAspectFit
2320
+ return icon
1134
2321
  case "ScrollView":
1135
2322
  return CraftNativeScrollView()
1136
2323
  case "FlatList":
1137
2324
  return CraftNativeFlatList()
1138
2325
  case "View", "SafeAreaView":
1139
2326
  return CraftNativeFlowView()
2327
+ case "TouchableOpacity", "TouchableHighlight", "Pressable":
2328
+ return CraftNativeFlowView()
1140
2329
  default:
1141
2330
  return CraftNativeFlowView()
1142
2331
  }
1143
2332
  }
1144
2333
 
1145
2334
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
1146
- // The current stx-native compiler emits key in props; accept the IR
2335
+ // The current stx native compiler emits key in props; accept the IR
1147
2336
  // field too so keyed children keep working when the compiler adopts it.
1148
2337
  [node["id"], node["key"], props["key"], props["testID"]]
1149
2338
  .compactMap { $0 as? String }
@@ -1186,7 +2375,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1186
2375
  for child in parent.children where !next.contains(where: { $0 === child }) {
1187
2376
  detach(child, from: stack)
1188
2377
  }
1189
- for (index, child) in next.enumerated() {
2378
+ for modal in stack.arrangedSubviews where modal is CraftNativeModalView {
2379
+ stack.removeArrangedSubview(modal)
2380
+ }
2381
+ let flowChildren = next.filter { $0.type != "Modal" }
2382
+ for (index, child) in flowChildren.enumerated() {
1190
2383
  if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view {
1191
2384
  stack.setLayoutStyle(child.style, for: child.view)
1192
2385
  continue
@@ -1195,6 +2388,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1195
2388
  stack.insertArrangedSubview(child.view, at: min(index, stack.arrangedSubviews.count))
1196
2389
  stack.setLayoutStyle(child.style, for: child.view)
1197
2390
  }
2391
+ for child in next where child.type == "Modal" {
2392
+ guard let modal = child.view as? CraftNativeModalView else { continue }
2393
+ if modal.superview !== stack {
2394
+ modal.removeFromSuperview()
2395
+ stack.addSubview(modal)
2396
+ }
2397
+ modal.frame = stack.bounds
2398
+ modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
2399
+ stack.bringSubviewToFront(modal)
2400
+ }
1198
2401
  parent.children = next
1199
2402
  addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
1200
2403
  }
@@ -1212,6 +2415,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1212
2415
  flatListOwners = flatListOwners.filter { $0.value != owner }
1213
2416
  for child in children { registerFlatListOwnership(child, owner: owner) }
1214
2417
  list.bounces = props["bounces"] as? Bool ?? true
2418
+ list.isScrollEnabled = props["scrollEnabled"] as? Bool != false
2419
+ list.onFittingHeightChanged = { [weak list] in
2420
+ guard let list else { return }
2421
+ CraftNativeFlexLayout.invalidateAncestors(of: list)
2422
+ }
2423
+ list.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
2424
+ list.setContentContainerStyle(props["contentContainerStyle"] as? [String: Any])
2425
+ list.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
2426
+ { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
2427
+ }, refreshing: props["refreshing"] as? Bool == true)
1215
2428
  list.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
1216
2429
  list.onScrollEvent = nonEmptyHandler(events["onScroll"]).map { handler in
1217
2430
  { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
@@ -1276,20 +2489,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1276
2489
 
1277
2490
  private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
1278
2491
  forgetHandlers(node)
2492
+ if node.type == "Modal" {
2493
+ node.view.removeFromSuperview()
2494
+ return
2495
+ }
1279
2496
  stack.removeLayoutStyle(for: node.view)
1280
2497
  stack.removeArrangedSubview(node.view)
1281
2498
  node.view.removeFromSuperview()
1282
2499
  }
1283
2500
 
1284
2501
  private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
2502
+ (node.view as? CraftNativeScrollView)?.setRefreshHandler(nil, refreshing: false)
1285
2503
  if let list = node.view as? CraftNativeFlatList {
1286
2504
  let listKey = ObjectIdentifier(list)
2505
+ list.setRefreshHandler(nil, refreshing: false)
1287
2506
  list.discardAll()
1288
2507
  flatListRows.removeValue(forKey: listKey)
1289
2508
  if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
1290
2509
  }
1291
2510
  let id = ObjectIdentifier(node.view)
1292
2511
  handlers.removeValue(forKey: id)
2512
+ pressInHandlers.removeValue(forKey: id)
2513
+ pressOutHandlers.removeValue(forKey: id)
1293
2514
  layoutHandlers.removeValue(forKey: id)
1294
2515
  lastLayoutFrames.removeValue(forKey: id)
1295
2516
  focusHandlers.removeValue(forKey: id)
@@ -1307,12 +2528,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1307
2528
  scrollMomentumEndHandlers.removeValue(forKey: id)
1308
2529
  sliderCompleteHandlers.removeValue(forKey: id)
1309
2530
  sliderSteps.removeValue(forKey: id)
2531
+ pickerOptions.removeValue(forKey: id)
2532
+ modalVisibility.removeValue(forKey: id)
1310
2533
  if let recognizer = tapRecognizers.removeValue(forKey: id) {
1311
2534
  node.view.removeGestureRecognizer(recognizer)
1312
2535
  }
1313
2536
  if let recognizer = longPressRecognizers.removeValue(forKey: id) {
1314
2537
  node.view.removeGestureRecognizer(recognizer)
1315
2538
  }
2539
+ if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) {
2540
+ node.view.removeGestureRecognizer(recognizer)
2541
+ }
1316
2542
  imageSources.removeValue(forKey: id)
1317
2543
  imageTasks.removeValue(forKey: id)?.cancel()
1318
2544
  imageErrors.removeValue(forKey: id)
@@ -1402,7 +2628,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1402
2628
  } else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
1403
2629
  view.removeGestureRecognizer(recognizer)
1404
2630
  }
1405
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil }
2631
+ // A container keeps receiving touches without a handler of its own,
2632
+ // or every control inside it (and the screen root) would be dead.
2633
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
2634
+ }
2635
+
2636
+ private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
2637
+ let id = ObjectIdentifier(view)
2638
+ if let pressIn, !pressIn.isEmpty { pressInHandlers[id] = pressIn } else { pressInHandlers.removeValue(forKey: id) }
2639
+ if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
2640
+ guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
2641
+ if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
2642
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
2643
+ return
2644
+ }
2645
+ if pressFeedbackRecognizers[id] == nil {
2646
+ let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewPressFeedback(_:)))
2647
+ recognizer.minimumPressDuration = 0
2648
+ recognizer.allowableMovement = 10
2649
+ recognizer.cancelsTouchesInView = false
2650
+ pressFeedbackRecognizers[id] = recognizer
2651
+ view.addGestureRecognizer(recognizer)
2652
+ }
2653
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
1406
2654
  }
1407
2655
 
1408
2656
  private func updateLongPressHandler(_ handler: String?, for view: UIView) {
@@ -1420,7 +2668,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1420
2668
  if let recognizer = longPressRecognizers.removeValue(forKey: id) {
1421
2669
  view.removeGestureRecognizer(recognizer)
1422
2670
  }
1423
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil }
2671
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || view is CraftNativeFlowView }
1424
2672
  }
1425
2673
  }
1426
2674
 
@@ -1504,6 +2752,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1504
2752
  }
1505
2753
  }
1506
2754
 
2755
+ /// `<Icon symbol="sun.max">`: an SF Symbol, tinted by `color`, sized by
2756
+ /// `fontSize` or else the icon's box, weighted by `fontWeight`. A name
2757
+ /// the system does not have draws `circle`, so a typo shows rather than
2758
+ /// leaving a hole.
2759
+ private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
2760
+ let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
2761
+ let box = [number(style["width"]), number(style["height"])].compactMap { $0 }.min()
2762
+ let fontSize = number(style["fontSize"])
2763
+ // Without a size of its own an icon is a 20 pt square (contract A2).
2764
+ (icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
2765
+ let pointSize = fontSize ?? (box ?? 20) * 0.84
2766
+ let weight: UIImage.SymbolWeight
2767
+ switch style["fontWeight"] as? String {
2768
+ case "100": weight = .ultraLight
2769
+ case "200": weight = .thin
2770
+ case "300": weight = .light
2771
+ case "500": weight = .medium
2772
+ case "600": weight = .semibold
2773
+ case "bold", "700": weight = .bold
2774
+ case "800": weight = .heavy
2775
+ case "900": weight = .black
2776
+ default: weight = .regular
2777
+ }
2778
+ let scale: UIImage.SymbolScale
2779
+ switch props["scale"] as? String {
2780
+ case "small": scale = .small
2781
+ case "large": scale = .large
2782
+ default: scale = .medium
2783
+ }
2784
+ let configuration = UIImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: weight, scale: scale)
2785
+ let image = UIImage(systemName: name, withConfiguration: configuration)
2786
+ ?? UIImage(systemName: "circle", withConfiguration: configuration)
2787
+ if icon.image != image { icon.image = image }
2788
+ icon.preferredSymbolConfiguration = configuration
2789
+ icon.tintColor = color(style["color"]) ?? color(props["color"]) ?? .label
2790
+ icon.contentMode = .scaleAspectFit
2791
+ }
2792
+
1507
2793
  private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
1508
2794
  let direction = style["flexDirection"] as? String
1509
2795
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
@@ -1517,15 +2803,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1517
2803
  stack.grid = style["display"] as? String == "grid"
1518
2804
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
1519
2805
  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
- )
2806
+ stack.padding = paddingInsets(style)
2807
+ stack.invalidateMeasurements()
1529
2808
  }
1530
2809
 
1531
2810
  private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
@@ -1538,7 +2817,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1538
2817
  view.backgroundColor = color(style["backgroundColor"]) ?? .clear
1539
2818
  view.alpha = number(style["opacity"]) ?? 1
1540
2819
  view.isHidden = style["display"] as? String == "none"
1541
- view.layer.cornerRadius = number(style["borderRadius"]) ?? 0
2820
+ view.craftRequestedCornerRadius = number(style["borderRadius"])
1542
2821
  view.layer.borderWidth = number(style["borderWidth"]) ?? 0
1543
2822
  view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
1544
2823
  let elevation = max(0, number(style["elevation"]) ?? 0)
@@ -1546,8 +2825,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1546
2825
  view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
1547
2826
  view.layer.shadowRadius = elevation * 0.5
1548
2827
  view.layer.shadowOffset = CGSize(width: 0, height: elevation * 0.25)
2828
+ // A label paints its background inside its own bounds, so a rounded
2829
+ // chip of text clips to its corners.
1549
2830
  view.clipsToBounds = style["overflow"] as? String == "hidden"
2831
+ || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
1550
2832
  view.layer.masksToBounds = view.clipsToBounds
2833
+ view.craftLayoutStyle = CraftNativeLayoutStyle(style)
2834
+ (view as? CraftNativeFlowView)?.invalidateMeasurements()
2835
+ (view as? CraftNativeScrollView)?.setContentNeedsLayout()
2836
+ }
2837
+
2838
+ private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
2839
+ let padding = number(style["padding"]) ?? 0
2840
+ let horizontal = number(style["paddingHorizontal"]) ?? padding
2841
+ let vertical = number(style["paddingVertical"]) ?? padding
2842
+ return UIEdgeInsets(
2843
+ top: number(style["paddingTop"]) ?? vertical,
2844
+ left: number(style["paddingLeft"]) ?? horizontal,
2845
+ bottom: number(style["paddingBottom"]) ?? vertical,
2846
+ right: number(style["paddingRight"]) ?? horizontal
2847
+ )
1551
2848
  }
1552
2849
 
1553
2850
  private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
@@ -1557,13 +2854,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1557
2854
  label.textAlignment = textAlignment(style["textAlign"])
1558
2855
  var attributes: [NSAttributedString.Key: Any] = [:]
1559
2856
  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
2857
  switch style["textDecorationLine"] as? String {
1568
2858
  case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
1569
2859
  case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
@@ -1572,8 +2862,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1572
2862
  attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
1573
2863
  default: break
1574
2864
  }
2865
+ if let lineHeight = number(style["lineHeight"]) {
2866
+ let paragraph = NSMutableParagraphStyle()
2867
+ paragraph.minimumLineHeight = lineHeight
2868
+ paragraph.maximumLineHeight = lineHeight
2869
+ paragraph.alignment = label.textAlignment
2870
+ attributes[.paragraphStyle] = paragraph
2871
+ }
2872
+ let alignment = label.textAlignment
2873
+ let lineBreakMode = label.lineBreakMode
1575
2874
  label.attributedText = attributes.isEmpty ? nil : NSAttributedString(string: transformed, attributes: attributes)
1576
2875
  if attributes.isEmpty { label.text = transformed }
2876
+ // Attributed text brings its own paragraph style, which resets the
2877
+ // label's alignment and truncation; set them again over the whole text.
2878
+ label.textAlignment = alignment
2879
+ label.lineBreakMode = lineBreakMode
1577
2880
  }
1578
2881
 
1579
2882
  private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
@@ -1808,7 +3111,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1808
3111
  view.accessibilityValue = nil
1809
3112
  }
1810
3113
  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))
3114
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Picker", "Switch", "Slider"].contains(type))
1812
3115
  var traits: UIAccessibilityTraits = []
1813
3116
  switch role ?? type.lowercased() {
1814
3117
  case "button": traits.insert(.button)
@@ -1844,7 +3147,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1844
3147
  if let value = value {
1845
3148
  if let constraint = constraint { constraint.constant = value }
1846
3149
  else {
3150
+ // Below required: the flex layout owns frames, and a width
3151
+ // the layout stretches or shrinks must not log a conflict.
1847
3152
  constraint = anchor.constraint(equalToConstant: value)
3153
+ constraint?.priority = UILayoutPriority(999)
1848
3154
  constraint?.isActive = true
1849
3155
  }
1850
3156
  } else {
@@ -1936,6 +3242,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1936
3242
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
1937
3243
  }
1938
3244
 
3245
+ func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
3246
+
3247
+ func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
3248
+ pickerOptions[ObjectIdentifier(pickerView)]?.count ?? 0
3249
+ }
3250
+
3251
+ func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
3252
+ guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row) else { return nil }
3253
+ return options[row].label
3254
+ }
3255
+
3256
+ func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
3257
+ guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row),
3258
+ let handler = handlers[ObjectIdentifier(pickerView)] else { return }
3259
+ let option = options[row]
3260
+ send(type: "EVENT", payload: [
3261
+ "handlerName": handler,
3262
+ "nativeEvent": ["value": option.value, "index": row],
3263
+ ])
3264
+ }
3265
+
1939
3266
  @objc private func textFocused(_ sender: UITextField) {
1940
3267
  lastFocusedInput = sender
1941
3268
  guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
@@ -1960,9 +3287,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1960
3287
 
1961
3288
  @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
1962
3289
  guard let view = sender.view, let handler = handlers[ObjectIdentifier(view)] else { return }
3290
+ let id = ObjectIdentifier(view)
3291
+ if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
3292
+ let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
3293
+ UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
3294
+ UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity }
3295
+ }
3296
+ }
1963
3297
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
1964
3298
  }
1965
3299
 
3300
+ @objc private func viewPressFeedback(_ sender: UILongPressGestureRecognizer) {
3301
+ guard let view = sender.view else { return }
3302
+ let id = ObjectIdentifier(view)
3303
+ switch sender.state {
3304
+ case .began:
3305
+ if let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
3306
+ UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }
3307
+ }
3308
+ if let handler = pressInHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
3309
+ case .ended, .cancelled, .failed:
3310
+ if let pressable = view as? CraftNativeFlowView { UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity } }
3311
+ if let handler = pressOutHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
3312
+ default:
3313
+ break
3314
+ }
3315
+ }
3316
+
1966
3317
  @objc private func viewLongPressed(_ sender: UILongPressGestureRecognizer) {
1967
3318
  guard sender.state == .began,
1968
3319
  let view = sender.view,
@@ -2048,8 +3399,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2048
3399
  }
2049
3400
  }
2050
3401
 
3402
+ /// A style colour: CSS hex with or without alpha (`#10b9811a` is
3403
+ /// emerald at 10%), `rgb()`/`rgba()` and the few names; `UIColor(hex:)`
3404
+ /// for anything else it has always read.
2051
3405
  private func color(_ value: Any?) -> UIColor? {
2052
- guard let hex = value as? String else { return nil }
2053
- return UIColor(hex: hex)
3406
+ guard let text = value as? String else { return nil }
3407
+ return UIColor(css: text) ?? UIColor(hex: text)
3408
+ }
3409
+ }
3410
+
3411
+ /// `console` for native screens: the unified log, subsystem `craft.native`,
3412
+ /// category the screen's name. Read it with
3413
+ /// `log stream --predicate 'subsystem == "craft.native"'`.
3414
+ enum CraftNativeConsole {
3415
+ private static var loggers: [String: Logger] = [:]
3416
+ private static let lock = NSLock()
3417
+
3418
+ static func write(_ level: String, category: String, message: String) {
3419
+ lock.lock()
3420
+ let logger = loggers[category] ?? Logger(subsystem: "craft.native", category: category)
3421
+ loggers[category] = logger
3422
+ lock.unlock()
3423
+ switch level {
3424
+ case "debug": logger.debug("\(message, privacy: .public)")
3425
+ case "info": logger.info("\(message, privacy: .public)")
3426
+ case "warn": logger.warning("\(message, privacy: .public)")
3427
+ case "error": logger.error("\(message, privacy: .public)")
3428
+ default: logger.notice("\(message, privacy: .public)")
3429
+ }
2054
3430
  }
2055
3431
  }