craft-native 0.0.123 → 0.0.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,24 @@
1
1
  import Foundation
2
2
  import JavaScriptCore
3
+ import OSLog
3
4
  import SwiftUI
4
5
  import UIKit
5
6
 
6
7
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
7
8
 
8
- /// A WebView-free host for the first stx-native vertical slice. The bundled
9
+ private func craftNativeGridColumnCount(_ value: Any?) -> Int {
10
+ if let number = value as? NSNumber { return max(1, number.intValue) }
11
+ guard let text = value as? String else { return 1 }
12
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
13
+ if trimmed.hasPrefix("repeat(") {
14
+ let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
15
+ if let count = Int(digits) { return max(1, count) }
16
+ }
17
+ let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
18
+ return max(1, tracks.count)
19
+ }
20
+
21
+ /// A WebView-free host for the first stx native vertical slice. The bundled
9
22
  /// JavaScript sends whole, compiled view trees to UIKit and receives control
10
23
  /// events and Craft API replies through JavaScriptCore.
11
24
  struct CraftNativeScreen: UIViewControllerRepresentable {
@@ -18,59 +31,242 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
18
31
  func updateUIViewController(_ controller: UINavigationController, context: Context) {}
19
32
  }
20
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.
21
67
  private struct CraftNativeLayoutStyle {
22
- let width: CGFloat?
23
- let height: CGFloat?
24
- let minWidth: CGFloat?
25
- let maxWidth: CGFloat?
26
- let minHeight: CGFloat?
27
- 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?
28
74
  let position: String
29
75
  let top: CGFloat?
30
76
  let right: CGFloat?
31
77
  let bottom: CGFloat?
32
78
  let left: CGFloat?
33
79
  let alignSelf: String?
80
+ let flexGrow: 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?
85
+ let marginTop: CGFloat
86
+ let marginRight: CGFloat
87
+ let marginBottom: CGFloat
88
+ let marginLeft: CGFloat
89
+ let display: String
90
+ let gridColumns: Int
91
+ let gridAutoRows: CGFloat?
92
+
93
+ static let empty = CraftNativeLayoutStyle([:])
34
94
 
35
95
  init(_ raw: [String: Any]) {
36
96
  func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
37
- width = number(raw["width"])
38
- height = number(raw["height"])
39
- minWidth = number(raw["minWidth"])
40
- maxWidth = number(raw["maxWidth"])
41
- minHeight = number(raw["minHeight"])
42
- 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"])
43
103
  position = raw["position"] as? String ?? "relative"
44
104
  top = number(raw["top"])
45
105
  right = number(raw["right"])
46
106
  bottom = number(raw["bottom"])
47
107
  left = number(raw["left"])
48
- alignSelf = raw["alignSelf"] as? String
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 }
117
+ let margin = number(raw["margin"]) ?? 0
118
+ let horizontalMargin = number(raw["marginHorizontal"]) ?? margin
119
+ let verticalMargin = number(raw["marginVertical"]) ?? margin
120
+ marginTop = number(raw["marginTop"]) ?? verticalMargin
121
+ marginRight = number(raw["marginRight"]) ?? horizontalMargin
122
+ marginBottom = number(raw["marginBottom"]) ?? verticalMargin
123
+ marginLeft = number(raw["marginLeft"]) ?? horizontalMargin
124
+ display = raw["display"] as? String ?? "flex"
125
+ gridColumns = craftNativeGridColumnCount(raw["gridTemplateColumns"] ?? raw["gridColumns"])
126
+ gridAutoRows = number(raw["gridAutoRows"])
127
+ }
128
+ }
129
+
130
+ private final class CraftNativeLayoutStyleBox {
131
+ let style: CraftNativeLayoutStyle
132
+ init(_ style: CraftNativeLayoutStyle) { self.style = style }
133
+ }
134
+
135
+ private var craftNativeLayoutStyleKey: UInt8 = 0
136
+ private var craftNativeCornerRadiusKey: UInt8 = 0
137
+
138
+ extension UIView {
139
+ /// The layout style this view's parent reads. It lives on the view, so an
140
+ /// in-place style update reaches the parent's next layout pass without the
141
+ /// parent being reconciled too.
142
+ fileprivate var craftLayoutStyle: CraftNativeLayoutStyle {
143
+ get { (objc_getAssociatedObject(self, &craftNativeLayoutStyleKey) as? CraftNativeLayoutStyleBox)?.style ?? .empty }
144
+ set { objc_setAssociatedObject(self, &craftNativeLayoutStyleKey, CraftNativeLayoutStyleBox(newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
145
+ }
146
+
147
+ /// The corner radius the style asked for. The layer gets at most half the
148
+ /// shortest side: Core Animation draws nothing at all for a radius larger
149
+ /// than the view (`rounded-full` is 9999).
150
+ var craftRequestedCornerRadius: CGFloat? {
151
+ get { (objc_getAssociatedObject(self, &craftNativeCornerRadiusKey) as? NSNumber).map { CGFloat(truncating: $0) } }
152
+ set {
153
+ objc_setAssociatedObject(self, &craftNativeCornerRadiusKey, newValue.map { NSNumber(value: Double($0)) }, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
154
+ craftApplyCornerRadius()
155
+ }
156
+ }
157
+
158
+ func craftApplyCornerRadius() {
159
+ let requested = craftRequestedCornerRadius ?? 0
160
+ let limit = min(bounds.width, bounds.height) / 2
161
+ let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
162
+ if layer.cornerRadius != radius { layer.cornerRadius = radius }
163
+ }
164
+ }
165
+
166
+ /// Measures any native-screen view the way its parent's flex layout needs:
167
+ /// at a width (or height) the parent fixes, or at its natural size within the
168
+ /// room there is. Shared by the flow views, the scroll view and FlatList rows.
169
+ enum CraftNativeFlexLayout {
170
+ static let unbounded: CGFloat = 1_000_000
171
+
172
+ static func measure(_ view: UIView, width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
173
+ let maxWidth = max(0, maxWidth)
174
+ let maxHeight = max(0, maxHeight)
175
+ switch view {
176
+ case let flow as CraftNativeFlowView:
177
+ return flow.measure(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
178
+ case let scroll as CraftNativeScrollView:
179
+ return scroll.measure(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
180
+ case let list as CraftNativeFlatList:
181
+ let listWidth = width ?? maxWidth
182
+ return CGSize(width: listWidth, height: height ?? list.fittingHeight(width: listWidth))
183
+ case let label as UILabel:
184
+ // Text takes the width it needs up to the room there is, and
185
+ // wraps or truncates inside it rather than widening its parent.
186
+ let limit = width ?? maxWidth
187
+ let natural = label.sizeThatFits(CGSize(width: limit, height: unbounded))
188
+ let resolvedWidth = width ?? min(ceil(natural.width), limit)
189
+ let resolvedHeight = height ?? ceil(resolvedWidth < limit
190
+ ? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
191
+ : natural.height)
192
+ return CGSize(width: resolvedWidth, height: resolvedHeight)
193
+ case let image as UIImageView where image.image == nil:
194
+ return CGSize(width: width ?? 0, height: height ?? 0)
195
+ default:
196
+ let intrinsic = view.intrinsicContentSize
197
+ let fitted = view.sizeThatFits(CGSize(width: width ?? maxWidth, height: height ?? maxHeight))
198
+ let naturalWidth = intrinsic.width > 0 && intrinsic.width != UIView.noIntrinsicMetric ? intrinsic.width : fitted.width
199
+ let naturalHeight = intrinsic.height > 0 && intrinsic.height != UIView.noIntrinsicMetric ? intrinsic.height : fitted.height
200
+ return CGSize(width: width ?? min(max(0, naturalWidth), maxWidth), height: height ?? max(0, naturalHeight))
201
+ }
202
+ }
203
+
204
+ /// Marks every flex container above `view` for measuring again, after a
205
+ /// change inside it that may change its size. A FlatList row stops the
206
+ /// walk: the list measures its rows itself.
207
+ static func invalidateAncestors(of view: UIView) {
208
+ var current = view.superview
209
+ while let ancestor = current, !(ancestor is UICollectionViewCell) {
210
+ if let flow = ancestor as? CraftNativeFlowView { flow.invalidateMeasurements() }
211
+ if let scroll = ancestor as? CraftNativeScrollView { scroll.setContentNeedsLayout() }
212
+ current = ancestor.superview
213
+ }
214
+ }
215
+
216
+ /// A frame snapped to the screen's pixels edge by edge, so neighbours
217
+ /// neither overlap nor leave a hairline between them.
218
+ static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
219
+ let scale = max(1, scale)
220
+ func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
221
+ let minX = snap(rect.minX), minY = snap(rect.minY)
222
+ return CGRect(x: minX, y: minY, width: max(0, snap(rect.maxX) - minX), height: max(0, snap(rect.maxY) - minY))
49
223
  }
50
224
  }
51
225
 
226
+ /// A flexbox container: rows and columns with grow, shrink and basis, wrap,
227
+ /// gaps, alignment, margins, percentages, absolute children and a simple
228
+ /// grid. It is a UIStackView in name only, so existing callers and tests keep
229
+ /// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
230
+ /// constraints are never created, and every frame comes from `layout`.
52
231
  private final class CraftNativeFlowView: UIStackView {
53
- var wrap = false { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
54
- var alignItems = "stretch" { didSet { alignment = alignmentValue; setNeedsLayout() } }
55
- var justifyContent = "flex-start" { didSet { distribution = distributionValue; setNeedsLayout() } }
56
- var gap: CGFloat = 0 { didSet { spacing = gap; invalidateIntrinsicContentSize(); setNeedsLayout() } }
57
- var rowGap: CGFloat? { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
58
- var columnGap: CGFloat? { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
232
+ var pressActiveOpacity: CGFloat?
233
+ var pressBaseOpacity: CGFloat = 1
234
+ var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
235
+ var alignItems = "stretch" { didSet { alignment = alignmentValue; if alignItems != oldValue { invalidateMeasurements() } } }
236
+ var justifyContent = "flex-start" { didSet { distribution = distributionValue; if justifyContent != oldValue { invalidateMeasurements() } } }
237
+ var gap: CGFloat = 0 { didSet { spacing = gap; if gap != oldValue { invalidateMeasurements() } } }
238
+ var rowGap: CGFloat? { didSet { if rowGap != oldValue { invalidateMeasurements() } } }
239
+ var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
240
+ var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
241
+ var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
242
+ var gridAutoRows: CGFloat? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
59
243
  var padding = UIEdgeInsets.zero {
60
244
  didSet {
61
245
  layoutMargins = padding
62
246
  isLayoutMarginsRelativeArrangement = true
63
- invalidateIntrinsicContentSize()
64
- setNeedsLayout()
247
+ if padding != oldValue { invalidateMeasurements() }
65
248
  }
66
249
  }
67
- private var childStyles: [ObjectIdentifier: CraftNativeLayoutStyle] = [:]
250
+ override var axis: NSLayoutConstraint.Axis {
251
+ didSet { if axis != oldValue { invalidateMeasurements() } }
252
+ }
253
+
254
+ private var flowChildren: [UIView] = []
255
+ private var measurements: [MeasureKey: CGSize] = [:]
256
+
257
+ private struct MeasureKey: Hashable {
258
+ let width: CGFloat?
259
+ let height: CGFloat?
260
+ let maxWidth: CGFloat
261
+ let maxHeight: CGFloat
262
+ }
68
263
 
69
264
  override init(frame: CGRect) {
70
265
  super.init(frame: frame)
71
266
  alignment = .fill
72
267
  distribution = .fill
73
268
  isLayoutMarginsRelativeArrangement = true
269
+ insetsLayoutMarginsFromSafeArea = false
74
270
  }
75
271
 
76
272
  required init(coder: NSCoder) { super.init(coder: coder) }
@@ -93,178 +289,731 @@ private final class CraftNativeFlowView: UIStackView {
93
289
  }
94
290
  }
95
291
 
96
- func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
97
- childStyles[ObjectIdentifier(view)] = CraftNativeLayoutStyle(raw)
98
- invalidateIntrinsicContentSize()
99
- setNeedsLayout()
292
+ // MARK: Children
293
+
294
+ override var arrangedSubviews: [UIView] { flowChildren }
295
+
296
+ override func addArrangedSubview(_ view: UIView) {
297
+ insertArrangedSubview(view, at: flowChildren.count)
100
298
  }
101
299
 
102
- func removeLayoutStyle(for view: UIView) { childStyles.removeValue(forKey: ObjectIdentifier(view)) }
300
+ override func insertArrangedSubview(_ view: UIView, at stackIndex: Int) {
301
+ flowChildren.removeAll { $0 === view }
302
+ flowChildren.insert(view, at: min(max(0, stackIndex), flowChildren.count))
303
+ if view.superview !== self {
304
+ view.removeFromSuperview()
305
+ addSubview(view)
306
+ }
307
+ view.translatesAutoresizingMaskIntoConstraints = true
308
+ invalidateMeasurements()
309
+ }
103
310
 
104
- override func didAddSubview(_ subview: UIView) {
105
- super.didAddSubview(subview)
106
- setNeedsLayout()
311
+ override func removeArrangedSubview(_ view: UIView) {
312
+ flowChildren.removeAll { $0 === view }
313
+ invalidateMeasurements()
107
314
  }
108
315
 
109
316
  override func willRemoveSubview(_ subview: UIView) {
110
- childStyles.removeValue(forKey: ObjectIdentifier(subview))
317
+ flowChildren.removeAll { $0 === subview }
111
318
  super.willRemoveSubview(subview)
319
+ invalidateMeasurements()
320
+ }
321
+
322
+ func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
323
+ view.craftLayoutStyle = CraftNativeLayoutStyle(raw)
324
+ invalidateMeasurements()
112
325
  }
113
326
 
327
+ func removeLayoutStyle(for view: UIView) {
328
+ view.craftLayoutStyle = .empty
329
+ }
330
+
331
+ /// Forgets measured sizes and lays out again on the next pass.
332
+ func invalidateMeasurements() {
333
+ measurements.removeAll(keepingCapacity: true)
334
+ setNeedsLayout()
335
+ }
336
+
337
+ // MARK: Measuring and layout
338
+
114
339
  override var intrinsicContentSize: CGSize {
115
- let children = subviews.filter { !$0.isHidden && !($0 is CraftNativeFlexSpacer) }
116
- guard !children.isEmpty else { return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) }
117
- let sizes = children.map { measuredSize(for: $0, available: CGSize(width: 10_000, height: 10_000)) }
118
- let main = sizes.map { axis == .horizontal ? $0.width : $0.height }.reduce(0, +)
119
- let cross = sizes.map { axis == .horizontal ? $0.height : $0.width }.max() ?? 0
120
- let mainGap = max(0, CGFloat(max(0, children.count - 1))) * mainGapValue
121
- if axis == .horizontal {
122
- return CGSize(width: padding.left + padding.right + main + mainGap, height: padding.top + padding.bottom + cross)
123
- }
124
- return CGSize(width: padding.left + padding.right + cross, height: padding.top + padding.bottom + main + mainGap)
340
+ CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
341
+ }
342
+
343
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
344
+ measure(width: nil, height: nil, maxWidth: size.width, maxHeight: size.height)
345
+ }
346
+
347
+ func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
348
+ let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
349
+ if let cached = measurements[key] { return cached }
350
+ let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
351
+ measurements[key] = size
352
+ return size
125
353
  }
126
354
 
127
355
  override func layoutSubviews() {
128
356
  super.layoutSubviews()
129
- let content = bounds.inset(by: padding)
130
- let availableMain = axis == .horizontal ? content.width : content.height
131
- var lines: [([UIView], [CGSize], CGFloat, CGFloat)] = []
132
- var items: [UIView] = []
133
- var sizes: [CGSize] = []
357
+ craftApplyCornerRadius()
358
+ let scale = window?.screen.scale ?? traitCollection.displayScale
359
+ let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
360
+ for (child, frame) in result.frames {
361
+ let snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
362
+ if child.frame != snapped { child.frame = snapped }
363
+ child.craftApplyCornerRadius()
364
+ }
365
+ }
366
+
367
+ private struct Item {
368
+ let view: UIView
369
+ let style: CraftNativeLayoutStyle
370
+ let align: String
371
+ let mainLeading: CGFloat
372
+ let mainTrailing: CGFloat
373
+ let crossLeading: CGFloat
374
+ let crossTrailing: CGFloat
375
+ let minMain: CGFloat?
376
+ let maxMain: CGFloat?
377
+ let minCross: CGFloat?
378
+ let maxCross: CGFloat?
379
+ let fixedCross: CGFloat?
380
+ let grow: CGFloat
381
+ let shrink: CGFloat
382
+ var basis: CGFloat
134
383
  var main: CGFloat = 0
135
384
  var cross: CGFloat = 0
136
385
 
137
- func flush() {
138
- guard !items.isEmpty else { return }
139
- lines.append((items, sizes, main, cross))
140
- items = []; sizes = []; main = 0; cross = 0
141
- }
142
-
143
- for child in subviews where !child.isHidden && !(child is CraftNativeFlexSpacer) {
144
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
145
- if style.position == "absolute" { continue }
146
- let size = measuredSize(for: child, available: content.size, style: style)
147
- let childMain = axis == .horizontal ? size.width : size.height
148
- let childCross = axis == .horizontal ? size.height : size.width
149
- let next = items.isEmpty ? childMain : main + mainGapValue + childMain
150
- if wrap && !items.isEmpty && next > availableMain { flush() }
151
- items.append(child); sizes.append(size)
152
- main = items.count == 1 ? childMain : main + mainGapValue + childMain
153
- cross = max(cross, childCross)
154
- }
155
- flush()
156
-
157
- var crossOffset: CGFloat = 0
158
- for (lineItems, lineSizes, lineMain, lineCross) in lines {
159
- let free = max(0, availableMain - lineMain)
160
- let (leading, between) = distribution(free: free, count: lineItems.count)
161
- var mainOffset = leading
162
- for (index, child) in lineItems.enumerated() {
163
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
164
- let size = lineSizes[index]
165
- let childCross = axis == .horizontal ? size.height : size.width
166
- let alignment = style.alignSelf ?? alignItems
167
- let crossSize: CGFloat = alignment == "stretch" && styleCrossDimension(style) == nil ? lineCross : childCross
168
- let crossPosition: CGFloat
169
- switch alignment {
170
- case "center": crossPosition = (lineCross - crossSize) / 2
171
- case "flex-end": crossPosition = lineCross - crossSize
172
- default: crossPosition = 0
386
+ var mainMargins: CGFloat { mainLeading + mainTrailing }
387
+ var crossMargins: CGFloat { crossLeading + crossTrailing }
388
+ }
389
+
390
+ private func clamp(_ value: CGFloat, _ lower: CGFloat?, _ upper: CGFloat?) -> CGFloat {
391
+ var result = value
392
+ if let upper { result = min(result, upper) }
393
+ if let lower { result = max(result, lower) }
394
+ return max(0, result)
395
+ }
396
+
397
+ /// Children that take part in the flow: shown, not a modal overlay.
398
+ private var visibleChildren: [UIView] {
399
+ flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
400
+ }
401
+
402
+ /// The flexbox algorithm, single pass. `width`/`height` are this view's
403
+ /// outer size when its parent fixes it, nil when it sizes to its content
404
+ /// within `maxWidth`/`maxHeight`. Returns its outer size and, when
405
+ /// placing, every child's frame in its own coordinates.
406
+ private func layout(
407
+ width: CGFloat?,
408
+ height: CGFloat?,
409
+ maxWidth: CGFloat,
410
+ maxHeight: CGFloat,
411
+ place: Bool
412
+ ) -> (size: CGSize, frames: [(UIView, CGRect)]) {
413
+ let children = visibleChildren
414
+ let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
415
+ let horizontal = axis == .horizontal
416
+ let paddingWidth = padding.left + padding.right
417
+ let paddingHeight = padding.top + padding.bottom
418
+ let innerWidth = width.map { max(0, $0 - paddingWidth) }
419
+ let innerHeight = height.map { max(0, $0 - paddingHeight) }
420
+ let roomWidth = max(0, (width ?? maxWidth) - paddingWidth)
421
+ let roomHeight = max(0, (height ?? maxHeight) - paddingHeight)
422
+
423
+ var frames: [(UIView, CGRect)] = []
424
+ var contentSize: CGSize
425
+ if grid {
426
+ contentSize = layoutGrid(flow, innerWidth: innerWidth, roomWidth: roomWidth, innerHeight: innerHeight, place: place, frames: &frames)
427
+ } else {
428
+ contentSize = layoutFlex(
429
+ flow,
430
+ horizontal: horizontal,
431
+ innerWidth: innerWidth,
432
+ innerHeight: innerHeight,
433
+ roomWidth: roomWidth,
434
+ roomHeight: roomHeight,
435
+ place: place,
436
+ frames: &frames
437
+ )
438
+ }
439
+ let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
440
+
441
+ if place {
442
+ let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
443
+ for child in children where child.craftLayoutStyle.position == "absolute" {
444
+ let style = child.craftLayoutStyle
445
+ let fixedWidth = style.left != nil && style.right != nil
446
+ ? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
447
+ : style.width?.resolve(content.width)
448
+ let fixedHeight = style.top != nil && style.bottom != nil
449
+ ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
450
+ : style.height?.resolve(content.height)
451
+ let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
452
+ let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
453
+ let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
454
+ frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height)))
455
+ }
456
+ }
457
+ return (size, frames)
458
+ }
459
+
460
+ private func layoutFlex(
461
+ _ children: [UIView],
462
+ horizontal: Bool,
463
+ innerWidth: CGFloat?,
464
+ innerHeight: CGFloat?,
465
+ roomWidth: CGFloat,
466
+ roomHeight: CGFloat,
467
+ place: Bool,
468
+ frames: inout [(UIView, CGRect)]
469
+ ) -> CGSize {
470
+ let definiteMain = horizontal ? innerWidth : innerHeight
471
+ let definiteCross = horizontal ? innerHeight : innerWidth
472
+ let roomMain = horizontal ? roomWidth : roomHeight
473
+ let roomCross = horizontal ? roomHeight : roomWidth
474
+ let mainGap = horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
475
+ let crossGap = horizontal ? (rowGap ?? 0) : (columnGap ?? 0)
476
+
477
+ func measure(_ view: UIView, main: CGFloat?, cross: CGFloat?, mainLimit: CGFloat, crossLimit: CGFloat) -> (main: CGFloat, cross: CGFloat) {
478
+ let size = horizontal
479
+ ? CraftNativeFlexLayout.measure(view, width: main, height: cross, maxWidth: mainLimit, maxHeight: crossLimit)
480
+ : CraftNativeFlexLayout.measure(view, width: cross, height: main, maxWidth: crossLimit, maxHeight: mainLimit)
481
+ return horizontal ? (size.width, size.height) : (size.height, size.width)
482
+ }
483
+
484
+ var items: [Item] = children.map { view in
485
+ let style = view.craftLayoutStyle
486
+ let mainLeading = horizontal ? style.marginLeft : style.marginTop
487
+ let mainTrailing = horizontal ? style.marginRight : style.marginBottom
488
+ let crossLeading = horizontal ? style.marginTop : style.marginLeft
489
+ let crossTrailing = horizontal ? style.marginBottom : style.marginRight
490
+ let align = style.alignSelf ?? alignItems
491
+ let explicitMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
492
+ let explicitCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
493
+ let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
494
+ let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
495
+ let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
496
+ let maxCross = (horizontal ? style.maxHeight : style.maxWidth)?.resolve(definiteCross)
497
+ var fixedCross = explicitCross
498
+ if fixedCross == nil, align == "stretch", !wrap, let definiteCross {
499
+ fixedCross = definiteCross - crossLeading - crossTrailing
500
+ }
501
+ fixedCross = fixedCross.map { clamp($0, minCross, maxCross) }
502
+ let defaultShrink: CGFloat
503
+ if view is UIScrollView {
504
+ defaultShrink = 1
505
+ } else if view is UILabel {
506
+ defaultShrink = horizontal ? 1 : 0
507
+ } else {
508
+ defaultShrink = 0
509
+ }
510
+
511
+ // The basis: a flex-basis, else the explicit size, else the
512
+ // content's size. A zero basis (`flex-1`) means "share the free
513
+ // space" only when there is a free space to share; in a container
514
+ // sized by its content, the content is the basis, as on the web.
515
+ let basis: CGFloat
516
+ if let flexBasis = style.flexBasis?.resolve(definiteMain), definiteMain != nil || flexBasis > 0 {
517
+ basis = flexBasis
518
+ } else if let explicitMain {
519
+ basis = explicitMain
520
+ } else {
521
+ basis = measure(
522
+ view,
523
+ main: nil,
524
+ cross: fixedCross,
525
+ mainLimit: max(0, (maxMain ?? roomMain) - mainLeading - mainTrailing),
526
+ crossLimit: max(0, (fixedCross ?? (maxCross ?? roomCross - crossLeading - crossTrailing)))
527
+ ).main
528
+ }
529
+ return Item(
530
+ view: view,
531
+ style: style,
532
+ align: align,
533
+ mainLeading: mainLeading,
534
+ mainTrailing: mainTrailing,
535
+ crossLeading: crossLeading,
536
+ crossTrailing: crossTrailing,
537
+ minMain: minMain,
538
+ maxMain: maxMain,
539
+ minCross: minCross,
540
+ maxCross: maxCross,
541
+ fixedCross: fixedCross,
542
+ grow: style.flexGrow,
543
+ shrink: style.flexShrink ?? defaultShrink,
544
+ basis: clamp(basis, minMain, maxMain)
545
+ )
546
+ }
547
+
548
+ // Lines: one, or as many as wrapping needs.
549
+ var lines: [Range<Int>] = []
550
+ let lineLimit = definiteMain ?? roomMain
551
+ var start = 0
552
+ var used: CGFloat = 0
553
+ for index in items.indices {
554
+ let size = items[index].basis + items[index].mainMargins
555
+ let next = index == start ? size : used + mainGap + size
556
+ if wrap, index > start, next > lineLimit {
557
+ lines.append(start..<index)
558
+ start = index
559
+ used = size
560
+ } else {
561
+ used = next
562
+ }
563
+ }
564
+ if start < items.count { lines.append(start..<items.count) }
565
+
566
+ var lineMains: [CGFloat] = []
567
+ var lineCrosses: [CGFloat] = []
568
+ for line in lines {
569
+ let gaps = CGFloat(max(0, line.count - 1)) * mainGap
570
+ let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + gaps
571
+ // A row sized by its content is as wide as that content, up to the
572
+ // room it has; a column sized by its content is as tall as it is.
573
+ let lineMain = definiteMain ?? (horizontal ? min(hypothetical, roomMain) : hypothetical)
574
+ let free = lineMain - hypothetical
575
+ if free > 0 {
576
+ let total = line.reduce(0) { $0 + items[$1].grow }
577
+ for index in line {
578
+ let share = total > 0 ? free * items[index].grow / total : 0
579
+ items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
173
580
  }
174
- let frame: CGRect
175
- if axis == .horizontal {
176
- frame = CGRect(x: content.minX + mainOffset, y: content.minY + crossOffset + crossPosition, width: size.width, height: crossSize)
581
+ } else if free < 0 {
582
+ let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
583
+ for index in line {
584
+ let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
585
+ items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
586
+ }
587
+ } else {
588
+ for index in line { items[index].main = items[index].basis }
589
+ }
590
+
591
+ var lineCross: CGFloat = 0
592
+ for index in line {
593
+ let item = items[index]
594
+ if let fixedCross = item.fixedCross {
595
+ items[index].cross = fixedCross
177
596
  } else {
178
- frame = CGRect(x: content.minX + crossOffset + crossPosition, y: content.minY + mainOffset, width: crossSize, height: size.height)
597
+ let measured = measure(
598
+ item.view,
599
+ main: item.main,
600
+ cross: nil,
601
+ mainLimit: item.main,
602
+ crossLimit: max(0, (item.maxCross ?? roomCross) - item.crossMargins)
603
+ )
604
+ items[index].cross = clamp(measured.cross, item.minCross, item.maxCross)
179
605
  }
180
- child.frame = frame.integral
181
- mainOffset += (axis == .horizontal ? size.width : size.height) + mainGapValue + between
606
+ lineCross = max(lineCross, items[index].cross + item.crossMargins)
607
+ }
608
+ // A single line is as thick as the container when the container's
609
+ // size is known: stretched children fill it, others align in it.
610
+ if !wrap, let definiteCross { lineCross = definiteCross }
611
+ for index in line where items[index].fixedCross == nil && items[index].align == "stretch" {
612
+ let item = items[index]
613
+ if case .some = (horizontal ? item.style.height : item.style.width) { continue }
614
+ items[index].cross = clamp(lineCross - item.crossMargins, item.minCross, item.maxCross)
182
615
  }
183
- crossOffset += lineCross + crossGapValue
616
+ lineMains.append(line.reduce(0) { $0 + items[$1].main + items[$1].mainMargins } + gaps)
617
+ lineCrosses.append(lineCross)
184
618
  }
185
619
 
186
- for child in subviews where !child.isHidden && !(child is CraftNativeFlexSpacer) {
187
- let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
188
- guard style.position == "absolute" else { continue }
189
- let size = measuredSize(for: child, available: content.size, style: style)
190
- let width = style.left != nil && style.right != nil ? max(0, content.width - (style.left ?? 0) - (style.right ?? 0)) : size.width
191
- let height = style.top != nil && style.bottom != nil ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0)) : size.height
192
- let x = style.left ?? (style.right.map { content.width - $0 - width } ?? 0)
193
- let y = style.top ?? (style.bottom.map { content.height - $0 - height } ?? 0)
194
- child.frame = CGRect(x: content.minX + x, y: content.minY + y, width: width, height: height).integral
620
+ let contentMain = definiteMain ?? (lineMains.max() ?? 0)
621
+ let contentCross = definiteCross
622
+ ?? (lineCrosses.reduce(0, +) + CGFloat(max(0, lineCrosses.count - 1)) * crossGap)
623
+
624
+ if place {
625
+ var crossOffset: CGFloat = 0
626
+ for (lineIndex, line) in lines.enumerated() {
627
+ let free = max(0, contentMain - lineMains[lineIndex])
628
+ let count = line.count
629
+ let leading: CGFloat
630
+ let between: CGFloat
631
+ switch justifyContent {
632
+ case "center": leading = free / 2; between = 0
633
+ case "flex-end": leading = free; between = 0
634
+ case "space-between": leading = 0; between = count > 1 ? free / CGFloat(count - 1) : 0
635
+ case "space-around": leading = free / CGFloat(count * 2); between = free / CGFloat(count)
636
+ case "space-evenly": leading = free / CGFloat(count + 1); between = free / CGFloat(count + 1)
637
+ default: leading = 0; between = 0
638
+ }
639
+ let lineCross = lineCrosses[lineIndex]
640
+ var mainOffset = leading
641
+ for index in line {
642
+ let item = items[index]
643
+ let crossPosition: CGFloat
644
+ switch item.align {
645
+ case "center": crossPosition = item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
646
+ case "flex-end": crossPosition = lineCross - item.crossTrailing - item.cross
647
+ default: crossPosition = item.crossLeading
648
+ }
649
+ let mainPosition = mainOffset + item.mainLeading
650
+ let frame = horizontal
651
+ ? CGRect(x: padding.left + mainPosition, y: padding.top + crossOffset + crossPosition, width: item.main, height: item.cross)
652
+ : CGRect(x: padding.left + crossOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
653
+ frames.append((item.view, frame))
654
+ mainOffset += item.mainMargins + item.main + mainGap + between
655
+ }
656
+ crossOffset += lineCross + crossGap
657
+ }
658
+ }
659
+ return horizontal ? CGSize(width: contentMain, height: contentCross) : CGSize(width: contentCross, height: contentMain)
660
+ }
661
+
662
+ private func layoutGrid(
663
+ _ children: [UIView],
664
+ innerWidth: CGFloat?,
665
+ roomWidth: CGFloat,
666
+ innerHeight: CGFloat?,
667
+ place: Bool,
668
+ frames: inout [(UIView, CGRect)]
669
+ ) -> CGSize {
670
+ let columns = max(1, gridColumns)
671
+ let horizontalGap = columnGap ?? gap
672
+ let verticalGap = rowGap ?? gap
673
+ let gaps = CGFloat(columns - 1) * horizontalGap
674
+ var cellWidth: CGFloat
675
+ if let innerWidth {
676
+ cellWidth = max(0, (innerWidth - gaps) / CGFloat(columns))
677
+ } else {
678
+ // Sized by content: the widest cell, as long as the row fits.
679
+ let widest = children.map { child -> CGFloat in
680
+ let style = child.craftLayoutStyle
681
+ return CraftNativeFlexLayout.measure(child, width: style.width?.resolve(nil), height: nil, maxWidth: roomWidth, maxHeight: CraftNativeFlexLayout.unbounded).width
682
+ + style.marginLeft + style.marginRight
683
+ }.max() ?? 0
684
+ cellWidth = min(widest, max(0, (roomWidth - gaps) / CGFloat(columns)))
195
685
  }
686
+ var rowHeights: [CGFloat] = []
687
+ var sizes: [CGSize] = []
688
+ for (index, child) in children.enumerated() {
689
+ let style = child.craftLayoutStyle
690
+ let available = max(0, cellWidth - style.marginLeft - style.marginRight)
691
+ let align = style.alignSelf ?? alignItems
692
+ let fixedWidth = style.width?.resolve(cellWidth) ?? (align == "stretch" ? available : nil)
693
+ let size = CraftNativeFlexLayout.measure(
694
+ child,
695
+ width: fixedWidth.map { min($0, available) },
696
+ height: style.height?.resolve(nil),
697
+ maxWidth: available,
698
+ maxHeight: CraftNativeFlexLayout.unbounded
699
+ )
700
+ sizes.append(size)
701
+ let row = index / columns
702
+ while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
703
+ rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
704
+ }
705
+ let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
706
+ let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
707
+ if place {
708
+ var rowTop = padding.top
709
+ for (index, child) in children.enumerated() {
710
+ let style = child.craftLayoutStyle
711
+ let row = index / columns
712
+ let column = index % columns
713
+ let rowHeight = rowHeights[row]
714
+ let size = sizes[index]
715
+ let align = style.alignSelf ?? alignItems
716
+ let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
717
+ let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
718
+ let childWidth = min(availableWidth, size.width)
719
+ let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
720
+ let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
721
+ + (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
722
+ let y = rowTop + style.marginTop
723
+ + (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
724
+ frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
725
+ if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + verticalGap }
726
+ }
727
+ }
728
+ return CGSize(width: width, height: innerHeight ?? height)
196
729
  }
730
+ }
197
731
 
198
- private var mainGapValue: CGFloat { axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap) }
199
- private var crossGapValue: CGFloat { axis == .horizontal ? (rowGap ?? 0) : (columnGap ?? 0) }
732
+ private final class CraftNativeModalView: UIView {
733
+ let contentStack = CraftNativeFlowView()
734
+ private let blocker = UIView()
735
+ private var renderedVisible = false
736
+ var onRequestClose: (() -> Void)?
200
737
 
201
- private func styleCrossDimension(_ style: CraftNativeLayoutStyle) -> CGFloat? {
202
- axis == .horizontal ? style.height : style.width
738
+ var transparent = false {
739
+ didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
203
740
  }
204
741
 
205
- private func measuredSize(for view: UIView, available: CGSize, style: CraftNativeLayoutStyle? = nil) -> CGSize {
206
- let style = style ?? childStyles[ObjectIdentifier(view)] ?? CraftNativeLayoutStyle([:])
207
- let intrinsic = view.intrinsicContentSize
208
- let fitted = view.sizeThatFits(available)
209
- let width = style.width ?? (intrinsic.width > 0 && intrinsic.width != UIView.noIntrinsicMetric ? intrinsic.width : max(0, fitted.width))
210
- let height = style.height ?? (intrinsic.height > 0 && intrinsic.height != UIView.noIntrinsicMetric ? intrinsic.height : max(0, fitted.height))
211
- return CGSize(width: clamp(width, min: style.minWidth, max: style.maxWidth), height: clamp(height, min: style.minHeight, max: style.maxHeight))
742
+ override init(frame: CGRect) {
743
+ super.init(frame: frame)
744
+ isUserInteractionEnabled = true
745
+ blocker.isUserInteractionEnabled = true
746
+ blocker.backgroundColor = UIColor.black.withAlphaComponent(0.32)
747
+ blocker.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blockerTapped)))
748
+ addSubview(blocker)
749
+ contentStack.backgroundColor = .clear
750
+ addSubview(contentStack)
212
751
  }
213
752
 
214
- private func clamp(_ value: CGFloat, min lower: CGFloat?, max upper: CGFloat?) -> CGFloat {
215
- var result = value
216
- if let lower { result = max(result, lower) }
217
- if let upper { result = min(result, upper) }
218
- return result
219
- }
753
+ required init?(coder: NSCoder) { nil }
220
754
 
221
- private func distribution(free: CGFloat, count: Int) -> (CGFloat, CGFloat) {
222
- guard count > 0 else { return (0, 0) }
223
- switch justifyContent {
224
- case "center": return (free / 2, 0)
225
- case "flex-end": return (free, 0)
226
- case "space-between": return (0, count > 1 ? free / CGFloat(count - 1) : 0)
227
- case "space-around": return (free / CGFloat(count * 2), free / CGFloat(count))
228
- case "space-evenly": return (free / CGFloat(count + 1), free / CGFloat(count + 1))
229
- default: return (0, 0)
755
+ func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
756
+ guard renderedVisible != visible || isHidden != !visible else { return }
757
+ renderedVisible = visible
758
+ guard visible else {
759
+ isHidden = true
760
+ alpha = 0
761
+ return
762
+ }
763
+ isHidden = false
764
+ if animationType == "none" {
765
+ alpha = opacity
766
+ } else {
767
+ alpha = 0
768
+ UIView.animate(withDuration: animationType == "slide" ? 0.24 : 0.18) { self.alpha = opacity }
230
769
  }
231
770
  }
771
+
772
+ @objc private func blockerTapped() { onRequestClose?() }
773
+
774
+ override func layoutSubviews() {
775
+ super.layoutSubviews()
776
+ blocker.frame = bounds
777
+ contentStack.frame = bounds
778
+ }
232
779
  }
233
780
 
234
- private final class CraftNativeScrollView: UIScrollView {
781
+ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
235
782
  let contentStack = CraftNativeFlowView()
236
- private var crossAxisConstraint: NSLayoutConstraint?
783
+ private let keyboardTapGesture = UITapGestureRecognizer()
784
+ private var keyboardShouldPersistTaps = "never"
785
+ private var contentNeedsLayout = true
786
+ private var laidOutSize: CGSize = .zero
787
+ private let pullToRefresh = UIRefreshControl()
788
+ private var refreshHandler: (() -> Void)?
237
789
 
238
790
  override init(frame: CGRect) {
239
791
  super.init(frame: frame)
240
- contentStack.translatesAutoresizingMaskIntoConstraints = false
792
+ keyboardTapGesture.cancelsTouchesInView = false
793
+ keyboardTapGesture.delegate = self
794
+ keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
795
+ addGestureRecognizer(keyboardTapGesture)
796
+ pullToRefresh.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
241
797
  addSubview(contentStack)
242
- NSLayoutConstraint.activate([
243
- contentStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
244
- contentStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
245
- contentStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
246
- contentStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor)
247
- ])
248
798
  setAxis(.vertical)
249
799
  }
250
800
 
251
801
  required init?(coder: NSCoder) { nil }
252
802
 
803
+ var isVertical: Bool { contentStack.axis == .vertical }
804
+
805
+ /// Lays the content out again on the next pass. Scrolling alone does not:
806
+ /// a scroll view lays out on every frame of a scroll.
807
+ func setContentNeedsLayout() {
808
+ contentNeedsLayout = true
809
+ setNeedsLayout()
810
+ }
811
+
812
+ /// The size of the content laid out across the visible width (or, sideways,
813
+ /// height), which is also the scroll view's own size when nothing fixes it.
814
+ func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
815
+ if isVertical {
816
+ let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
817
+ return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
818
+ }
819
+ let contentHeight = height.map { max(0, $0 - adjustedContentInset.top - adjustedContentInset.bottom) }
820
+ return contentStack.measure(width: nil, height: contentHeight, maxWidth: CraftNativeFlexLayout.unbounded, maxHeight: contentHeight ?? CraftNativeFlexLayout.unbounded)
821
+ }
822
+
823
+ func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
824
+ if isVertical {
825
+ let resolvedWidth = width ?? maxWidth
826
+ return CGSize(width: resolvedWidth, height: height ?? contentFitting(width: resolvedWidth, height: nil).height)
827
+ }
828
+ let content = contentFitting(width: nil, height: height)
829
+ return CGSize(width: width ?? min(content.width, maxWidth), height: height ?? content.height)
830
+ }
831
+
832
+ override func layoutSubviews() {
833
+ super.layoutSubviews()
834
+ craftApplyCornerRadius()
835
+ guard contentNeedsLayout || laidOutSize != bounds.size else { return }
836
+ contentNeedsLayout = false
837
+ laidOutSize = bounds.size
838
+ var size = contentFitting(width: bounds.width, height: bounds.height)
839
+ if isVertical {
840
+ size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
841
+ } else {
842
+ size.height = max(0, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
843
+ }
844
+ let frame = CGRect(origin: .zero, size: size)
845
+ if contentStack.frame != frame { contentStack.frame = frame }
846
+ if contentSize != size { contentSize = size }
847
+ }
848
+
849
+ override func adjustedContentInsetDidChange() {
850
+ super.adjustedContentInsetDidChange()
851
+ setContentNeedsLayout()
852
+ }
853
+
854
+ private var refreshingProp = false
855
+
856
+ /// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
857
+ /// as in React Native. The spinner shows while `refreshing` is true; a
858
+ /// pull that the screen does not answer with `refreshing` true ends at
859
+ /// once instead of spinning forever.
860
+ func setRefreshHandler(_ handler: (() -> Void)?, refreshing: Bool) {
861
+ refreshHandler = handler
862
+ refreshingProp = refreshing
863
+ if handler == nil {
864
+ if pullToRefresh.isRefreshing { pullToRefresh.endRefreshing() }
865
+ if refreshControl === pullToRefresh { refreshControl = nil }
866
+ return
867
+ }
868
+ if refreshControl !== pullToRefresh { refreshControl = pullToRefresh }
869
+ if refreshing, !pullToRefresh.isRefreshing {
870
+ pullToRefresh.beginRefreshing()
871
+ } else if !refreshing, pullToRefresh.isRefreshing {
872
+ pullToRefresh.endRefreshing()
873
+ }
874
+ }
875
+
876
+ var isRefreshingNow: Bool { pullToRefresh.isRefreshing }
877
+
878
+ @objc private func refreshPulled() {
879
+ refreshHandler?()
880
+ // The handler's synchronous render has landed by now.
881
+ DispatchQueue.main.async { [weak self] in
882
+ guard let self, !self.refreshingProp, self.pullToRefresh.isRefreshing else { return }
883
+ self.pullToRefresh.endRefreshing()
884
+ }
885
+ }
886
+
887
+ func setKeyboardShouldPersistTaps(_ value: Any?) {
888
+ let requested = value as? String
889
+ keyboardShouldPersistTaps = ["always", "handled", "never"].contains(requested) ? requested! : "never"
890
+ keyboardTapGesture.isEnabled = keyboardShouldPersistTaps != "always"
891
+ }
892
+
893
+ @objc private func keyboardTap() {
894
+ guard keyboardShouldPersistTaps != "always" else { return }
895
+ if keyboardShouldPersistTaps == "handled" {
896
+ let point = keyboardTapGesture.location(in: self)
897
+ var hit = hitTest(point, with: nil)
898
+ while let view = hit, view !== self {
899
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
900
+ hit = view.superview
901
+ }
902
+ }
903
+ window?.endEditing(true)
904
+ }
905
+
906
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
907
+ true
908
+ }
909
+
253
910
  func setAxis(_ axis: NSLayoutConstraint.Axis) {
911
+ if contentStack.axis != axis { setContentNeedsLayout() }
254
912
  contentStack.axis = axis
255
- crossAxisConstraint?.isActive = false
256
- crossAxisConstraint = axis == .vertical
257
- ? contentStack.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor)
258
- : contentStack.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor)
259
- crossAxisConstraint?.isActive = true
260
913
  alwaysBounceVertical = axis == .vertical
261
914
  alwaysBounceHorizontal = axis == .horizontal
262
915
  }
263
916
  }
264
917
 
918
+ /// Text with padding of its own: the padding is inside the label, around
919
+ /// the text, and counts in its measured size.
920
+ private final class CraftNativeLabel: UILabel {
921
+ var insets = UIEdgeInsets.zero {
922
+ didSet {
923
+ guard insets != oldValue else { return }
924
+ invalidateIntrinsicContentSize()
925
+ setNeedsDisplay()
926
+ }
927
+ }
928
+
929
+ override func drawText(in rect: CGRect) {
930
+ super.drawText(in: rect.inset(by: insets))
931
+ }
932
+
933
+ override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect {
934
+ guard insets != .zero else { return super.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines) }
935
+ let inner = super.textRect(forBounds: bounds.inset(by: insets), limitedToNumberOfLines: numberOfLines)
936
+ return CGRect(
937
+ x: inner.minX - insets.left,
938
+ y: inner.minY - insets.top,
939
+ width: inner.width + insets.left + insets.right,
940
+ height: inner.height + insets.top + insets.bottom
941
+ )
942
+ }
943
+
944
+ override func layoutSubviews() {
945
+ super.layoutSubviews()
946
+ craftApplyCornerRadius()
947
+ }
948
+ }
949
+
265
950
  private final class CraftNativeFlexSpacer: UIView {}
266
951
 
267
- final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate {
952
+ /// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
953
+ /// when the style gives it no size at all.
954
+ private final class CraftNativeIconView: UIImageView {
955
+ var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
956
+
957
+ override var intrinsicContentSize: CGSize {
958
+ if let defaultBox { return CGSize(width: defaultBox, height: defaultBox) }
959
+ return super.intrinsicContentSize
960
+ }
961
+ }
962
+
963
+ private final class CraftNativeTextView: UITextView {
964
+ private let placeholderLabel = UILabel()
965
+ var placeholder: String? {
966
+ didSet { refreshPlaceholder() }
967
+ }
968
+ var placeholderColor: UIColor? {
969
+ didSet { placeholderLabel.textColor = placeholderColor ?? .placeholderText }
970
+ }
971
+ var placeholderFont: UIFont? {
972
+ didSet { placeholderLabel.font = placeholderFont ?? .systemFont(ofSize: 16) }
973
+ }
974
+
975
+ override init(frame: CGRect, textContainer: NSTextContainer?) {
976
+ super.init(frame: frame, textContainer: textContainer)
977
+ configurePlaceholder()
978
+ }
979
+
980
+ convenience init() {
981
+ self.init(frame: .zero, textContainer: nil)
982
+ }
983
+
984
+ required init?(coder: NSCoder) {
985
+ super.init(coder: coder)
986
+ configurePlaceholder()
987
+ }
988
+
989
+ override func layoutSubviews() {
990
+ super.layoutSubviews()
991
+ let inset = textContainerInset
992
+ let padding = textContainer.lineFragmentPadding
993
+ placeholderLabel.frame = CGRect(
994
+ x: inset.left + padding,
995
+ y: inset.top,
996
+ width: max(0, bounds.width - inset.left - inset.right - padding * 2),
997
+ height: max(0, bounds.height - inset.top - inset.bottom)
998
+ )
999
+ }
1000
+
1001
+ func refreshPlaceholder() {
1002
+ placeholderLabel.text = placeholder
1003
+ placeholderLabel.isHidden = !(text?.isEmpty ?? true) || placeholder?.isEmpty != false
1004
+ }
1005
+
1006
+ private func configurePlaceholder() {
1007
+ placeholderLabel.numberOfLines = 0
1008
+ placeholderLabel.font = font
1009
+ placeholderLabel.textColor = .placeholderText
1010
+ placeholderLabel.isUserInteractionEnabled = false
1011
+ addSubview(placeholderLabel)
1012
+ refreshPlaceholder()
1013
+ }
1014
+ }
1015
+
1016
+ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
268
1017
  private final class RenderedNode {
269
1018
  let identity: String
270
1019
  let type: String
@@ -288,25 +1037,55 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
288
1037
  private let jsContext = JSContext()!
289
1038
  private let rootStack = CraftNativeFlowView()
290
1039
  private var handlers: [ObjectIdentifier: String] = [:]
1040
+ private var pressInHandlers: [ObjectIdentifier: String] = [:]
1041
+ private var pressOutHandlers: [ObjectIdentifier: String] = [:]
291
1042
  private var focusHandlers: [ObjectIdentifier: String] = [:]
292
1043
  private var blurHandlers: [ObjectIdentifier: String] = [:]
1044
+ private var endEditingHandlers: [ObjectIdentifier: String] = [:]
293
1045
  private var submitHandlers: [ObjectIdentifier: String] = [:]
1046
+ private var inputIdentities: [ObjectIdentifier: String] = [:]
1047
+ private var inputDrafts: [String: String] = [:]
1048
+ private var longPressHandlers: [ObjectIdentifier: String] = [:]
1049
+ private var textMaxLengths: [ObjectIdentifier: Int] = [:]
294
1050
  private var scrollHandlers: [ObjectIdentifier: String] = [:]
295
1051
  private var scrollBeginHandlers: [ObjectIdentifier: String] = [:]
296
1052
  private var scrollEndHandlers: [ObjectIdentifier: String] = [:]
1053
+ private var scrollMomentumBeginHandlers: [ObjectIdentifier: String] = [:]
1054
+ private var scrollMomentumEndHandlers: [ObjectIdentifier: String] = [:]
1055
+ private var sliderCompleteHandlers: [ObjectIdentifier: String] = [:]
1056
+ private var sliderSteps: [ObjectIdentifier: Float] = [:]
1057
+ private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
1058
+ private var modalVisibility: [ObjectIdentifier: Bool] = [:]
1059
+ private var layoutHandlers: [ObjectIdentifier: String] = [:]
1060
+ private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
297
1061
  private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
1062
+ private var longPressRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
1063
+ private var pressFeedbackRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
298
1064
  private var imageSources: [ObjectIdentifier: String] = [:]
299
1065
  private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
1066
+ private var imageErrors: [ObjectIdentifier: String] = [:]
1067
+ private var tintedImages = Set<ObjectIdentifier>()
1068
+ private var imageLoadStartHandlers: [ObjectIdentifier: String] = [:]
1069
+ private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
1070
+ private var imageLoadEndHandlers: [ObjectIdentifier: String] = [:]
1071
+ private var imageErrorHandlers: [ObjectIdentifier: String] = [:]
300
1072
  private var renderedRoot: RenderedNode?
301
- private weak var lastFocusedInput: UITextField?
1073
+ private weak var lastFocusedInput: UIView?
302
1074
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
303
1075
  private var flatListOwners: [String: String] = [:]
304
1076
  private let mutationDocument = CraftNativeMutationDocument()
305
1077
  private let capabilityScope = UUID().uuidString
306
1078
  private var pendingCapabilityRequests = Set<String>()
307
1079
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
1080
+ private var pendingTimers: [Int: DispatchWorkItem] = [:]
308
1081
  private var lifecycleObservers: [NSObjectProtocol] = []
309
1082
  private var deepLinkListener: UUID?
1083
+ private var keyboardSafeAreaInset: CGFloat = 0
1084
+ /// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
1085
+ /// app's native screens: told of the first frame, of a first frame that
1086
+ /// could not be drawn, and of `craft.navigation.open(path)`.
1087
+ weak var hybridEvents: CraftHybridScreenEvents?
1088
+ private var hybridRendered = false
310
1089
 
311
1090
  init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
312
1091
  self.config = config
@@ -322,6 +1101,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
322
1101
  deinit {
323
1102
  imageTasks.values.forEach { $0.cancel() }
324
1103
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
1104
+ pendingTimers.values.forEach { $0.cancel() }
325
1105
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
326
1106
  lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
327
1107
  if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
@@ -335,8 +1115,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
335
1115
  rootStack.alignItems = "stretch"
336
1116
  rootStack.translatesAutoresizingMaskIntoConstraints = false
337
1117
  view.addSubview(rootStack)
1118
+ rootTopToSafeArea = rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
1119
+ rootTopToView = rootStack.topAnchor.constraint(equalTo: view.topAnchor)
338
1120
  NSLayoutConstraint.activate([
339
- rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
1121
+ rootTopToSafeArea!,
340
1122
  rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
341
1123
  rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
342
1124
  rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
@@ -346,18 +1128,80 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
346
1128
  observeNativeEvents()
347
1129
  }
348
1130
 
1131
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
1132
+ super.traitCollectionDidChange(previousTraitCollection)
1133
+ guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
1134
+ refreshTraitDefaults()
1135
+ send(type: "APPEARANCE", payload: ["colorScheme": colorScheme])
1136
+ }
1137
+
1138
+ override func viewWillAppear(_ animated: Bool) {
1139
+ super.viewWillAppear(animated)
1140
+ if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
1141
+ navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
1142
+ } else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
1143
+ navigationController?.viewControllers.first !== self {
1144
+ // A screen that did not ask for a hidden bar shows one, even after
1145
+ // a screen that hid its own.
1146
+ navigationController?.setNavigationBarHidden(false, animated: animated)
1147
+ }
1148
+ refreshTraitDefaults()
1149
+ }
1150
+
1151
+ private func refreshTraitDefaults() {
1152
+ view.backgroundColor = config.resolvedBackgroundColor
1153
+ if let document = mutationDocument.node("root") {
1154
+ renderCommitted(document)
1155
+ } else {
1156
+ rootStack.setNeedsLayout()
1157
+ }
1158
+ refreshTraitDefaults(in: renderedRoot)
1159
+ }
1160
+
1161
+ private func refreshTraitDefaults(in node: RenderedNode?) {
1162
+ guard let node else { return }
1163
+ (node.view as? CraftNativeFlatList)?.refreshThemeDefaults()
1164
+ node.children.forEach { refreshTraitDefaults(in: $0) }
1165
+ }
1166
+
1167
+ override func viewDidLayoutSubviews() {
1168
+ super.viewDidLayoutSubviews()
1169
+ emitLayoutEvents()
1170
+ }
1171
+
349
1172
  private func setupJavaScript() {
350
1173
  if let routeName = routeName {
351
1174
  jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
352
1175
  }
353
1176
  jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
354
- jsContext.exceptionHandler = { _, exception in
355
- NSLog("[craft native] JavaScript exception: %@", exception?.toString() ?? "unknown")
1177
+ jsContext.exceptionHandler = { [weak self] context, exception in
1178
+ let message = exception?.toString() ?? "unknown"
1179
+ let stack = exception?.objectForKeyedSubscript("stack")?.toString() ?? ""
1180
+ CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
1181
+ self?.hybridFailed(message)
356
1182
  }
357
1183
  let postMessage: @convention(block) (String) -> Void = { [weak self] json in
358
1184
  self?.receive(json)
359
1185
  }
360
1186
  jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
1187
+ // JavaScriptCore has no timers of its own. The callbacks stay in
1188
+ // JavaScript; native only owns the main-queue deadline, so route
1189
+ // teardown cancels every pending timer with the controller.
1190
+ let scheduleTimer: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
1191
+ guard let self = self else { return }
1192
+ let work = DispatchWorkItem { [weak self] in
1193
+ guard let self = self, self.pendingTimers.removeValue(forKey: id) != nil else { return }
1194
+ self.jsContext.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
1195
+ }
1196
+ self.pendingTimers[id] = work
1197
+ let milliseconds = delay.isFinite ? Int(min(max(delay, 0), 2_147_483_647)) : 0
1198
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
1199
+ }
1200
+ let cancelTimer: @convention(block) (Int) -> Void = { [weak self] id in
1201
+ self?.pendingTimers.removeValue(forKey: id)?.cancel()
1202
+ }
1203
+ jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
1204
+ jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
361
1205
  let capabilities = CraftNativeActions.capabilities(config: config)
362
1206
  let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
363
1207
  let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
@@ -370,12 +1214,185 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
370
1214
  capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
371
1215
  capabilities: \(capabilityJSON),
372
1216
  initialAppState: "\(CraftNativeActions.currentAppState())",
1217
+ colorScheme: "\(colorScheme)",
373
1218
  postMessage: function(message) { craftNativePostMessage(message); },
374
1219
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
375
1220
  };
376
- globalThis.console = {
377
- log: function() {}, warn: function() {}, error: function() {}
378
- };
1221
+ """)
1222
+ installHostAPIs()
1223
+ jsContext.evaluateScript("""
1224
+ if (typeof globalThis.setTimeout !== 'function') {
1225
+ (function() {
1226
+ var timers = new Map();
1227
+ var nextTimer = 0;
1228
+ globalThis.setTimeout = function(callback, delay) {
1229
+ var args = Array.prototype.slice.call(arguments, 2);
1230
+ var id = ++nextTimer;
1231
+ timers.set(id, function() {
1232
+ if (typeof callback === 'function') callback.apply(undefined, args);
1233
+ });
1234
+ craftNativeScheduleTimer(id, Number(delay) || 0);
1235
+ return id;
1236
+ };
1237
+ globalThis.clearTimeout = function(id) {
1238
+ if (timers.delete(id)) craftNativeCancelTimer(id);
1239
+ };
1240
+ globalThis.__craftNativeFireTimer = function(id) {
1241
+ var callback = timers.get(id);
1242
+ if (!callback) return;
1243
+ timers.delete(id);
1244
+ callback();
1245
+ };
1246
+ })();
1247
+ }
1248
+ """)
1249
+ }
1250
+
1251
+ /// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
1252
+ /// the window's appearance once there is one, else the app's setting.
1253
+ private var colorScheme: String {
1254
+ let traits = [view.window?.traitCollection, CraftPresenter.keyWindow()?.traitCollection, traitCollection]
1255
+ if let style = traits.compactMap({ $0 }).first(where: { $0.userInterfaceStyle != .unspecified })?.userInterfaceStyle {
1256
+ return style == .dark ? "dark" : "light"
1257
+ }
1258
+ return config.appearance == "dark" ? "dark" : "light"
1259
+ }
1260
+
1261
+ /// The screen's name as its script knows it: the route the host opened,
1262
+ /// else the one a route bundle selected for itself.
1263
+ private func screenName(in context: JSContext? = nil) -> String {
1264
+ if let routeName, !routeName.isEmpty { return routeName }
1265
+ if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
1266
+ return selected.toString()
1267
+ }
1268
+ return "screen"
1269
+ }
1270
+
1271
+ /// Host functions every screen script can call synchronously, installed
1272
+ /// before the script runs (contract sections 3, 4 and 5):
1273
+ ///
1274
+ /// - `console.*` writes to the unified log, subsystem `craft.native`,
1275
+ /// category the screen's name.
1276
+ /// - `craft.storage.getSync(key)` / `setSync(key, value)`: the store the
1277
+ /// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
1278
+ /// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
1279
+ /// promise): `Application Support/craft-snapshots/<name>.json`.
1280
+ /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
1281
+ /// `craft.secureStorage.set` wrote.
1282
+ /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
1283
+ /// rightButtons })`; a right button's tap is the event `navButton`.
1284
+ ///
1285
+ /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
1286
+ /// these: each namespace is a property whose setter adds them back to the
1287
+ /// object it is given.
1288
+ private func installHostAPIs() {
1289
+ let log: @convention(block) (String, String, String) -> Void = { level, category, message in
1290
+ CraftNativeConsole.write(level, category: category, message: message)
1291
+ }
1292
+ let storageGet: @convention(block) (String) -> String? = { key in
1293
+ CraftNativeActions.storageJSON(forKey: key)
1294
+ }
1295
+ let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
1296
+ CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
1297
+ }
1298
+ let snapshotGet: @convention(block) (String) -> String? = { name in
1299
+ CraftSnapshots.read(name)
1300
+ }
1301
+ let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
1302
+ CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
1303
+ done.call(withArguments: [ok])
1304
+ }
1305
+ }
1306
+ let config = self.config
1307
+ let secureGet: @convention(block) (String) -> String? = { key in
1308
+ config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
1309
+ }
1310
+ let setOptions: @convention(block) (String) -> Void = { [weak self] json in
1311
+ guard let data = json.data(using: .utf8),
1312
+ let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
1313
+ self?.applyNavigationOptions(options)
1314
+ }
1315
+ jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
1316
+ jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
1317
+ jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
1318
+ jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
1319
+ jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
1320
+ jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
1321
+ jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
1322
+ jsContext.evaluateScript("""
1323
+ (function() {
1324
+ function text(value) {
1325
+ if (typeof value === 'string') return value;
1326
+ if (value === undefined) return 'undefined';
1327
+ if (value instanceof Error) return value.stack ? value.message + '\\n' + value.stack : String(value);
1328
+ try { var json = JSON.stringify(value); return json === undefined ? String(value) : json; }
1329
+ catch (error) { return String(value); }
1330
+ }
1331
+ function screen() {
1332
+ var route = globalThis.__stxNativeRoute;
1333
+ return typeof route === 'string' && route ? route : 'screen';
1334
+ }
1335
+ function writer(level) {
1336
+ return function() {
1337
+ craftNativeLog(level, screen(), Array.prototype.map.call(arguments, text).join(' '));
1338
+ };
1339
+ }
1340
+ globalThis.console = {
1341
+ log: writer('log'), info: writer('info'), debug: writer('debug'), trace: writer('debug'),
1342
+ warn: writer('warn'), error: writer('error')
1343
+ };
1344
+
1345
+ function parsed(json) {
1346
+ if (json === null || json === undefined) return null;
1347
+ try { return JSON.parse(json); } catch (error) { return null; }
1348
+ }
1349
+ function encoded(value) {
1350
+ return value === null || value === undefined ? null : JSON.stringify(value);
1351
+ }
1352
+ var craft = globalThis.craft = globalThis.craft || {};
1353
+ function keep(name, extras) {
1354
+ var current = {};
1355
+ function adopt(next) {
1356
+ current = next && typeof next === 'object' ? next : {};
1357
+ Object.keys(extras).forEach(function(key) {
1358
+ if (!(key in current)) current[key] = extras[key];
1359
+ });
1360
+ }
1361
+ adopt(craft[name]);
1362
+ Object.defineProperty(craft, name, {
1363
+ configurable: true,
1364
+ enumerable: true,
1365
+ get: function() { return current; },
1366
+ set: adopt
1367
+ });
1368
+ }
1369
+ keep('storage', {
1370
+ getSync: function(key) { return parsed(craftNativeStorageGet(String(key))); },
1371
+ setSync: function(key, value) {
1372
+ if (!craftNativeStorageSet(String(key), encoded(value))) throw new Error('craft.storage.setSync could not store ' + key);
1373
+ }
1374
+ });
1375
+ keep('snapshots', {
1376
+ get: function(name) { return parsed(craftNativeSnapshotGet(String(name))); },
1377
+ set: function(name, value) {
1378
+ return new Promise(function(resolve, reject) {
1379
+ craftNativeSnapshotSet(String(name), encoded(value), function(ok) {
1380
+ if (ok) resolve();
1381
+ else reject(new Error('craft.snapshots.set could not write ' + name));
1382
+ });
1383
+ });
1384
+ }
1385
+ });
1386
+ keep('secureStorage', {
1387
+ getSync: function(key) {
1388
+ var value = craftNativeSecureGet(String(key));
1389
+ return value === undefined ? null : value;
1390
+ }
1391
+ });
1392
+ keep('navigation', {
1393
+ setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
1394
+ });
1395
+ })();
379
1396
  """)
380
1397
  }
381
1398
 
@@ -383,15 +1400,110 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
383
1400
  guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
384
1401
  ?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
385
1402
  let script = try? String(contentsOf: url, encoding: .utf8) else {
386
- showError("Missing dist/native-screen.js. Compile a .stx screen with stx-native first.")
1403
+ showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
387
1404
  return
388
1405
  }
389
1406
  jsContext.evaluateScript(script)
390
- if routeName == nil, let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute")?.toString() {
391
- navigationItem.title = selected
1407
+ // A bundle that names no route leaves `__stxNativeRoute` undefined;
1408
+ // its title is the app's, not the word "undefined". A title the
1409
+ // script set with `navigation.setOptions` wins over both.
1410
+ if routeName == nil, !titleFromOptions,
1411
+ let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
1412
+ let name = selected.toString(), !name.isEmpty {
1413
+ navigationItem.title = name
1414
+ }
1415
+ }
1416
+
1417
+ // MARK: Navigation bar options
1418
+
1419
+ private var titleFromOptions = false
1420
+ private var rootTopToSafeArea: NSLayoutConstraint?
1421
+ private var rootTopToView: NSLayoutConstraint?
1422
+ private var navigationBarHidden: Bool?
1423
+ private var prefersLargeTitle: Bool?
1424
+ private weak var trackedScrollView: UIScrollView?
1425
+
1426
+ /// `craft.navigation.setOptions`: this screen's bar. Every key is
1427
+ /// optional and only the keys given change.
1428
+ private func applyNavigationOptions(_ options: [String: Any]) {
1429
+ if options.keys.contains("title") {
1430
+ navigationItem.title = options["title"] as? String
1431
+ titleFromOptions = true
1432
+ }
1433
+ if options.keys.contains("backTitle") {
1434
+ navigationItem.backButtonTitle = options["backTitle"] as? String
1435
+ }
1436
+ if let largeTitle = options["largeTitle"] as? Bool {
1437
+ prefersLargeTitle = largeTitle
1438
+ navigationItem.largeTitleDisplayMode = largeTitle ? .always : .never
1439
+ if largeTitle { navigationController?.navigationBar.prefersLargeTitles = true }
1440
+ trackContentScrollView()
1441
+ }
1442
+ if let hidden = options["hidden"] as? Bool {
1443
+ navigationBarHidden = hidden
1444
+ if navigationController?.topViewController === self {
1445
+ navigationController?.setNavigationBarHidden(hidden, animated: view.window != nil)
1446
+ }
1447
+ }
1448
+ if options.keys.contains("rightButtons") {
1449
+ let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
1450
+ navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
392
1451
  }
393
1452
  }
394
1453
 
1454
+ /// A bar button from `{ id, symbol | title }`. Its tap is the event
1455
+ /// `navButton` with `{ id }`.
1456
+ private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
1457
+ guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
1458
+ let action = UIAction { [weak self] _ in
1459
+ self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": id]])
1460
+ }
1461
+ let item: UIBarButtonItem
1462
+ if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
1463
+ item = UIBarButtonItem(image: image, primaryAction: action)
1464
+ } else if let title = spec["title"] as? String {
1465
+ item = UIBarButtonItem(title: title, primaryAction: action)
1466
+ } else {
1467
+ return nil
1468
+ }
1469
+ item.accessibilityIdentifier = "nav-\(id)"
1470
+ if let label = (spec["accessibilityLabel"] as? String) ?? (spec["title"] as? String) {
1471
+ item.accessibilityLabel = label
1472
+ } else {
1473
+ item.accessibilityLabel = id
1474
+ }
1475
+ if let tint = (spec["color"] as? String).flatMap({ UIColor(hex: $0) }) { item.tintColor = tint }
1476
+ return item
1477
+ }
1478
+
1479
+ /// The first vertical scroll view of the screen drives the bar: a large
1480
+ /// title collapses as it scrolls, and the bar's background follows it.
1481
+ private func trackContentScrollView() {
1482
+ guard let renderedRoot else { return }
1483
+ func find(_ node: RenderedNode) -> UIScrollView? {
1484
+ if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical, scroll.isScrollEnabled { return scroll }
1485
+ if let list = node.view as? CraftNativeFlatList, list.isScrollEnabled,
1486
+ (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { return list }
1487
+ for child in node.children {
1488
+ if let match = find(child) { return match }
1489
+ }
1490
+ return nil
1491
+ }
1492
+ let scroll = find(renderedRoot)
1493
+ // Under a large title the screen runs edge to edge, as UIKit's own
1494
+ // screens do: the scroll view starts under the bar and insets its
1495
+ // content by it, so the title collapses and pull to refresh draws
1496
+ // in the bar's space. Other screens stay inside the safe area.
1497
+ let edgeToEdge = scroll != nil && prefersLargeTitle == true
1498
+ if rootTopToView?.isActive != edgeToEdge {
1499
+ rootTopToSafeArea?.isActive = !edgeToEdge
1500
+ rootTopToView?.isActive = edgeToEdge
1501
+ }
1502
+ guard scroll !== trackedScrollView else { return }
1503
+ trackedScrollView = scroll
1504
+ setContentScrollView(scroll, for: .top)
1505
+ }
1506
+
395
1507
  private func observeNativeEvents() {
396
1508
  func observe(_ name: Notification.Name, state: String) {
397
1509
  lifecycleObservers.append(NotificationCenter.default.addObserver(
@@ -403,6 +1515,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
403
1515
  observe(UIApplication.didBecomeActiveNotification, state: "active")
404
1516
  observe(UIApplication.willResignActiveNotification, state: "inactive")
405
1517
  observe(UIApplication.didEnterBackgroundNotification, state: "background")
1518
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
1519
+ forName: UIResponder.keyboardWillChangeFrameNotification,
1520
+ object: nil,
1521
+ queue: .main
1522
+ ) { [weak self] notification in
1523
+ self?.applyKeyboardSafeArea(notification)
1524
+ })
1525
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
1526
+ forName: UIResponder.keyboardWillHideNotification,
1527
+ object: nil,
1528
+ queue: .main
1529
+ ) { [weak self] notification in
1530
+ self?.applyKeyboardSafeArea(notification, hidden: true)
1531
+ })
406
1532
  sendAppState(CraftNativeActions.currentAppState())
407
1533
  deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
408
1534
  guard let self = self, self.navigationController?.topViewController === self else { return }
@@ -412,6 +1538,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
412
1538
  }
413
1539
  }
414
1540
 
1541
+ private func applyKeyboardSafeArea(_ notification: Notification, hidden: Bool = false) {
1542
+ let inset: CGFloat
1543
+ if hidden {
1544
+ inset = 0
1545
+ } else if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
1546
+ let keyboardFrame = view.convert(frame.cgRectValue, from: nil)
1547
+ inset = max(0, view.bounds.intersection(keyboardFrame).height - view.safeAreaInsets.bottom)
1548
+ } else {
1549
+ return
1550
+ }
1551
+ guard abs(inset - keyboardSafeAreaInset) > 0.5 else { return }
1552
+ keyboardSafeAreaInset = inset
1553
+ let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
1554
+ let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
1555
+ UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
1556
+ self.additionalSafeAreaInsets.bottom = inset
1557
+ self.view.layoutIfNeeded()
1558
+ }
1559
+ }
1560
+
415
1561
  private func sendAppState(_ state: String) {
416
1562
  guard navigationController?.topViewController === self else { return }
417
1563
  send(type: "APP_STATE", payload: ["state": state])
@@ -426,6 +1572,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
426
1572
  rootStack.addArrangedSubview(label)
427
1573
  rootStack.setLayoutStyle(["alignSelf": "stretch"], for: label)
428
1574
  NSLog("[craft native] %@", message)
1575
+ hybridFailed(message)
1576
+ }
1577
+
1578
+ /// Only before the first frame: later exceptions belong to a screen that
1579
+ /// is up, and are logged.
1580
+ private func hybridFailed(_ message: String) {
1581
+ guard !hybridRendered else { return }
1582
+ hybridEvents?.nativeScreen(self, didFail: message)
429
1583
  }
430
1584
 
431
1585
  private func receive(_ raw: String) {
@@ -469,11 +1623,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
469
1623
  let navigation = navigationController else { return }
470
1624
  let params = payload["params"] as? [String: Any] ?? [:]
471
1625
  let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
1626
+ next.hybridEvents = hybridEvents
472
1627
  if type == "NAVIGATE" {
473
1628
  navigation.pushViewController(next, animated: true)
474
1629
  } else {
475
1630
  navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
476
1631
  }
1632
+ case "NAVIGATION_SET_OPTIONS":
1633
+ applyNavigationOptions(payload)
1634
+ case "NAVIGATE_OPEN":
1635
+ // A path rather than a screen: the hybrid shell opens it, natively
1636
+ // or in the web view.
1637
+ guard let path = payload["path"] as? String, !path.isEmpty else { return }
1638
+ hybridEvents?.nativeScreen(self, open: path)
477
1639
  case "NAVIGATE_BACK":
478
1640
  if navigationController?.topViewController === self {
479
1641
  navigationController?.popViewController(animated: true)
@@ -536,10 +1698,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
536
1698
  }
537
1699
  }
538
1700
 
1701
+ /// Runs script in the screen's JavaScript context. Internal for
1702
+ /// simulator-hosted XCTest of the host APIs.
1703
+ @discardableResult
1704
+ func evaluateScript(_ script: String) -> JSValue? {
1705
+ jsContext.evaluateScript(script)
1706
+ }
1707
+
539
1708
  // Internal so simulator-hosted XCTest can assert actual UIView identity.
540
1709
  func render(_ document: [String: Any]) {
541
1710
  mutationDocument.replace(with: document)
542
1711
  renderCommitted(document)
1712
+ if !hybridRendered {
1713
+ hybridRendered = true
1714
+ hybridEvents?.nativeScreenDidRender(self)
1715
+ }
543
1716
  }
544
1717
 
545
1718
  @discardableResult
@@ -557,7 +1730,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
557
1730
  let target = flatListOwners[id] ?? id
558
1731
  guard applied.insert(target).inserted else { continue }
559
1732
  guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
560
- _ = reconcile(document, identity: previous.identity, path: target, previous: previous)
1733
+ let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
1734
+ // A style patch is applied in place, layout included: the parent
1735
+ // reads the node's new layout style on its next pass.
1736
+ CraftNativeFlexLayout.invalidateAncestors(of: next.view)
561
1737
  }
562
1738
  restoreFocus(focused)
563
1739
  return true
@@ -578,10 +1754,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
578
1754
  let next = reconcile(document, identity: "root", path: "root", previous: previous)
579
1755
  if previous?.view !== next.view {
580
1756
  if let old = previous { detach(old, from: rootStack) }
581
- rootStack.addArrangedSubview(next.view)
582
1757
  }
1758
+ // A failed or interrupted reconciliation can leave an older root
1759
+ // attached even though `renderedRoot` points at the latest tree. Keep
1760
+ // the host single-rooted so accessibility queries and event routing
1761
+ // never see stale copies of the native screen.
1762
+ for child in rootStack.arrangedSubviews where child !== next.view {
1763
+ rootStack.removeArrangedSubview(child)
1764
+ child.removeFromSuperview()
1765
+ }
1766
+ if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }
583
1767
  rootStack.setLayoutStyle(next.style, for: next.view)
1768
+ rootStack.invalidateMeasurements()
584
1769
  renderedRoot = next
1770
+ trackContentScrollView()
585
1771
  restoreFocus(focused)
586
1772
  }
587
1773
 
@@ -610,72 +1796,242 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
610
1796
  let events = node["events"] as? [String: String] ?? [:]
611
1797
  let children = node["children"] as? [Any] ?? []
612
1798
  let current: RenderedNode
613
- if let previous = previous, previous.identity == identity, previous.type == type {
1799
+ let wantsMultilineTextView = type == "TextInput" && props["multiline"] as? Bool == true
1800
+ let canReuseInput = type != "TextInput" || ((previous?.view is UITextView) == wantsMultilineTextView)
1801
+ if let previous = previous, previous.identity == identity, previous.type == type, canReuseInput {
614
1802
  current = previous
615
1803
  } else {
616
- current = RenderedNode(identity: identity, type: type, view: makeView(type))
1804
+ current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
617
1805
  }
618
1806
 
619
1807
  let result = current.view
620
1808
  current.protocolId = node["id"] as? String
621
1809
  current.style = style
622
1810
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
623
- if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" {
1811
+ if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
624
1812
  result.setContentHuggingPriority(.required, for: .vertical)
625
1813
  }
626
1814
  applyViewStyle(style, to: result, node: current)
1815
+ updateLayoutHandler(events["onLayout"], for: result)
627
1816
 
628
1817
  switch type {
629
1818
  case "Text":
630
1819
  let label = result as! UILabel
1820
+ label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
1821
+ label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
1822
+ (label as? CraftNativeLabel)?.insets = paddingInsets(style)
631
1823
  configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
632
- case "Button":
1824
+ case "Button", "Link":
633
1825
  let button = result as! UIButton
634
- button.setTitle(props["title"] as? String ?? children.compactMap { $0 as? String }.joined(), for: .normal)
635
- button.setTitleColor(color(style["color"]) ?? .systemBlue, for: .normal)
636
- button.titleLabel?.font = textFont(
1826
+ let title = props["title"] as? String ?? children.compactMap { $0 as? String }.joined()
1827
+ let transformedTitle = transformedText(title, style: style)
1828
+ let titleColor = color(props["color"]) ?? color(style["color"]) ?? .systemBlue
1829
+ let font = textFont(
637
1830
  style,
638
- default: button.titleLabel?.font ?? .systemFont(ofSize: UIFont.buttonFontSize)
1831
+ default: .systemFont(ofSize: 16)
639
1832
  )
640
- updateHandler(events["onPress"] ?? events["onClick"], for: button)
1833
+ // The plain title too, so `title(for:)` and accessibility read it.
1834
+ if button.title(for: .normal) != transformedTitle { button.setTitle(transformedTitle, for: .normal) }
1835
+ button.setAttributedTitle(NSAttributedString(
1836
+ string: transformedTitle,
1837
+ attributes: buttonTitleAttributes(style, color: titleColor, font: font, forceUnderline: type == "Link")
1838
+ ), for: .normal)
1839
+ button.contentHorizontalAlignment = buttonAlignment(style["textAlign"])
1840
+ button.isEnabled = props["disabled"] as? Bool != true
1841
+ updateHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: button)
641
1842
  case "TextInput":
1843
+ if let textView = result as? UITextView {
1844
+ inputIdentities[ObjectIdentifier(textView)] = current.identity
1845
+ configureTextView(
1846
+ textView,
1847
+ props: props,
1848
+ style: style,
1849
+ events: events,
1850
+ defaultValue: previous?.view === textView
1851
+ ? nil
1852
+ : inputDrafts[current.identity] ?? props["defaultValue"] as? String
1853
+ )
1854
+ break
1855
+ }
642
1856
  let field = result as! UITextField
643
1857
  field.placeholder = props["placeholder"] as? String
1858
+ if let placeholder = field.placeholder,
1859
+ let placeholderColor = color(props["placeholderTextColor"]) {
1860
+ field.attributedPlaceholder = NSAttributedString(
1861
+ string: placeholder,
1862
+ attributes: [.foregroundColor: placeholderColor]
1863
+ )
1864
+ } else {
1865
+ field.attributedPlaceholder = nil
1866
+ }
1867
+ field.tintColor = color(props["selectionColor"])
1868
+ let fieldId = ObjectIdentifier(field)
1869
+ inputIdentities[fieldId] = current.identity
1870
+ if let maxLength = (props["maxLength"] as? NSNumber)?.intValue, maxLength > 0 {
1871
+ textMaxLengths[fieldId] = maxLength
1872
+ } else {
1873
+ textMaxLengths.removeValue(forKey: fieldId)
1874
+ }
1875
+ field.delegate = self
644
1876
  field.textColor = color(style["color"]) ?? .label
645
- field.font = textFont(style, default: field.font ?? .systemFont(ofSize: UIFont.systemFontSize))
1877
+ field.font = textFont(style, default: .systemFont(ofSize: 16))
646
1878
  field.textAlignment = textAlignment(style["textAlign"])
1879
+ field.defaultTextAttributes = inputTextAttributes(style, font: field.font ?? .systemFont(ofSize: 16), alignment: field.textAlignment)
647
1880
  let desiredKeyboardType = keyboardType(props["keyboardType"])
648
1881
  if field.keyboardType != desiredKeyboardType { field.keyboardType = desiredKeyboardType }
649
1882
  let desiredReturnKeyType = returnKeyType(props["returnKeyType"])
650
1883
  if field.returnKeyType != desiredReturnKeyType { field.returnKeyType = desiredReturnKeyType }
651
1884
  let desiredAutocorrection: UITextAutocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
652
1885
  if field.autocorrectionType != desiredAutocorrection { field.autocorrectionType = desiredAutocorrection }
653
- let desiredCapitalization = capitalizationType(props["autoCapitalize"])
654
- if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
1886
+ // Secure entry first: turning it on resets the capitalization.
655
1887
  let desiredSecureEntry = props["secureTextEntry"] as? Bool == true
656
1888
  if field.isSecureTextEntry != desiredSecureEntry { field.isSecureTextEntry = desiredSecureEntry }
1889
+ let desiredCapitalization = capitalizationType(props["autoCapitalize"])
1890
+ if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
657
1891
  let desiredEnabled = props["editable"] as? Bool != false
658
1892
  if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
659
- updateField(field, value: props["value"] as? String)
660
- updateHandler(events["onChange"] ?? events["onChangeText"], for: field)
1893
+ if let value = props["value"] as? String {
1894
+ updateField(field, value: value)
1895
+ } else if previous?.view !== field {
1896
+ updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
1897
+ }
1898
+ updateHandler(nonEmptyHandler(events["onChange"]) ?? nonEmptyHandler(events["onChangeText"]), for: field)
661
1899
  updateAuxiliaryHandler(events["onFocus"], in: &focusHandlers, for: field)
662
- updateAuxiliaryHandler(events["onBlur"] ?? events["onEndEditing"], in: &blurHandlers, for: field)
1900
+ updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: field)
1901
+ updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
663
1902
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
664
1903
  if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
665
1904
  DispatchQueue.main.async { _ = field.becomeFirstResponder() }
666
1905
  }
1906
+ case "Picker":
1907
+ let picker = result as! UIPickerView
1908
+ let options = children.compactMap { child -> (value: String, label: String)? in
1909
+ guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
1910
+ let optionProps = node["props"] as? [String: Any] ?? [:]
1911
+ let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
1912
+ guard !label.isEmpty else { return nil }
1913
+ return (value: optionProps["value"] as? String ?? label, label: label)
1914
+ }
1915
+ let pickerID = ObjectIdentifier(picker)
1916
+ pickerOptions[pickerID] = options
1917
+ picker.reloadAllComponents()
1918
+ if let selected = (props["selectedValue"] as? String) ?? (props["value"] as? String),
1919
+ let row = options.firstIndex(where: { $0.value == selected }) {
1920
+ picker.selectRow(row, inComponent: 0, animated: false)
1921
+ } else if !options.isEmpty {
1922
+ picker.selectRow(0, inComponent: 0, animated: false)
1923
+ }
1924
+ picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
1925
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
1926
+ case "Modal":
1927
+ let modal = result as! CraftNativeModalView
1928
+ modal.transparent = props["transparent"] as? Bool == true
1929
+ let visible = props["visible"] as? Bool == true
1930
+ let modalID = ObjectIdentifier(modal)
1931
+ let wasVisible = modalVisibility[modalID]
1932
+ modalVisibility[modalID] = visible
1933
+ modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
1934
+ modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
1935
+ { [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
1936
+ }
1937
+ if wasVisible != visible, visible || wasVisible == true {
1938
+ let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
1939
+ if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
1940
+ send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
1941
+ }
1942
+ }
1943
+ configureStack(modal.contentStack, style: style)
1944
+ reconcileChildren(children, in: modal.contentStack, parent: current, path: path, style: style)
1945
+ case "TouchableOpacity", "TouchableHighlight", "Pressable":
1946
+ let pressable = result as! CraftNativeFlowView
1947
+ configureStack(pressable, style: style)
1948
+ reconcileChildren(children, in: pressable, parent: current, path: path, style: style)
1949
+ pressable.pressActiveOpacity = props["disabled"] as? Bool == true
1950
+ ? nil : number(props["activeOpacity"]) ?? 0.5
1951
+ pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
1952
+ case "Switch":
1953
+ let toggle = result as! UISwitch
1954
+ toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
1955
+ toggle.isEnabled = props["disabled"] as? Bool != true
1956
+ if let track = props["trackColor"] as? [String: Any] {
1957
+ toggle.onTintColor = color(track["true"])
1958
+ toggle.tintColor = color(track["false"])
1959
+ } else {
1960
+ toggle.onTintColor = nil
1961
+ toggle.tintColor = nil
1962
+ }
1963
+ toggle.thumbTintColor = color(props["thumbColor"])
1964
+ if let background = color(props["ios_backgroundColor"]) { toggle.tintColor = background }
1965
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)
1966
+ case "Slider":
1967
+ let slider = result as! UISlider
1968
+ slider.minimumValue = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
1969
+ slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, (props["maximumValue"] as? NSNumber)?.floatValue ?? 1)
1970
+ let value = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
1971
+ if let step = (props["step"] as? NSNumber)?.floatValue, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
1972
+ else { sliderSteps.removeValue(forKey: ObjectIdentifier(slider)) }
1973
+ slider.value = snappedSliderValue(value, for: slider)
1974
+ slider.minimumTrackTintColor = color(props["minimumTrackTintColor"])
1975
+ slider.maximumTrackTintColor = color(props["maximumTrackTintColor"])
1976
+ slider.thumbTintColor = color(props["thumbTintColor"])
1977
+ slider.isEnabled = props["disabled"] as? Bool != true
1978
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: slider)
1979
+ updateAuxiliaryHandler(events["onSlidingComplete"], in: &sliderCompleteHandlers, for: slider)
1980
+ case "ActivityIndicator":
1981
+ let indicator = result as! UIActivityIndicatorView
1982
+ let requestedSize = props["size"]
1983
+ indicator.style = (requestedSize as? String) == "large" ? .large : .medium
1984
+ let scale: CGFloat
1985
+ if let numericSize = requestedSize as? NSNumber {
1986
+ scale = max(0.5, CGFloat(numericSize.doubleValue / 20.0))
1987
+ } else if (requestedSize as? String) == "small" {
1988
+ scale = 0.75
1989
+ } else {
1990
+ scale = 1
1991
+ }
1992
+ indicator.transform = CGAffineTransform(scaleX: scale, y: scale)
1993
+ indicator.color = color(props["color"]) ?? .tintColor
1994
+ indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
1995
+ if props["animating"] as? Bool == false {
1996
+ indicator.stopAnimating()
1997
+ } else {
1998
+ indicator.startAnimating()
1999
+ }
2000
+ case "Icon":
2001
+ configureIcon(result as! UIImageView, props: props, style: style)
667
2002
  case "Image":
668
2003
  let image = result as! UIImageView
669
- image.contentMode = imageContentMode(style["resizeMode"] ?? props["resizeMode"])
2004
+ let imageID = ObjectIdentifier(image)
2005
+ if let tint = color(style["tintColor"]) {
2006
+ tintedImages.insert(imageID)
2007
+ image.tintColor = tint
2008
+ } else {
2009
+ tintedImages.remove(imageID)
2010
+ image.tintColor = nil
2011
+ }
2012
+ if let current = image.image { image.image = imageForDisplay(current, view: image) }
2013
+ let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
2014
+ image.contentMode = imageContentMode(requestedResizeMode)
670
2015
  image.clipsToBounds = image.contentMode == .scaleAspectFill
2016
+ updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
2017
+ updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
2018
+ updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
2019
+ updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
671
2020
  updateImage(image, source: props["source"])
672
2021
  case "ScrollView":
673
2022
  let scroll = result as! CraftNativeScrollView
674
- let direction = (props["horizontal"] as? Bool) == true || style["flexDirection"] as? String == "row"
2023
+ let flexDirection = style["flexDirection"] as? String
2024
+ let direction = (props["horizontal"] as? Bool) == true
2025
+ || flexDirection == "row"
2026
+ || flexDirection == "row-reverse"
675
2027
  ? NSLayoutConstraint.Axis.horizontal : .vertical
676
2028
  scroll.setAxis(direction)
677
2029
  scroll.delegate = self
678
2030
  scroll.isScrollEnabled = props["scrollEnabled"] as? Bool != false
2031
+ scroll.bounces = props["bounces"] as? Bool ?? true
2032
+ scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
2033
+ scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
2034
+ scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
679
2035
  scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
680
2036
  scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
681
2037
  scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
@@ -683,8 +2039,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
683
2039
  updateAuxiliaryHandler(events["onScroll"], in: &scrollHandlers, for: scroll)
684
2040
  updateAuxiliaryHandler(events["onScrollBeginDrag"], in: &scrollBeginHandlers, for: scroll)
685
2041
  updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
686
- configureStack(scroll.contentStack, style: style)
687
- reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: style)
2042
+ updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
2043
+ updateAuxiliaryHandler(events["onMomentumScrollEnd"], in: &scrollMomentumEndHandlers, for: scroll)
2044
+ scroll.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
2045
+ { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
2046
+ }, refreshing: props["refreshing"] as? Bool == true)
2047
+ var contentStyle = style
2048
+ if let override = props["contentContainerStyle"] as? [String: Any] {
2049
+ contentStyle.merge(override) { _, next in next }
2050
+ }
2051
+ configureStack(scroll.contentStack, style: contentStyle)
2052
+ reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
688
2053
  case "FlatList":
689
2054
  let list = result as! CraftNativeFlatList
690
2055
  reconcileFlatList(
@@ -700,24 +2065,37 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
700
2065
  configureStack(stack, style: style)
701
2066
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
702
2067
  }
703
- if type != "Button" && type != "TextInput" {
704
- updatePressHandler(events["onPress"] ?? events["onClick"], for: result)
2068
+ updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
2069
+ if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
2070
+ let disabled = props["disabled"] as? Bool == true
2071
+ updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
2072
+ updatePressFeedback(
2073
+ disabled ? nil : nonEmptyHandler(events["onPressIn"]),
2074
+ disabled ? nil : nonEmptyHandler(events["onPressOut"]),
2075
+ for: result
2076
+ )
2077
+ if disabled { result.isUserInteractionEnabled = false }
705
2078
  }
706
2079
  applyAccessibility(props, type: type, to: result)
707
2080
  return current
708
2081
  }
709
2082
 
710
- private func makeView(_ type: String) -> UIView {
2083
+ private func makeView(_ type: String, props: [String: Any]) -> UIView {
711
2084
  switch type {
712
2085
  case "Text":
713
- let label = UILabel()
2086
+ let label = CraftNativeLabel()
714
2087
  label.numberOfLines = 0
715
2088
  return label
716
- case "Button":
2089
+ case "Button", "Link":
717
2090
  let button = UIButton(type: .system)
718
2091
  button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
719
2092
  return button
720
2093
  case "TextInput":
2094
+ if props["multiline"] as? Bool == true {
2095
+ let textView = CraftNativeTextView()
2096
+ textView.delegate = self
2097
+ return textView
2098
+ }
721
2099
  let field = UITextField()
722
2100
  field.borderStyle = .roundedRect
723
2101
  field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
@@ -725,19 +2103,45 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
725
2103
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
726
2104
  field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
727
2105
  return field
2106
+ case "Picker":
2107
+ let picker = UIPickerView()
2108
+ picker.dataSource = self
2109
+ picker.delegate = self
2110
+ return picker
2111
+ case "Modal":
2112
+ return CraftNativeModalView()
2113
+ case "Switch":
2114
+ let toggle = UISwitch()
2115
+ toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
2116
+ return toggle
2117
+ case "Slider":
2118
+ let slider = UISlider()
2119
+ slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
2120
+ slider.addTarget(self, action: #selector(sliderFinished(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel])
2121
+ return slider
2122
+ case "ActivityIndicator":
2123
+ return UIActivityIndicatorView(style: .medium)
728
2124
  case "Image":
729
2125
  return UIImageView()
2126
+ case "Icon":
2127
+ let icon = CraftNativeIconView()
2128
+ icon.contentMode = .scaleAspectFit
2129
+ return icon
730
2130
  case "ScrollView":
731
2131
  return CraftNativeScrollView()
732
2132
  case "FlatList":
733
2133
  return CraftNativeFlatList()
2134
+ case "View", "SafeAreaView":
2135
+ return CraftNativeFlowView()
2136
+ case "TouchableOpacity", "TouchableHighlight", "Pressable":
2137
+ return CraftNativeFlowView()
734
2138
  default:
735
2139
  return CraftNativeFlowView()
736
2140
  }
737
2141
  }
738
2142
 
739
2143
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
740
- // The current stx-native compiler emits key in props; accept the IR
2144
+ // The current stx native compiler emits key in props; accept the IR
741
2145
  // field too so keyed children keep working when the compiler adopts it.
742
2146
  [node["id"], node["key"], props["key"], props["testID"]]
743
2147
  .compactMap { $0 as? String }
@@ -780,7 +2184,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
780
2184
  for child in parent.children where !next.contains(where: { $0 === child }) {
781
2185
  detach(child, from: stack)
782
2186
  }
783
- for (index, child) in next.enumerated() {
2187
+ for modal in stack.arrangedSubviews where modal is CraftNativeModalView {
2188
+ stack.removeArrangedSubview(modal)
2189
+ }
2190
+ let flowChildren = next.filter { $0.type != "Modal" }
2191
+ for (index, child) in flowChildren.enumerated() {
784
2192
  if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view {
785
2193
  stack.setLayoutStyle(child.style, for: child.view)
786
2194
  continue
@@ -789,6 +2197,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
789
2197
  stack.insertArrangedSubview(child.view, at: min(index, stack.arrangedSubviews.count))
790
2198
  stack.setLayoutStyle(child.style, for: child.view)
791
2199
  }
2200
+ for child in next where child.type == "Modal" {
2201
+ guard let modal = child.view as? CraftNativeModalView else { continue }
2202
+ if modal.superview !== stack {
2203
+ modal.removeFromSuperview()
2204
+ stack.addSubview(modal)
2205
+ }
2206
+ modal.frame = stack.bounds
2207
+ modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
2208
+ stack.bringSubviewToFront(modal)
2209
+ }
792
2210
  parent.children = next
793
2211
  addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
794
2212
  }
@@ -805,6 +2223,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
805
2223
  let owner = parent.protocolId ?? path
806
2224
  flatListOwners = flatListOwners.filter { $0.value != owner }
807
2225
  for child in children { registerFlatListOwnership(child, owner: owner) }
2226
+ list.bounces = props["bounces"] as? Bool ?? true
2227
+ list.isScrollEnabled = props["scrollEnabled"] as? Bool != false
2228
+ list.onFittingHeightChanged = { [weak list] in
2229
+ guard let list else { return }
2230
+ CraftNativeFlexLayout.invalidateAncestors(of: list)
2231
+ }
2232
+ list.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
2233
+ list.setContentContainerStyle(props["contentContainerStyle"] as? [String: Any])
2234
+ list.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
2235
+ { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
2236
+ }, refreshing: props["refreshing"] as? Bool == true)
2237
+ list.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
2238
+ list.onScrollEvent = nonEmptyHandler(events["onScroll"]).map { handler in
2239
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
2240
+ }
2241
+ list.onScrollBeginDrag = nonEmptyHandler(events["onScrollBeginDrag"]).map { handler in
2242
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
2243
+ }
2244
+ list.onScrollEndDrag = nonEmptyHandler(events["onScrollEndDrag"]).map { handler in
2245
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
2246
+ }
2247
+ list.onMomentumScrollBegin = nonEmptyHandler(events["onMomentumScrollBegin"]).map { handler in
2248
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
2249
+ }
2250
+ list.onMomentumScrollEnd = nonEmptyHandler(events["onMomentumScrollEnd"]).map { handler in
2251
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
2252
+ }
808
2253
 
809
2254
  list.apply(
810
2255
  nodes: children,
@@ -823,15 +2268,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
823
2268
  path: "\(path).\(identity)",
824
2269
  previous: previous
825
2270
  )
2271
+ if let previous, previous.view !== next.view {
2272
+ self.forgetHandlers(previous)
2273
+ }
826
2274
  self.flatListRows[listKey, default: [:]][identity] = next
827
2275
  return next.view
828
2276
  },
829
2277
  recycleItem: { [weak self] identity in
830
2278
  guard let self = self,
831
2279
  let row = self.flatListRows[listKey]?.removeValue(forKey: identity) else { return }
832
- self.forgetHandlers(row)
2280
+ self.forgetHandlers(row, preservingInputDrafts: true)
833
2281
  },
834
- endReached: events["onEndReached"].map { [weak self] handler in
2282
+ endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
835
2283
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
836
2284
  }
837
2285
  )
@@ -850,40 +2298,119 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
850
2298
 
851
2299
  private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
852
2300
  forgetHandlers(node)
2301
+ if node.type == "Modal" {
2302
+ node.view.removeFromSuperview()
2303
+ return
2304
+ }
853
2305
  stack.removeLayoutStyle(for: node.view)
854
2306
  stack.removeArrangedSubview(node.view)
855
2307
  node.view.removeFromSuperview()
856
2308
  }
857
2309
 
858
- private func forgetHandlers(_ node: RenderedNode) {
2310
+ private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
2311
+ (node.view as? CraftNativeScrollView)?.setRefreshHandler(nil, refreshing: false)
859
2312
  if let list = node.view as? CraftNativeFlatList {
860
2313
  let listKey = ObjectIdentifier(list)
2314
+ list.setRefreshHandler(nil, refreshing: false)
861
2315
  list.discardAll()
862
2316
  flatListRows.removeValue(forKey: listKey)
863
2317
  if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
864
2318
  }
865
2319
  let id = ObjectIdentifier(node.view)
866
2320
  handlers.removeValue(forKey: id)
2321
+ pressInHandlers.removeValue(forKey: id)
2322
+ pressOutHandlers.removeValue(forKey: id)
2323
+ layoutHandlers.removeValue(forKey: id)
2324
+ lastLayoutFrames.removeValue(forKey: id)
867
2325
  focusHandlers.removeValue(forKey: id)
868
2326
  blurHandlers.removeValue(forKey: id)
2327
+ endEditingHandlers.removeValue(forKey: id)
869
2328
  submitHandlers.removeValue(forKey: id)
2329
+ inputIdentities.removeValue(forKey: id)
2330
+ if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
2331
+ longPressHandlers.removeValue(forKey: id)
2332
+ textMaxLengths.removeValue(forKey: id)
870
2333
  scrollHandlers.removeValue(forKey: id)
871
2334
  scrollBeginHandlers.removeValue(forKey: id)
872
2335
  scrollEndHandlers.removeValue(forKey: id)
2336
+ scrollMomentumBeginHandlers.removeValue(forKey: id)
2337
+ scrollMomentumEndHandlers.removeValue(forKey: id)
2338
+ sliderCompleteHandlers.removeValue(forKey: id)
2339
+ sliderSteps.removeValue(forKey: id)
2340
+ pickerOptions.removeValue(forKey: id)
2341
+ modalVisibility.removeValue(forKey: id)
873
2342
  if let recognizer = tapRecognizers.removeValue(forKey: id) {
874
2343
  node.view.removeGestureRecognizer(recognizer)
875
2344
  }
2345
+ if let recognizer = longPressRecognizers.removeValue(forKey: id) {
2346
+ node.view.removeGestureRecognizer(recognizer)
2347
+ }
2348
+ if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) {
2349
+ node.view.removeGestureRecognizer(recognizer)
2350
+ }
876
2351
  imageSources.removeValue(forKey: id)
877
2352
  imageTasks.removeValue(forKey: id)?.cancel()
878
- for child in node.children { forgetHandlers(child) }
2353
+ imageErrors.removeValue(forKey: id)
2354
+ tintedImages.remove(id)
2355
+ imageLoadStartHandlers.removeValue(forKey: id)
2356
+ imageLoadHandlers.removeValue(forKey: id)
2357
+ imageLoadEndHandlers.removeValue(forKey: id)
2358
+ imageErrorHandlers.removeValue(forKey: id)
2359
+ for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
879
2360
  }
880
2361
 
881
2362
  private func updateHandler(_ handler: String?, for view: UIView) {
882
2363
  let id = ObjectIdentifier(view)
883
- if let handler = handler { handlers[id] = handler }
2364
+ if let handler, !handler.isEmpty { handlers[id] = handler }
884
2365
  else { handlers.removeValue(forKey: id) }
885
2366
  }
886
2367
 
2368
+ private func snappedSliderValue(_ value: Float, for slider: UISlider) -> Float {
2369
+ let bounded = min(slider.maximumValue, max(slider.minimumValue, value))
2370
+ guard let step = sliderSteps[ObjectIdentifier(slider)], step > 0 else { return bounded }
2371
+ let steps = ((bounded - slider.minimumValue) / step).rounded()
2372
+ return min(slider.maximumValue, max(slider.minimumValue, slider.minimumValue + steps * step))
2373
+ }
2374
+
2375
+ private func updateLayoutHandler(_ handler: String?, for view: UIView) {
2376
+ let id = ObjectIdentifier(view)
2377
+ guard let handler, !handler.isEmpty else {
2378
+ layoutHandlers.removeValue(forKey: id)
2379
+ lastLayoutFrames.removeValue(forKey: id)
2380
+ return
2381
+ }
2382
+ if layoutHandlers[id] != handler { lastLayoutFrames.removeValue(forKey: id) }
2383
+ layoutHandlers[id] = handler
2384
+ }
2385
+
2386
+ private func emitLayoutEvents() {
2387
+ guard let renderedRoot else { return }
2388
+ emitLayoutEvents(for: renderedRoot)
2389
+ }
2390
+
2391
+ private func emitLayoutEvents(for node: RenderedNode) {
2392
+ let id = ObjectIdentifier(node.view)
2393
+ if let handler = layoutHandlers[id], node.view.window != nil {
2394
+ let frame = node.view.frame
2395
+ if lastLayoutFrames[id] != frame {
2396
+ lastLayoutFrames[id] = frame
2397
+ send(type: "EVENT", payload: [
2398
+ "handlerName": handler,
2399
+ "nativeEvent": ["layout": [
2400
+ "x": frame.minX,
2401
+ "y": frame.minY,
2402
+ "width": frame.width,
2403
+ "height": frame.height,
2404
+ ]],
2405
+ ])
2406
+ }
2407
+ }
2408
+ for child in node.children { emitLayoutEvents(for: child) }
2409
+ if let list = node.view as? CraftNativeFlatList {
2410
+ flatListRows[ObjectIdentifier(list)]?.values.forEach { emitLayoutEvents(for: $0) }
2411
+ }
2412
+ }
2413
+
887
2414
  private func updateAuxiliaryHandler(
888
2415
  _ handler: String?,
889
2416
  in handlers: inout [ObjectIdentifier: String],
@@ -894,8 +2421,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
894
2421
  else { handlers.removeValue(forKey: id) }
895
2422
  }
896
2423
 
2424
+ private func nonEmptyHandler(_ handler: String?) -> String? {
2425
+ guard let handler, !handler.isEmpty else { return nil }
2426
+ return handler
2427
+ }
2428
+
897
2429
  private func updatePressHandler(_ handler: String?, for view: UIView) {
898
2430
  let id = ObjectIdentifier(view)
2431
+ let handler = handler?.isEmpty == false ? handler : nil
899
2432
  updateHandler(handler, for: view)
900
2433
  if handler != nil, tapRecognizers[id] == nil {
901
2434
  let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
@@ -904,7 +2437,48 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
904
2437
  } else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
905
2438
  view.removeGestureRecognizer(recognizer)
906
2439
  }
907
- if view is UIImageView { view.isUserInteractionEnabled = handler != nil }
2440
+ // A container keeps receiving touches without a handler of its own,
2441
+ // or every control inside it (and the screen root) would be dead.
2442
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
2443
+ }
2444
+
2445
+ private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
2446
+ let id = ObjectIdentifier(view)
2447
+ if let pressIn, !pressIn.isEmpty { pressInHandlers[id] = pressIn } else { pressInHandlers.removeValue(forKey: id) }
2448
+ if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
2449
+ guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
2450
+ if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
2451
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
2452
+ return
2453
+ }
2454
+ if pressFeedbackRecognizers[id] == nil {
2455
+ let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewPressFeedback(_:)))
2456
+ recognizer.minimumPressDuration = 0
2457
+ recognizer.allowableMovement = 10
2458
+ recognizer.cancelsTouchesInView = false
2459
+ pressFeedbackRecognizers[id] = recognizer
2460
+ view.addGestureRecognizer(recognizer)
2461
+ }
2462
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
2463
+ }
2464
+
2465
+ private func updateLongPressHandler(_ handler: String?, for view: UIView) {
2466
+ let id = ObjectIdentifier(view)
2467
+ if let handler, !handler.isEmpty {
2468
+ longPressHandlers[id] = handler
2469
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
2470
+ if longPressRecognizers[id] == nil {
2471
+ let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewLongPressed(_:)))
2472
+ longPressRecognizers[id] = recognizer
2473
+ view.addGestureRecognizer(recognizer)
2474
+ }
2475
+ } else {
2476
+ longPressHandlers.removeValue(forKey: id)
2477
+ if let recognizer = longPressRecognizers.removeValue(forKey: id) {
2478
+ view.removeGestureRecognizer(recognizer)
2479
+ }
2480
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || view is CraftNativeFlowView }
2481
+ }
908
2482
  }
909
2483
 
910
2484
  private func updateField(_ field: UITextField, value: String?) {
@@ -920,24 +2494,126 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
920
2494
  }
921
2495
  }
922
2496
 
2497
+ private func updateTextView(_ textView: UITextView, value: String?) {
2498
+ guard let value = value, textView.text != value, textView.markedTextRange == nil else { return }
2499
+ let selection = textView.selectedTextRange
2500
+ let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
2501
+ let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
2502
+ textView.text = value
2503
+ if let start = start, let end = end,
2504
+ let from = textView.position(from: textView.beginningOfDocument, offset: min(start, value.utf16.count)),
2505
+ let to = textView.position(from: textView.beginningOfDocument, offset: min(end, value.utf16.count)) {
2506
+ textView.selectedTextRange = textView.textRange(from: from, to: to)
2507
+ }
2508
+ }
2509
+
2510
+ private func configureTextView(
2511
+ _ textView: UITextView,
2512
+ props: [String: Any],
2513
+ style: [String: Any],
2514
+ events: [String: String],
2515
+ defaultValue: String?
2516
+ ) {
2517
+ let id = ObjectIdentifier(textView)
2518
+ textView.delegate = self
2519
+ textView.textColor = color(style["color"]) ?? .label
2520
+ textView.font = textFont(style, default: .systemFont(ofSize: 16))
2521
+ textView.textAlignment = textAlignment(style["textAlign"])
2522
+ textView.tintColor = color(props["selectionColor"])
2523
+ textView.keyboardType = keyboardType(props["keyboardType"])
2524
+ textView.returnKeyType = returnKeyType(props["returnKeyType"])
2525
+ textView.autocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
2526
+ textView.autocapitalizationType = capitalizationType(props["autoCapitalize"])
2527
+ textView.isSecureTextEntry = props["secureTextEntry"] as? Bool == true
2528
+ textView.isEditable = props["editable"] as? Bool != false
2529
+ textView.isScrollEnabled = false
2530
+ if let numberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue, numberOfLines > 0 {
2531
+ textView.textContainer.maximumNumberOfLines = numberOfLines
2532
+ } else {
2533
+ textView.textContainer.maximumNumberOfLines = 0
2534
+ }
2535
+ if let maxLength = (props["maxLength"] as? NSNumber)?.intValue, maxLength > 0 {
2536
+ textMaxLengths[id] = maxLength
2537
+ } else {
2538
+ textMaxLengths.removeValue(forKey: id)
2539
+ }
2540
+ if let craftTextView = textView as? CraftNativeTextView {
2541
+ craftTextView.placeholder = props["placeholder"] as? String
2542
+ craftTextView.placeholderColor = color(props["placeholderTextColor"])
2543
+ craftTextView.placeholderFont = textView.font
2544
+ }
2545
+ updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
2546
+ let selectedRange = textView.selectedRange
2547
+ let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
2548
+ textView.typingAttributes = attributes
2549
+ if !textView.text.isEmpty {
2550
+ textView.attributedText = NSAttributedString(string: textView.text, attributes: attributes)
2551
+ textView.selectedRange = selectedRange
2552
+ }
2553
+ (textView as? CraftNativeTextView)?.refreshPlaceholder()
2554
+ updateHandler(nonEmptyHandler(events["onChange"]) ?? nonEmptyHandler(events["onChangeText"]), for: textView)
2555
+ updateAuxiliaryHandler(events["onFocus"], in: &focusHandlers, for: textView)
2556
+ updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: textView)
2557
+ updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
2558
+ updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
2559
+ if props["autoFocus"] as? Bool == true, !textView.isFirstResponder {
2560
+ DispatchQueue.main.async { _ = textView.becomeFirstResponder() }
2561
+ }
2562
+ }
2563
+
2564
+ /// `<Icon symbol="sun.max">`: an SF Symbol, tinted by `color`, sized by
2565
+ /// `fontSize` or else the icon's box, weighted by `fontWeight`. A name
2566
+ /// the system does not have draws `circle`, so a typo shows rather than
2567
+ /// leaving a hole.
2568
+ private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
2569
+ let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
2570
+ let box = [number(style["width"]), number(style["height"])].compactMap { $0 }.min()
2571
+ let fontSize = number(style["fontSize"])
2572
+ // Without a size of its own an icon is a 20 pt square (contract A2).
2573
+ (icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
2574
+ let pointSize = fontSize ?? (box ?? 20) * 0.84
2575
+ let weight: UIImage.SymbolWeight
2576
+ switch style["fontWeight"] as? String {
2577
+ case "100": weight = .ultraLight
2578
+ case "200": weight = .thin
2579
+ case "300": weight = .light
2580
+ case "500": weight = .medium
2581
+ case "600": weight = .semibold
2582
+ case "bold", "700": weight = .bold
2583
+ case "800": weight = .heavy
2584
+ case "900": weight = .black
2585
+ default: weight = .regular
2586
+ }
2587
+ let scale: UIImage.SymbolScale
2588
+ switch props["scale"] as? String {
2589
+ case "small": scale = .small
2590
+ case "large": scale = .large
2591
+ default: scale = .medium
2592
+ }
2593
+ let configuration = UIImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: weight, scale: scale)
2594
+ let image = UIImage(systemName: name, withConfiguration: configuration)
2595
+ ?? UIImage(systemName: "circle", withConfiguration: configuration)
2596
+ if icon.image != image { icon.image = image }
2597
+ icon.preferredSymbolConfiguration = configuration
2598
+ icon.tintColor = color(style["color"]) ?? color(props["color"]) ?? .label
2599
+ icon.contentMode = .scaleAspectFit
2600
+ }
2601
+
923
2602
  private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
924
2603
  let direction = style["flexDirection"] as? String
925
2604
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
926
2605
  stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
927
2606
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
928
2607
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
929
- stack.gap = number(style["gap"]) ?? 0
930
- stack.rowGap = number(style["rowGap"])
931
- stack.columnGap = number(style["columnGap"])
932
- let padding = number(style["padding"]) ?? 0
933
- let horizontal = number(style["paddingHorizontal"]) ?? padding
934
- let vertical = number(style["paddingVertical"]) ?? padding
935
- stack.padding = UIEdgeInsets(
936
- top: number(style["paddingTop"]) ?? vertical,
937
- left: number(style["paddingLeft"]) ?? horizontal,
938
- bottom: number(style["paddingBottom"]) ?? vertical,
939
- right: number(style["paddingRight"]) ?? horizontal
940
- )
2608
+ let gap = number(style["gap"]) ?? 0
2609
+ stack.gap = gap
2610
+ stack.rowGap = number(style["rowGap"]) ?? gap
2611
+ stack.columnGap = number(style["columnGap"]) ?? gap
2612
+ stack.grid = style["display"] as? String == "grid"
2613
+ stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
2614
+ stack.gridAutoRows = number(style["gridAutoRows"])
2615
+ stack.padding = paddingInsets(style)
2616
+ stack.invalidateMeasurements()
941
2617
  }
942
2618
 
943
2619
  private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
@@ -950,25 +2626,51 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
950
2626
  view.backgroundColor = color(style["backgroundColor"]) ?? .clear
951
2627
  view.alpha = number(style["opacity"]) ?? 1
952
2628
  view.isHidden = style["display"] as? String == "none"
953
- view.layer.cornerRadius = number(style["borderRadius"]) ?? 0
2629
+ view.craftRequestedCornerRadius = number(style["borderRadius"])
954
2630
  view.layer.borderWidth = number(style["borderWidth"]) ?? 0
955
2631
  view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
2632
+ let elevation = max(0, number(style["elevation"]) ?? 0)
2633
+ view.layer.shadowColor = UIColor.black.cgColor
2634
+ view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
2635
+ view.layer.shadowRadius = elevation * 0.5
2636
+ view.layer.shadowOffset = CGSize(width: 0, height: elevation * 0.25)
2637
+ // A label paints its background inside its own bounds, so a rounded
2638
+ // chip of text clips to its corners.
956
2639
  view.clipsToBounds = style["overflow"] as? String == "hidden"
2640
+ || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
2641
+ view.layer.masksToBounds = view.clipsToBounds
2642
+ view.craftLayoutStyle = CraftNativeLayoutStyle(style)
2643
+ (view as? CraftNativeFlowView)?.invalidateMeasurements()
2644
+ (view as? CraftNativeScrollView)?.setContentNeedsLayout()
2645
+ }
2646
+
2647
+ private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
2648
+ let padding = number(style["padding"]) ?? 0
2649
+ let horizontal = number(style["paddingHorizontal"]) ?? padding
2650
+ let vertical = number(style["paddingVertical"]) ?? padding
2651
+ return UIEdgeInsets(
2652
+ top: number(style["paddingTop"]) ?? vertical,
2653
+ left: number(style["paddingLeft"]) ?? horizontal,
2654
+ bottom: number(style["paddingBottom"]) ?? vertical,
2655
+ right: number(style["paddingRight"]) ?? horizontal
2656
+ )
957
2657
  }
958
2658
 
959
2659
  private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
960
- let transformed: String
961
- switch style["textTransform"] as? String {
962
- case "uppercase": transformed = text.uppercased()
963
- case "lowercase": transformed = text.lowercased()
964
- case "capitalize": transformed = text.capitalized
965
- default: transformed = text
966
- }
2660
+ let transformed = transformedText(text, style: style)
967
2661
  label.textColor = color(style["color"]) ?? .label
968
- label.font = textFont(style, default: .systemFont(ofSize: UIFont.systemFontSize))
2662
+ label.font = textFont(style, default: .systemFont(ofSize: 16))
969
2663
  label.textAlignment = textAlignment(style["textAlign"])
970
2664
  var attributes: [NSAttributedString.Key: Any] = [:]
971
2665
  if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
2666
+ switch style["textDecorationLine"] as? String {
2667
+ case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
2668
+ case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
2669
+ case "underline line-through":
2670
+ attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
2671
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
2672
+ default: break
2673
+ }
972
2674
  if let lineHeight = number(style["lineHeight"]) {
973
2675
  let paragraph = NSMutableParagraphStyle()
974
2676
  paragraph.minimumLineHeight = lineHeight
@@ -976,6 +2678,43 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
976
2678
  paragraph.alignment = label.textAlignment
977
2679
  attributes[.paragraphStyle] = paragraph
978
2680
  }
2681
+ let alignment = label.textAlignment
2682
+ let lineBreakMode = label.lineBreakMode
2683
+ label.attributedText = attributes.isEmpty ? nil : NSAttributedString(string: transformed, attributes: attributes)
2684
+ if attributes.isEmpty { label.text = transformed }
2685
+ // Attributed text brings its own paragraph style, which resets the
2686
+ // label's alignment and truncation; set them again over the whole text.
2687
+ label.textAlignment = alignment
2688
+ label.lineBreakMode = lineBreakMode
2689
+ }
2690
+
2691
+ private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
2692
+ switch value as? String {
2693
+ case "head": return .byTruncatingHead
2694
+ case "middle": return .byTruncatingMiddle
2695
+ case "tail": return .byTruncatingTail
2696
+ case "clip": return .byClipping
2697
+ default: return .byTruncatingTail
2698
+ }
2699
+ }
2700
+
2701
+ private func buttonTitleAttributes(
2702
+ _ style: [String: Any],
2703
+ color: UIColor,
2704
+ font: UIFont,
2705
+ forceUnderline: Bool
2706
+ ) -> [NSAttributedString.Key: Any] {
2707
+ var attributes: [NSAttributedString.Key: Any] = [
2708
+ .foregroundColor: color,
2709
+ .font: font,
2710
+ ]
2711
+ if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
2712
+ if let lineHeight = number(style["lineHeight"]) {
2713
+ let paragraph = NSMutableParagraphStyle()
2714
+ paragraph.minimumLineHeight = lineHeight
2715
+ paragraph.maximumLineHeight = lineHeight
2716
+ attributes[.paragraphStyle] = paragraph
2717
+ }
979
2718
  switch style["textDecorationLine"] as? String {
980
2719
  case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
981
2720
  case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
@@ -984,8 +2723,48 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
984
2723
  attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
985
2724
  default: break
986
2725
  }
987
- label.attributedText = attributes.isEmpty ? nil : NSAttributedString(string: transformed, attributes: attributes)
988
- if attributes.isEmpty { label.text = transformed }
2726
+ if forceUnderline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue }
2727
+ return attributes
2728
+ }
2729
+
2730
+ private func inputTextAttributes(
2731
+ _ style: [String: Any],
2732
+ font: UIFont,
2733
+ alignment: NSTextAlignment
2734
+ ) -> [NSAttributedString.Key: Any] {
2735
+ var attributes: [NSAttributedString.Key: Any] = [
2736
+ .foregroundColor: color(style["color"]) ?? .label,
2737
+ .font: font,
2738
+ ]
2739
+ if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
2740
+ if let lineHeight = number(style["lineHeight"]) {
2741
+ let paragraph = NSMutableParagraphStyle()
2742
+ paragraph.minimumLineHeight = lineHeight
2743
+ paragraph.maximumLineHeight = lineHeight
2744
+ paragraph.alignment = alignment
2745
+ attributes[.paragraphStyle] = paragraph
2746
+ }
2747
+ switch style["textDecorationLine"] as? String {
2748
+ case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
2749
+ case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
2750
+ case "underline line-through":
2751
+ attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
2752
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
2753
+ default: break
2754
+ }
2755
+ return attributes
2756
+ }
2757
+
2758
+ private func transformedText(_ text: String, style: [String: Any]) -> String {
2759
+ switch style["textTransform"] as? String {
2760
+ case "uppercase": return text.uppercased()
2761
+ case "lowercase": return text.lowercased()
2762
+ case "capitalize": return text.split(separator: " ", omittingEmptySubsequences: false).map { word in
2763
+ guard let first = word.first else { return String(word) }
2764
+ return String(first).uppercased() + String(word.dropFirst())
2765
+ }.joined(separator: " ")
2766
+ default: return text
2767
+ }
989
2768
  }
990
2769
 
991
2770
  private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
@@ -1021,6 +2800,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1021
2800
  }
1022
2801
  }
1023
2802
 
2803
+ private func buttonAlignment(_ value: Any?) -> UIControl.ContentHorizontalAlignment {
2804
+ switch value as? String {
2805
+ case "left": return .left
2806
+ case "right": return .right
2807
+ case "center": return .center
2808
+ default: return .center
2809
+ }
2810
+ }
2811
+
1024
2812
  private func imageContentMode(_ value: Any?) -> UIView.ContentMode {
1025
2813
  switch value as? String {
1026
2814
  case "cover": return .scaleAspectFill
@@ -1030,6 +2818,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1030
2818
  }
1031
2819
  }
1032
2820
 
2821
+ private func keyboardDismissMode(_ value: Any?) -> UIScrollView.KeyboardDismissMode {
2822
+ switch value as? String {
2823
+ case "on-drag": return .onDrag
2824
+ case "interactive": return .interactive
2825
+ default: return .none
2826
+ }
2827
+ }
2828
+
1033
2829
  private func updateImage(_ view: UIImageView, source: Any?) {
1034
2830
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
1035
2831
  let id = ObjectIdentifier(view)
@@ -1042,8 +2838,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1042
2838
  guard imageSources[id] != uri else { return }
1043
2839
  imageTasks.removeValue(forKey: id)?.cancel()
1044
2840
  imageSources[id] = uri
2841
+ imageErrors.removeValue(forKey: id)
1045
2842
  view.image = nil
1046
2843
  view.accessibilityValue = nil
2844
+ emitImageEvent(view, handler: imageLoadStartHandlers[id])
1047
2845
 
1048
2846
  if uri.hasPrefix("data:image/") {
1049
2847
  guard let comma = uri.firstIndex(of: ","),
@@ -1052,7 +2850,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1052
2850
  imageFailure(view, uri: uri, message: "Image data is invalid")
1053
2851
  return
1054
2852
  }
1055
- view.image = image
2853
+ view.image = imageForDisplay(image, view: view)
2854
+ emitImageEvent(view, handler: imageLoadHandlers[id])
2855
+ emitImageEvent(view, handler: imageLoadEndHandlers[id])
1056
2856
  return
1057
2857
  }
1058
2858
  if let url = URL(string: uri), url.scheme == "https" {
@@ -1065,7 +2865,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1065
2865
  self.imageFailure(view, uri: uri, message: "Image download failed")
1066
2866
  return
1067
2867
  }
1068
- view.image = image
2868
+ view.image = self.imageForDisplay(image, view: view)
2869
+ self.emitImageEvent(view, handler: self.imageLoadHandlers[ObjectIdentifier(view)])
2870
+ self.emitImageEvent(view, handler: self.imageLoadEndHandlers[ObjectIdentifier(view)])
1069
2871
  }
1070
2872
  }
1071
2873
  imageTasks[id] = task
@@ -1077,26 +2879,48 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1077
2879
  imageFailure(view, uri: uri, message: "Bundled image was not found")
1078
2880
  return
1079
2881
  }
1080
- view.image = image
2882
+ view.image = imageForDisplay(image, view: view)
2883
+ emitImageEvent(view, handler: imageLoadHandlers[id])
2884
+ emitImageEvent(view, handler: imageLoadEndHandlers[id])
1081
2885
  return
1082
2886
  }
1083
2887
  imageFailure(view, uri: uri, message: "Unsupported image source")
1084
2888
  }
1085
2889
 
1086
2890
  private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
2891
+ imageErrors[ObjectIdentifier(view)] = message
1087
2892
  view.image = nil
1088
2893
  view.accessibilityValue = message
2894
+ let id = ObjectIdentifier(view)
2895
+ emitImageEvent(view, handler: imageErrorHandlers[id], nativeEvent: ["error": ["message": message]])
2896
+ emitImageEvent(view, handler: imageLoadEndHandlers[id])
1089
2897
  NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
1090
2898
  }
1091
2899
 
2900
+ private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
2901
+ image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
2902
+ }
2903
+
2904
+ private func emitImageEvent(
2905
+ _ view: UIImageView,
2906
+ handler: String?,
2907
+ nativeEvent: [String: Any] = [:]
2908
+ ) {
2909
+ guard let handler, !handler.isEmpty else { return }
2910
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
2911
+ }
2912
+
1092
2913
  private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
1093
- view.accessibilityLabel = props["accessibilityLabel"] as? String
2914
+ view.accessibilityLabel = (props["accessibilityLabel"] as? String)
2915
+ ?? (type == "Image" ? props["alt"] as? String : nil)
1094
2916
  view.accessibilityHint = props["accessibilityHint"] as? String
1095
2917
  if let value = props["accessibilityValue"] {
1096
2918
  view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
2919
+ } else if type != "TextInput" {
2920
+ view.accessibilityValue = nil
1097
2921
  }
1098
2922
  let role = props["accessibilityRole"] as? String
1099
- view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Image", "TextInput"].contains(type))
2923
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Picker", "Switch", "Slider"].contains(type))
1100
2924
  var traits: UIAccessibilityTraits = []
1101
2925
  switch role ?? type.lowercased() {
1102
2926
  case "button": traits.insert(.button)
@@ -1107,11 +2931,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1107
2931
  default: break
1108
2932
  }
1109
2933
  if let state = props["accessibilityState"] as? [String: Any] {
1110
- if state["disabled"] as? Bool == true { traits.insert(.notEnabled) }
2934
+ if state["disabled"] as? Bool == true {
2935
+ traits.insert(.notEnabled)
2936
+ if let control = view as? UIControl {
2937
+ control.isEnabled = false
2938
+ } else if handlers[ObjectIdentifier(view)] != nil || longPressHandlers[ObjectIdentifier(view)] != nil {
2939
+ view.isUserInteractionEnabled = false
2940
+ }
2941
+ }
1111
2942
  if state["selected"] as? Bool == true { traits.insert(.selected) }
1112
2943
  if state["checked"] as? Bool == true { traits.insert(.selected) }
1113
2944
  }
1114
2945
  if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
2946
+ if type == "Image", let message = imageErrors[ObjectIdentifier(view)] {
2947
+ let current = view.accessibilityValue
2948
+ if current?.contains(message) != true {
2949
+ view.accessibilityValue = [current, message].compactMap { $0 }.joined(separator: ", ")
2950
+ }
2951
+ }
1115
2952
  view.accessibilityTraits = traits
1116
2953
  }
1117
2954
 
@@ -1119,7 +2956,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1119
2956
  if let value = value {
1120
2957
  if let constraint = constraint { constraint.constant = value }
1121
2958
  else {
2959
+ // Below required: the flex layout owns frames, and a width
2960
+ // the layout stretches or shrinks must not log a conflict.
1122
2961
  constraint = anchor.constraint(equalToConstant: value)
2962
+ constraint?.priority = UILayoutPriority(999)
1123
2963
  constraint?.isActive = true
1124
2964
  }
1125
2965
  } else {
@@ -1138,10 +2978,55 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1138
2978
  }
1139
2979
  }
1140
2980
 
2981
+ func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
2982
+ guard let maxLength = textMaxLengths[ObjectIdentifier(textField)],
2983
+ let current = textField.text,
2984
+ let stringRange = Range(range, in: current) else { return true }
2985
+ return current.replacingCharacters(in: stringRange, with: string).utf16.count <= maxLength
2986
+ }
2987
+
2988
+ func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
2989
+ if text == "\n", let handler = submitHandlers[ObjectIdentifier(textView)] {
2990
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
2991
+ }
2992
+ guard let maxLength = textMaxLengths[ObjectIdentifier(textView)],
2993
+ let current = textView.text,
2994
+ let stringRange = Range(range, in: current) else { return true }
2995
+ return current.replacingCharacters(in: stringRange, with: text).utf16.count <= maxLength
2996
+ }
2997
+
2998
+ func textViewDidChange(_ textView: UITextView) {
2999
+ lastFocusedInput = textView
3000
+ let id = ObjectIdentifier(textView)
3001
+ inputIdentities[id].map { inputDrafts[$0] = textView.text ?? "" }
3002
+ (textView as? CraftNativeTextView)?.refreshPlaceholder()
3003
+ guard let handler = handlers[id] else { return }
3004
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
3005
+ }
3006
+
3007
+ func textViewDidBeginEditing(_ textView: UITextView) {
3008
+ lastFocusedInput = textView
3009
+ guard let handler = focusHandlers[ObjectIdentifier(textView)] else { return }
3010
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
3011
+ }
3012
+
3013
+ func textViewDidEndEditing(_ textView: UITextView) {
3014
+ let id = ObjectIdentifier(textView)
3015
+ let nativeEvent: [String: Any] = ["text": textView.text ?? ""]
3016
+ if let handler = blurHandlers[id] {
3017
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
3018
+ }
3019
+ if let handler = endEditingHandlers[id] {
3020
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
3021
+ }
3022
+ }
3023
+
1141
3024
  @objc private func textChanged(_ sender: UITextField) {
1142
3025
  lastFocusedInput = sender
3026
+ let id = ObjectIdentifier(sender)
3027
+ inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
1143
3028
  let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
1144
- guard let handler = handlers[ObjectIdentifier(sender)] else { return }
3029
+ guard let handler = handlers[id] else { return }
1145
3030
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
1146
3031
  restoreFocus(focused)
1147
3032
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
@@ -1149,15 +3034,59 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1149
3034
  }
1150
3035
  }
1151
3036
 
3037
+ @objc private func switchChanged(_ sender: UISwitch) {
3038
+ guard let handler = handlers[ObjectIdentifier(sender)] else { return }
3039
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
3040
+ }
3041
+
3042
+ @objc private func sliderChanged(_ sender: UISlider) {
3043
+ let value = snappedSliderValue(sender.value, for: sender)
3044
+ if sender.value != value { sender.value = value }
3045
+ guard let handler = handlers[ObjectIdentifier(sender)] else { return }
3046
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value]])
3047
+ }
3048
+
3049
+ @objc private func sliderFinished(_ sender: UISlider) {
3050
+ guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
3051
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
3052
+ }
3053
+
3054
+ func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
3055
+
3056
+ func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
3057
+ pickerOptions[ObjectIdentifier(pickerView)]?.count ?? 0
3058
+ }
3059
+
3060
+ func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
3061
+ guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row) else { return nil }
3062
+ return options[row].label
3063
+ }
3064
+
3065
+ func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
3066
+ guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row),
3067
+ let handler = handlers[ObjectIdentifier(pickerView)] else { return }
3068
+ let option = options[row]
3069
+ send(type: "EVENT", payload: [
3070
+ "handlerName": handler,
3071
+ "nativeEvent": ["value": option.value, "index": row],
3072
+ ])
3073
+ }
3074
+
1152
3075
  @objc private func textFocused(_ sender: UITextField) {
1153
3076
  lastFocusedInput = sender
1154
3077
  guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
1155
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
3078
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
1156
3079
  }
1157
3080
 
1158
3081
  @objc private func textBlurred(_ sender: UITextField) {
1159
- guard let handler = blurHandlers[ObjectIdentifier(sender)] else { return }
1160
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
3082
+ let id = ObjectIdentifier(sender)
3083
+ let nativeEvent: [String: Any] = ["text": sender.text ?? ""]
3084
+ if let handler = blurHandlers[id] {
3085
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
3086
+ }
3087
+ if let handler = endEditingHandlers[id] {
3088
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
3089
+ }
1161
3090
  }
1162
3091
 
1163
3092
  @objc private func textSubmitted(_ sender: UITextField) {
@@ -1167,6 +3096,37 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1167
3096
 
1168
3097
  @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
1169
3098
  guard let view = sender.view, let handler = handlers[ObjectIdentifier(view)] else { return }
3099
+ let id = ObjectIdentifier(view)
3100
+ if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
3101
+ let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
3102
+ UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
3103
+ UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity }
3104
+ }
3105
+ }
3106
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
3107
+ }
3108
+
3109
+ @objc private func viewPressFeedback(_ sender: UILongPressGestureRecognizer) {
3110
+ guard let view = sender.view else { return }
3111
+ let id = ObjectIdentifier(view)
3112
+ switch sender.state {
3113
+ case .began:
3114
+ if let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
3115
+ UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }
3116
+ }
3117
+ if let handler = pressInHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
3118
+ case .ended, .cancelled, .failed:
3119
+ if let pressable = view as? CraftNativeFlowView { UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity } }
3120
+ if let handler = pressOutHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
3121
+ default:
3122
+ break
3123
+ }
3124
+ }
3125
+
3126
+ @objc private func viewLongPressed(_ sender: UILongPressGestureRecognizer) {
3127
+ guard sender.state == .began,
3128
+ let view = sender.view,
3129
+ let handler = longPressHandlers[ObjectIdentifier(view)] else { return }
1170
3130
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
1171
3131
  }
1172
3132
 
@@ -1185,6 +3145,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1185
3145
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
1186
3146
  }
1187
3147
 
3148
+ func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
3149
+ guard let handler = scrollMomentumBeginHandlers[ObjectIdentifier(scrollView)] else { return }
3150
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
3151
+ }
3152
+
3153
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
3154
+ guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
3155
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
3156
+ }
3157
+
1188
3158
  private func scrollEvent(_ scrollView: UIScrollView) -> [String: Any] {
1189
3159
  [
1190
3160
  "contentOffset": ["x": scrollView.contentOffset.x, "y": scrollView.contentOffset.y],
@@ -1208,10 +3178,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1208
3178
  private func keyboardType(_ value: Any?) -> UIKeyboardType {
1209
3179
  switch value as? String {
1210
3180
  case "email-address": return .emailAddress
1211
- case "numeric": return .numberPad
3181
+ case "numeric", "number-pad": return .numberPad
1212
3182
  case "phone-pad": return .phonePad
1213
3183
  case "decimal-pad": return .decimalPad
1214
3184
  case "url": return .URL
3185
+ case "web-search": return .webSearch
3186
+ case "visible-password": return .asciiCapable
1215
3187
  default: return .default
1216
3188
  }
1217
3189
  }
@@ -1236,8 +3208,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1236
3208
  }
1237
3209
  }
1238
3210
 
3211
+ /// A style colour: CSS hex with or without alpha (`#10b9811a` is
3212
+ /// emerald at 10%), `rgb()`/`rgba()` and the few names; `UIColor(hex:)`
3213
+ /// for anything else it has always read.
1239
3214
  private func color(_ value: Any?) -> UIColor? {
1240
- guard let hex = value as? String else { return nil }
1241
- return UIColor(hex: hex)
3215
+ guard let text = value as? String else { return nil }
3216
+ return UIColor(css: text) ?? UIColor(hex: text)
3217
+ }
3218
+ }
3219
+
3220
+ /// `console` for native screens: the unified log, subsystem `craft.native`,
3221
+ /// category the screen's name. Read it with
3222
+ /// `log stream --predicate 'subsystem == "craft.native"'`.
3223
+ enum CraftNativeConsole {
3224
+ private static var loggers: [String: Logger] = [:]
3225
+ private static let lock = NSLock()
3226
+
3227
+ static func write(_ level: String, category: String, message: String) {
3228
+ lock.lock()
3229
+ let logger = loggers[category] ?? Logger(subsystem: "craft.native", category: category)
3230
+ loggers[category] = logger
3231
+ lock.unlock()
3232
+ switch level {
3233
+ case "debug": logger.debug("\(message, privacy: .public)")
3234
+ case "info": logger.info("\(message, privacy: .public)")
3235
+ case "warn": logger.warning("\(message, privacy: .public)")
3236
+ case "error": logger.error("\(message, privacy: .public)")
3237
+ default: logger.notice("\(message, privacy: .public)")
3238
+ }
1242
3239
  }
1243
3240
  }