craft-native 0.0.119 → 0.0.121

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.
@@ -18,16 +18,225 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
18
18
  func updateUIViewController(_ controller: UINavigationController, context: Context) {}
19
19
  }
20
20
 
21
+ 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?
28
+ let position: String
29
+ let top: CGFloat?
30
+ let right: CGFloat?
31
+ let bottom: CGFloat?
32
+ let left: CGFloat?
33
+ let alignSelf: String?
34
+
35
+ init(_ raw: [String: Any]) {
36
+ 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"])
43
+ position = raw["position"] as? String ?? "relative"
44
+ top = number(raw["top"])
45
+ right = number(raw["right"])
46
+ bottom = number(raw["bottom"])
47
+ left = number(raw["left"])
48
+ alignSelf = raw["alignSelf"] as? String
49
+ }
50
+ }
51
+
52
+ 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() } }
59
+ var padding = UIEdgeInsets.zero {
60
+ didSet {
61
+ layoutMargins = padding
62
+ isLayoutMarginsRelativeArrangement = true
63
+ invalidateIntrinsicContentSize()
64
+ setNeedsLayout()
65
+ }
66
+ }
67
+ private var childStyles: [ObjectIdentifier: CraftNativeLayoutStyle] = [:]
68
+
69
+ override init(frame: CGRect) {
70
+ super.init(frame: frame)
71
+ alignment = .fill
72
+ distribution = .fill
73
+ isLayoutMarginsRelativeArrangement = true
74
+ }
75
+
76
+ required init(coder: NSCoder) { super.init(coder: coder) }
77
+
78
+ private var alignmentValue: UIStackView.Alignment {
79
+ switch alignItems {
80
+ case "flex-start": return .leading
81
+ case "center": return .center
82
+ case "flex-end": return .trailing
83
+ case "baseline": return .firstBaseline
84
+ default: return .fill
85
+ }
86
+ }
87
+
88
+ private var distributionValue: UIStackView.Distribution {
89
+ switch justifyContent {
90
+ case "center": return .equalCentering
91
+ case "space-between", "space-around", "space-evenly": return .equalSpacing
92
+ default: return .fill
93
+ }
94
+ }
95
+
96
+ func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
97
+ childStyles[ObjectIdentifier(view)] = CraftNativeLayoutStyle(raw)
98
+ invalidateIntrinsicContentSize()
99
+ setNeedsLayout()
100
+ }
101
+
102
+ func removeLayoutStyle(for view: UIView) { childStyles.removeValue(forKey: ObjectIdentifier(view)) }
103
+
104
+ override func didAddSubview(_ subview: UIView) {
105
+ super.didAddSubview(subview)
106
+ setNeedsLayout()
107
+ }
108
+
109
+ override func willRemoveSubview(_ subview: UIView) {
110
+ childStyles.removeValue(forKey: ObjectIdentifier(subview))
111
+ super.willRemoveSubview(subview)
112
+ }
113
+
114
+ 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)
125
+ }
126
+
127
+ override func layoutSubviews() {
128
+ 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] = []
134
+ var main: CGFloat = 0
135
+ var cross: CGFloat = 0
136
+
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
173
+ }
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)
177
+ } else {
178
+ frame = CGRect(x: content.minX + crossOffset + crossPosition, y: content.minY + mainOffset, width: crossSize, height: size.height)
179
+ }
180
+ child.frame = frame.integral
181
+ mainOffset += (axis == .horizontal ? size.width : size.height) + mainGapValue + between
182
+ }
183
+ crossOffset += lineCross + crossGapValue
184
+ }
185
+
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
195
+ }
196
+ }
197
+
198
+ private var mainGapValue: CGFloat { axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap) }
199
+ private var crossGapValue: CGFloat { axis == .horizontal ? (rowGap ?? 0) : (columnGap ?? 0) }
200
+
201
+ private func styleCrossDimension(_ style: CraftNativeLayoutStyle) -> CGFloat? {
202
+ axis == .horizontal ? style.height : style.width
203
+ }
204
+
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))
212
+ }
213
+
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
+ }
220
+
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)
230
+ }
231
+ }
232
+ }
233
+
21
234
  private final class CraftNativeScrollView: UIScrollView {
22
- let contentStack = UIStackView()
235
+ let contentStack = CraftNativeFlowView()
23
236
  private var crossAxisConstraint: NSLayoutConstraint?
24
237
 
25
238
  override init(frame: CGRect) {
26
239
  super.init(frame: frame)
27
- contentStack.axis = .vertical
28
- contentStack.alignment = .fill
29
- contentStack.distribution = .fill
30
- contentStack.isLayoutMarginsRelativeArrangement = true
31
240
  contentStack.translatesAutoresizingMaskIntoConstraints = false
32
241
  addSubview(contentStack)
33
242
  NSLayoutConstraint.activate([
@@ -55,12 +264,13 @@ private final class CraftNativeScrollView: UIScrollView {
55
264
 
56
265
  private final class CraftNativeFlexSpacer: UIView {}
57
266
 
58
- final class CraftNativeScreenController: UIViewController {
267
+ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate {
59
268
  private final class RenderedNode {
60
269
  let identity: String
61
270
  let type: String
62
271
  let view: UIView
63
272
  var protocolId: String?
273
+ var style: [String: Any] = [:]
64
274
  var children: [RenderedNode] = []
65
275
  var widthConstraint: NSLayoutConstraint?
66
276
  var heightConstraint: NSLayoutConstraint?
@@ -76,12 +286,19 @@ final class CraftNativeScreenController: UIViewController {
76
286
  private let routeName: String?
77
287
  private let routeParams: [String: Any]
78
288
  private let jsContext = JSContext()!
79
- private let rootStack = UIStackView()
289
+ private let rootStack = CraftNativeFlowView()
80
290
  private var handlers: [ObjectIdentifier: String] = [:]
291
+ private var focusHandlers: [ObjectIdentifier: String] = [:]
292
+ private var blurHandlers: [ObjectIdentifier: String] = [:]
293
+ private var submitHandlers: [ObjectIdentifier: String] = [:]
294
+ private var scrollHandlers: [ObjectIdentifier: String] = [:]
295
+ private var scrollBeginHandlers: [ObjectIdentifier: String] = [:]
296
+ private var scrollEndHandlers: [ObjectIdentifier: String] = [:]
81
297
  private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
82
298
  private var imageSources: [ObjectIdentifier: String] = [:]
83
299
  private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
84
300
  private var renderedRoot: RenderedNode?
301
+ private weak var lastFocusedInput: UITextField?
85
302
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
86
303
  private var flatListOwners: [String: String] = [:]
87
304
  private let mutationDocument = CraftNativeMutationDocument()
@@ -115,8 +332,7 @@ final class CraftNativeScreenController: UIViewController {
115
332
  navigationItem.title = routeName ?? config.appName
116
333
  view.backgroundColor = config.resolvedBackgroundColor
117
334
  rootStack.axis = .vertical
118
- rootStack.alignment = .fill
119
- rootStack.distribution = .fill
335
+ rootStack.alignItems = "stretch"
120
336
  rootStack.translatesAutoresizingMaskIntoConstraints = false
121
337
  view.addSubview(rootStack)
122
338
  NSLayoutConstraint.activate([
@@ -208,6 +424,7 @@ final class CraftNativeScreenController: UIViewController {
208
424
  label.textAlignment = .center
209
425
  label.text = message
210
426
  rootStack.addArrangedSubview(label)
427
+ rootStack.setLayoutStyle(["alignSelf": "stretch"], for: label)
211
428
  NSLog("[craft native] %@", message)
212
429
  }
213
430
 
@@ -334,6 +551,7 @@ final class CraftNativeScreenController: UIViewController {
334
551
  }
335
552
 
336
553
  private func applyTargetedUpdates(_ ids: [String]) -> Bool {
554
+ let focused = firstResponder(in: rootStack) ?? lastFocusedInput
337
555
  var applied = Set<String>()
338
556
  for id in ids {
339
557
  let target = flatListOwners[id] ?? id
@@ -341,6 +559,7 @@ final class CraftNativeScreenController: UIViewController {
341
559
  guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
342
560
  _ = reconcile(document, identity: previous.identity, path: target, previous: previous)
343
561
  }
562
+ restoreFocus(focused)
344
563
  return true
345
564
  }
346
565
 
@@ -354,13 +573,16 @@ final class CraftNativeScreenController: UIViewController {
354
573
  }
355
574
 
356
575
  private func renderCommitted(_ document: [String: Any]) {
576
+ let focused = firstResponder(in: rootStack) ?? lastFocusedInput
357
577
  let previous = renderedRoot
358
578
  let next = reconcile(document, identity: "root", path: "root", previous: previous)
359
579
  if previous?.view !== next.view {
360
580
  if let old = previous { detach(old, from: rootStack) }
361
581
  rootStack.addArrangedSubview(next.view)
362
582
  }
583
+ rootStack.setLayoutStyle(next.style, for: next.view)
363
584
  renderedRoot = next
585
+ restoreFocus(focused)
364
586
  }
365
587
 
366
588
  private func clearRenderedTree() {
@@ -368,6 +590,19 @@ final class CraftNativeScreenController: UIViewController {
368
590
  renderedRoot = nil
369
591
  }
370
592
 
593
+ private func firstResponder(in view: UIView) -> UIView? {
594
+ if view.isFirstResponder { return view }
595
+ for child in view.subviews {
596
+ if let responder = firstResponder(in: child) { return responder }
597
+ }
598
+ return nil
599
+ }
600
+
601
+ private func restoreFocus(_ focused: UIView?) {
602
+ guard let focused, focused.window != nil, !focused.isFirstResponder else { return }
603
+ _ = focused.becomeFirstResponder()
604
+ }
605
+
371
606
  private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
372
607
  let type = node["type"] as? String ?? "View"
373
608
  let props = node["props"] as? [String: Any] ?? [:]
@@ -383,6 +618,7 @@ final class CraftNativeScreenController: UIViewController {
383
618
 
384
619
  let result = current.view
385
620
  current.protocolId = node["id"] as? String
621
+ current.style = style
386
622
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
387
623
  if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" {
388
624
  result.setContentHuggingPriority(.required, for: .vertical)
@@ -408,8 +644,26 @@ final class CraftNativeScreenController: UIViewController {
408
644
  field.textColor = color(style["color"]) ?? .label
409
645
  field.font = textFont(style, default: field.font ?? .systemFont(ofSize: UIFont.systemFontSize))
410
646
  field.textAlignment = textAlignment(style["textAlign"])
647
+ let desiredKeyboardType = keyboardType(props["keyboardType"])
648
+ if field.keyboardType != desiredKeyboardType { field.keyboardType = desiredKeyboardType }
649
+ let desiredReturnKeyType = returnKeyType(props["returnKeyType"])
650
+ if field.returnKeyType != desiredReturnKeyType { field.returnKeyType = desiredReturnKeyType }
651
+ let desiredAutocorrection: UITextAutocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
652
+ if field.autocorrectionType != desiredAutocorrection { field.autocorrectionType = desiredAutocorrection }
653
+ let desiredCapitalization = capitalizationType(props["autoCapitalize"])
654
+ if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
655
+ let desiredSecureEntry = props["secureTextEntry"] as? Bool == true
656
+ if field.isSecureTextEntry != desiredSecureEntry { field.isSecureTextEntry = desiredSecureEntry }
657
+ let desiredEnabled = props["editable"] as? Bool != false
658
+ if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
411
659
  updateField(field, value: props["value"] as? String)
412
660
  updateHandler(events["onChange"] ?? events["onChangeText"], for: field)
661
+ updateAuxiliaryHandler(events["onFocus"], in: &focusHandlers, for: field)
662
+ updateAuxiliaryHandler(events["onBlur"] ?? events["onEndEditing"], in: &blurHandlers, for: field)
663
+ updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
664
+ if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
665
+ DispatchQueue.main.async { _ = field.becomeFirstResponder() }
666
+ }
413
667
  case "Image":
414
668
  let image = result as! UIImageView
415
669
  image.contentMode = imageContentMode(style["resizeMode"] ?? props["resizeMode"])
@@ -420,6 +674,15 @@ final class CraftNativeScreenController: UIViewController {
420
674
  let direction = (props["horizontal"] as? Bool) == true || style["flexDirection"] as? String == "row"
421
675
  ? NSLayoutConstraint.Axis.horizontal : .vertical
422
676
  scroll.setAxis(direction)
677
+ scroll.delegate = self
678
+ scroll.isScrollEnabled = props["scrollEnabled"] as? Bool != false
679
+ scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
680
+ scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
681
+ scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
682
+ scroll.alwaysBounceHorizontal = props["alwaysBounceHorizontal"] as? Bool ?? (direction == .horizontal)
683
+ updateAuxiliaryHandler(events["onScroll"], in: &scrollHandlers, for: scroll)
684
+ updateAuxiliaryHandler(events["onScrollBeginDrag"], in: &scrollBeginHandlers, for: scroll)
685
+ updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
423
686
  configureStack(scroll.contentStack, style: style)
424
687
  reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: style)
425
688
  case "FlatList":
@@ -433,7 +696,7 @@ final class CraftNativeScreenController: UIViewController {
433
696
  events: events
434
697
  )
435
698
  default:
436
- let stack = result as! UIStackView
699
+ let stack = result as! CraftNativeFlowView
437
700
  configureStack(stack, style: style)
438
701
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
439
702
  }
@@ -458,6 +721,9 @@ final class CraftNativeScreenController: UIViewController {
458
721
  let field = UITextField()
459
722
  field.borderStyle = .roundedRect
460
723
  field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
724
+ field.addTarget(self, action: #selector(textFocused(_:)), for: .editingDidBegin)
725
+ field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
726
+ field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
461
727
  return field
462
728
  case "Image":
463
729
  return UIImageView()
@@ -466,11 +732,7 @@ final class CraftNativeScreenController: UIViewController {
466
732
  case "FlatList":
467
733
  return CraftNativeFlatList()
468
734
  default:
469
- let stack = UIStackView()
470
- stack.alignment = .fill
471
- stack.distribution = .fill
472
- stack.isLayoutMarginsRelativeArrangement = true
473
- return stack
735
+ return CraftNativeFlowView()
474
736
  }
475
737
  }
476
738
 
@@ -490,7 +752,7 @@ final class CraftNativeScreenController: UIViewController {
490
752
 
491
753
  private func reconcileChildren(
492
754
  _ children: [Any],
493
- in stack: UIStackView,
755
+ in stack: CraftNativeFlowView,
494
756
  parent: RenderedNode,
495
757
  path: String,
496
758
  style: [String: Any]
@@ -519,11 +781,13 @@ final class CraftNativeScreenController: UIViewController {
519
781
  detach(child, from: stack)
520
782
  }
521
783
  for (index, child) in next.enumerated() {
522
- if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view { continue }
523
- if stack.arrangedSubviews.contains(where: { $0 === child.view }) {
524
- stack.removeArrangedSubview(child.view)
784
+ if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view {
785
+ stack.setLayoutStyle(child.style, for: child.view)
786
+ continue
525
787
  }
526
- stack.insertArrangedSubview(child.view, at: index)
788
+ if child.view.superview === stack { stack.removeArrangedSubview(child.view) }
789
+ stack.insertArrangedSubview(child.view, at: min(index, stack.arrangedSubviews.count))
790
+ stack.setLayoutStyle(child.style, for: child.view)
527
791
  }
528
792
  parent.children = next
529
793
  addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
@@ -584,8 +848,9 @@ final class CraftNativeScreenController: UIViewController {
584
848
  }
585
849
  }
586
850
 
587
- private func detach(_ node: RenderedNode, from stack: UIStackView) {
851
+ private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
588
852
  forgetHandlers(node)
853
+ stack.removeLayoutStyle(for: node.view)
589
854
  stack.removeArrangedSubview(node.view)
590
855
  node.view.removeFromSuperview()
591
856
  }
@@ -599,6 +864,12 @@ final class CraftNativeScreenController: UIViewController {
599
864
  }
600
865
  let id = ObjectIdentifier(node.view)
601
866
  handlers.removeValue(forKey: id)
867
+ focusHandlers.removeValue(forKey: id)
868
+ blurHandlers.removeValue(forKey: id)
869
+ submitHandlers.removeValue(forKey: id)
870
+ scrollHandlers.removeValue(forKey: id)
871
+ scrollBeginHandlers.removeValue(forKey: id)
872
+ scrollEndHandlers.removeValue(forKey: id)
602
873
  if let recognizer = tapRecognizers.removeValue(forKey: id) {
603
874
  node.view.removeGestureRecognizer(recognizer)
604
875
  }
@@ -613,6 +884,16 @@ final class CraftNativeScreenController: UIViewController {
613
884
  else { handlers.removeValue(forKey: id) }
614
885
  }
615
886
 
887
+ private func updateAuxiliaryHandler(
888
+ _ handler: String?,
889
+ in handlers: inout [ObjectIdentifier: String],
890
+ for view: UIView
891
+ ) {
892
+ let id = ObjectIdentifier(view)
893
+ if let handler, !handler.isEmpty { handlers[id] = handler }
894
+ else { handlers.removeValue(forKey: id) }
895
+ }
896
+
616
897
  private func updatePressHandler(_ handler: String?, for view: UIView) {
617
898
  let id = ObjectIdentifier(view)
618
899
  updateHandler(handler, for: view)
@@ -639,26 +920,19 @@ final class CraftNativeScreenController: UIViewController {
639
920
  }
640
921
  }
641
922
 
642
- private func configureStack(_ stack: UIStackView, style: [String: Any]) {
923
+ private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
643
924
  let direction = style["flexDirection"] as? String
644
925
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
645
- stack.spacing = number(stack.axis == .horizontal ? style["columnGap"] : style["rowGap"])
646
- ?? number(style["gap"]) ?? 0
647
- switch style["alignItems"] as? String {
648
- case "flex-start": stack.alignment = .leading
649
- case "center": stack.alignment = .center
650
- case "flex-end": stack.alignment = .trailing
651
- case "baseline": stack.alignment = .firstBaseline
652
- default: stack.alignment = .fill
653
- }
654
- switch style["justifyContent"] as? String {
655
- case "space-between", "space-around", "space-evenly": stack.distribution = .equalSpacing
656
- default: stack.distribution = .fill
657
- }
926
+ stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
927
+ stack.alignItems = style["alignItems"] as? String ?? "stretch"
928
+ 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"])
658
932
  let padding = number(style["padding"]) ?? 0
659
933
  let horizontal = number(style["paddingHorizontal"]) ?? padding
660
934
  let vertical = number(style["paddingVertical"]) ?? padding
661
- stack.layoutMargins = UIEdgeInsets(
935
+ stack.padding = UIEdgeInsets(
662
936
  top: number(style["paddingTop"]) ?? vertical,
663
937
  left: number(style["paddingLeft"]) ?? horizontal,
664
938
  bottom: number(style["paddingBottom"]) ?? vertical,
@@ -666,15 +940,13 @@ final class CraftNativeScreenController: UIViewController {
666
940
  )
667
941
  }
668
942
 
669
- private func addJustificationSpacers(to stack: UIStackView, value: String?) {
670
- guard !stack.arrangedSubviews.isEmpty else { return }
671
- let leading = value == "center" || value == "flex-end"
672
- let trailing = value == nil || value == "flex-start" || value == "center"
673
- if leading { stack.insertArrangedSubview(CraftNativeFlexSpacer(), at: 0) }
674
- if trailing { stack.addArrangedSubview(CraftNativeFlexSpacer()) }
675
- }
943
+ private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
676
944
 
677
945
  private func applyViewStyle(_ style: [String: Any], to view: UIView, node: RenderedNode) {
946
+ let width = style["minWidth"] == nil && style["maxWidth"] == nil ? number(style["width"]) : nil
947
+ let height = style["minHeight"] == nil && style["maxHeight"] == nil ? number(style["height"]) : nil
948
+ updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
949
+ updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
678
950
  view.backgroundColor = color(style["backgroundColor"]) ?? .clear
679
951
  view.alpha = number(style["opacity"]) ?? 1
680
952
  view.isHidden = style["display"] as? String == "none"
@@ -682,8 +954,6 @@ final class CraftNativeScreenController: UIViewController {
682
954
  view.layer.borderWidth = number(style["borderWidth"]) ?? 0
683
955
  view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
684
956
  view.clipsToBounds = style["overflow"] as? String == "hidden"
685
- updateDimension(number(style["width"]), constraint: &node.widthConstraint, anchor: view.widthAnchor)
686
- updateDimension(number(style["height"]), constraint: &node.heightConstraint, anchor: view.heightAnchor)
687
957
  }
688
958
 
689
959
  private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
@@ -822,16 +1092,27 @@ final class CraftNativeScreenController: UIViewController {
822
1092
  private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
823
1093
  view.accessibilityLabel = props["accessibilityLabel"] as? String
824
1094
  view.accessibilityHint = props["accessibilityHint"] as? String
1095
+ if let value = props["accessibilityValue"] {
1096
+ view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
1097
+ }
825
1098
  let role = props["accessibilityRole"] as? String
826
- view.isAccessibilityElement = view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Image", "TextInput"].contains(type)
1099
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Image", "TextInput"].contains(type))
1100
+ var traits: UIAccessibilityTraits = []
827
1101
  switch role ?? type.lowercased() {
828
- case "button": view.accessibilityTraits = .button
829
- case "image": view.accessibilityTraits = .image
830
- case "header": view.accessibilityTraits = .header
831
- case "link": view.accessibilityTraits = .link
832
- case "search": view.accessibilityTraits = .searchField
833
- default: view.accessibilityTraits = []
1102
+ case "button": traits.insert(.button)
1103
+ case "image": traits.insert(.image)
1104
+ case "header": traits.insert(.header)
1105
+ case "link": traits.insert(.link)
1106
+ case "search": traits.insert(.searchField)
1107
+ default: break
1108
+ }
1109
+ if let state = props["accessibilityState"] as? [String: Any] {
1110
+ if state["disabled"] as? Bool == true { traits.insert(.notEnabled) }
1111
+ if state["selected"] as? Bool == true { traits.insert(.selected) }
1112
+ if state["checked"] as? Bool == true { traits.insert(.selected) }
834
1113
  }
1114
+ if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
1115
+ view.accessibilityTraits = traits
835
1116
  }
836
1117
 
837
1118
  private func updateDimension(_ value: CGFloat?, constraint: inout NSLayoutConstraint?, anchor: NSLayoutDimension) {
@@ -848,13 +1129,40 @@ final class CraftNativeScreenController: UIViewController {
848
1129
  }
849
1130
 
850
1131
  @objc private func buttonPressed(_ sender: UIButton) {
1132
+ let focused = firstResponder(in: rootStack) ?? lastFocusedInput
851
1133
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
852
1134
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
1135
+ restoreFocus(focused)
1136
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
1137
+ self?.restoreFocus(focused)
1138
+ }
853
1139
  }
854
1140
 
855
1141
  @objc private func textChanged(_ sender: UITextField) {
1142
+ lastFocusedInput = sender
1143
+ let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
856
1144
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
857
1145
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
1146
+ restoreFocus(focused)
1147
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
1148
+ self?.restoreFocus(focused)
1149
+ }
1150
+ }
1151
+
1152
+ @objc private func textFocused(_ sender: UITextField) {
1153
+ lastFocusedInput = sender
1154
+ guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
1155
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
1156
+ }
1157
+
1158
+ @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 ?? ""]])
1161
+ }
1162
+
1163
+ @objc private func textSubmitted(_ sender: UITextField) {
1164
+ guard let handler = submitHandlers[ObjectIdentifier(sender)] else { return }
1165
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
858
1166
  }
859
1167
 
860
1168
  @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
@@ -862,6 +1170,29 @@ final class CraftNativeScreenController: UIViewController {
862
1170
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
863
1171
  }
864
1172
 
1173
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
1174
+ guard let handler = scrollHandlers[ObjectIdentifier(scrollView)] else { return }
1175
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
1176
+ }
1177
+
1178
+ func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
1179
+ guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
1180
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
1181
+ }
1182
+
1183
+ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
1184
+ guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
1185
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
1186
+ }
1187
+
1188
+ private func scrollEvent(_ scrollView: UIScrollView) -> [String: Any] {
1189
+ [
1190
+ "contentOffset": ["x": scrollView.contentOffset.x, "y": scrollView.contentOffset.y],
1191
+ "contentSize": ["width": scrollView.contentSize.width, "height": scrollView.contentSize.height],
1192
+ "layoutMeasurement": ["width": scrollView.bounds.width, "height": scrollView.bounds.height],
1193
+ ]
1194
+ }
1195
+
865
1196
  private func send(type: String, payload: [String: Any], correlationId: String? = nil) {
866
1197
  var message: [String: Any] = ["type": type, "payload": payload]
867
1198
  if let correlationId = correlationId { message["correlationId"] = correlationId }
@@ -874,6 +1205,37 @@ final class CraftNativeScreenController: UIViewController {
874
1205
  (value as? NSNumber).map { CGFloat(truncating: $0) }
875
1206
  }
876
1207
 
1208
+ private func keyboardType(_ value: Any?) -> UIKeyboardType {
1209
+ switch value as? String {
1210
+ case "email-address": return .emailAddress
1211
+ case "numeric": return .numberPad
1212
+ case "phone-pad": return .phonePad
1213
+ case "decimal-pad": return .decimalPad
1214
+ case "url": return .URL
1215
+ default: return .default
1216
+ }
1217
+ }
1218
+
1219
+ private func returnKeyType(_ value: Any?) -> UIReturnKeyType {
1220
+ switch value as? String {
1221
+ case "done": return .done
1222
+ case "go": return .go
1223
+ case "next": return .next
1224
+ case "search": return .search
1225
+ case "send": return .send
1226
+ default: return .default
1227
+ }
1228
+ }
1229
+
1230
+ private func capitalizationType(_ value: Any?) -> UITextAutocapitalizationType {
1231
+ switch value as? String {
1232
+ case "none": return .none
1233
+ case "words": return .words
1234
+ case "characters": return .allCharacters
1235
+ default: return .sentences
1236
+ }
1237
+ }
1238
+
877
1239
  private func color(_ value: Any?) -> UIColor? {
878
1240
  guard let hex = value as? String else { return nil }
879
1241
  return UIColor(hex: hex)