craft-native 0.0.113 → 0.0.115

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.
@@ -16,11 +16,49 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
16
16
  func updateUIViewController(_ controller: UINavigationController, context: Context) {}
17
17
  }
18
18
 
19
+ private final class CraftNativeScrollView: UIScrollView {
20
+ let contentStack = UIStackView()
21
+ private var crossAxisConstraint: NSLayoutConstraint?
22
+
23
+ override init(frame: CGRect) {
24
+ super.init(frame: frame)
25
+ contentStack.axis = .vertical
26
+ contentStack.alignment = .fill
27
+ contentStack.distribution = .fill
28
+ contentStack.isLayoutMarginsRelativeArrangement = true
29
+ contentStack.translatesAutoresizingMaskIntoConstraints = false
30
+ addSubview(contentStack)
31
+ NSLayoutConstraint.activate([
32
+ contentStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
33
+ contentStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
34
+ contentStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
35
+ contentStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor)
36
+ ])
37
+ setAxis(.vertical)
38
+ }
39
+
40
+ required init?(coder: NSCoder) { nil }
41
+
42
+ func setAxis(_ axis: NSLayoutConstraint.Axis) {
43
+ contentStack.axis = axis
44
+ crossAxisConstraint?.isActive = false
45
+ crossAxisConstraint = axis == .vertical
46
+ ? contentStack.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor)
47
+ : contentStack.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor)
48
+ crossAxisConstraint?.isActive = true
49
+ alwaysBounceVertical = axis == .vertical
50
+ alwaysBounceHorizontal = axis == .horizontal
51
+ }
52
+ }
53
+
54
+ private final class CraftNativeFlexSpacer: UIView {}
55
+
19
56
  final class CraftNativeScreenController: UIViewController {
20
57
  private final class RenderedNode {
21
58
  let identity: String
22
59
  let type: String
23
60
  let view: UIView
61
+ var protocolId: String?
24
62
  var children: [RenderedNode] = []
25
63
  var widthConstraint: NSLayoutConstraint?
26
64
  var heightConstraint: NSLayoutConstraint?
@@ -38,7 +76,13 @@ final class CraftNativeScreenController: UIViewController {
38
76
  private let jsContext = JSContext()!
39
77
  private let rootStack = UIStackView()
40
78
  private var handlers: [ObjectIdentifier: String] = [:]
79
+ private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
80
+ private var imageSources: [ObjectIdentifier: String] = [:]
81
+ private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
41
82
  private var renderedRoot: RenderedNode?
83
+ private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
84
+ private var flatListOwners: [String: String] = [:]
85
+ private let mutationDocument = CraftNativeMutationDocument()
42
86
 
43
87
  init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
44
88
  self.config = config
@@ -51,6 +95,10 @@ final class CraftNativeScreenController: UIViewController {
51
95
  fatalError("init(coder:) has not been implemented")
52
96
  }
53
97
 
98
+ deinit {
99
+ imageTasks.values.forEach { $0.cancel() }
100
+ }
101
+
54
102
  override func viewDidLoad() {
55
103
  super.viewDidLoad()
56
104
  navigationItem.title = routeName ?? config.appName
@@ -85,6 +133,7 @@ final class CraftNativeScreenController: UIViewController {
85
133
  jsContext.evaluateScript("""
86
134
  globalThis.__stxNativeCallback = null;
87
135
  globalThis.__stxNativeBridge = {
136
+ mutationProtocolVersion: 1,
88
137
  postMessage: function(message) { craftNativePostMessage(message); },
89
138
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
90
139
  };
@@ -120,13 +169,38 @@ final class CraftNativeScreenController: UIViewController {
120
169
  private func receive(_ raw: String) {
121
170
  guard let data = raw.data(using: .utf8),
122
171
  let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
123
- let type = message["type"] as? String,
124
- let payload = message["payload"] as? [String: Any] else { return }
172
+ let type = message["type"] as? String else { return }
173
+ let payload = message["payload"] as? [String: Any] ?? [:]
125
174
 
126
175
  switch type {
127
176
  case "RENDER":
128
177
  guard let document = payload["document"] as? [String: Any] else { return }
129
178
  render(document)
179
+ case "MUTATE":
180
+ do {
181
+ let result = try applyMutation(payload)
182
+ send(type: "MUTATION_ACK", payload: [
183
+ "batchId": result.batchId,
184
+ "revision": result.revision,
185
+ "version": craftNativeMutationProtocolVersion,
186
+ ])
187
+ } catch let failure as CraftNativeMutationFailure {
188
+ var error: [String: Any] = [
189
+ "batchId": payload["batchId"] as? String ?? "",
190
+ "code": failure.code,
191
+ "message": failure.message,
192
+ "version": craftNativeMutationProtocolVersion,
193
+ ]
194
+ if let operationIndex = failure.operationIndex { error["operationIndex"] = operationIndex }
195
+ send(type: "MUTATION_ERROR", payload: error)
196
+ } catch {
197
+ send(type: "MUTATION_ERROR", payload: [
198
+ "batchId": payload["batchId"] as? String ?? "",
199
+ "code": "INVALID_BATCH",
200
+ "message": "mutation batch could not be applied",
201
+ "version": craftNativeMutationProtocolVersion,
202
+ ])
203
+ }
130
204
  case "NAVIGATE", "NAVIGATE_REPLACE":
131
205
  guard navigationController?.topViewController === self,
132
206
  let screen = payload["screen"] as? String, !screen.isEmpty,
@@ -176,6 +250,39 @@ final class CraftNativeScreenController: UIViewController {
176
250
 
177
251
  // Internal so simulator-hosted XCTest can assert actual UIView identity.
178
252
  func render(_ document: [String: Any]) {
253
+ mutationDocument.replace(with: document)
254
+ renderCommitted(document)
255
+ }
256
+
257
+ @discardableResult
258
+ func applyMutation(_ payload: [String: Any]) throws -> CraftNativeMutationResult {
259
+ let result = try mutationDocument.apply(payload)
260
+ if !result.requiresFullRender, applyTargetedUpdates(result.affectedNodeIds) { return result }
261
+ if let document = result.document { renderCommitted(document) } else { clearRenderedTree() }
262
+ return result
263
+ }
264
+
265
+ private func applyTargetedUpdates(_ ids: [String]) -> Bool {
266
+ var applied = Set<String>()
267
+ for id in ids {
268
+ let target = flatListOwners[id] ?? id
269
+ guard applied.insert(target).inserted else { continue }
270
+ guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
271
+ _ = reconcile(document, identity: previous.identity, path: target, previous: previous)
272
+ }
273
+ return true
274
+ }
275
+
276
+ private func renderedNode(_ id: String, below node: RenderedNode? = nil) -> RenderedNode? {
277
+ guard let node = node ?? renderedRoot else { return nil }
278
+ if node.protocolId == id { return node }
279
+ for child in node.children {
280
+ if let match = renderedNode(id, below: child) { return match }
281
+ }
282
+ return nil
283
+ }
284
+
285
+ private func renderCommitted(_ document: [String: Any]) {
179
286
  let previous = renderedRoot
180
287
  let next = reconcile(document, identity: "root", path: "root", previous: previous)
181
288
  if previous?.view !== next.view {
@@ -185,6 +292,11 @@ final class CraftNativeScreenController: UIViewController {
185
292
  renderedRoot = next
186
293
  }
187
294
 
295
+ private func clearRenderedTree() {
296
+ if let root = renderedRoot { detach(root, from: rootStack) }
297
+ renderedRoot = nil
298
+ }
299
+
188
300
  private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
189
301
  let type = node["type"] as? String ?? "View"
190
302
  let props = node["props"] as? [String: Any] ?? [:]
@@ -199,43 +311,65 @@ final class CraftNativeScreenController: UIViewController {
199
311
  }
200
312
 
201
313
  let result = current.view
202
- result.accessibilityIdentifier = explicitKey(node, props: props) ?? path
203
- if type != "View" && type != "SafeAreaView" {
314
+ current.protocolId = node["id"] as? String
315
+ result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
316
+ if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" {
204
317
  result.setContentHuggingPriority(.required, for: .vertical)
205
318
  }
206
- result.backgroundColor = color(style["backgroundColor"]) ?? .clear
207
- result.layer.cornerRadius = number(style["borderRadius"]) ?? 0
208
- updateDimension(number(style["width"]), constraint: &current.widthConstraint, anchor: result.widthAnchor)
209
- updateDimension(number(style["height"]), constraint: &current.heightConstraint, anchor: result.heightAnchor)
319
+ applyViewStyle(style, to: result, node: current)
210
320
 
211
321
  switch type {
212
322
  case "Text":
213
323
  let label = result as! UILabel
214
- label.text = children.compactMap { $0 as? String }.joined()
215
- label.textColor = color(style["color"]) ?? .label
216
- label.font = .systemFont(ofSize: number(style["fontSize"]) ?? UIFont.systemFontSize)
324
+ configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
217
325
  case "Button":
218
326
  let button = result as! UIButton
219
327
  button.setTitle(props["title"] as? String ?? children.compactMap { $0 as? String }.joined(), for: .normal)
328
+ button.setTitleColor(color(style["color"]) ?? .systemBlue, for: .normal)
329
+ button.titleLabel?.font = textFont(
330
+ style,
331
+ default: button.titleLabel?.font ?? .systemFont(ofSize: UIFont.buttonFontSize)
332
+ )
220
333
  updateHandler(events["onPress"] ?? events["onClick"], for: button)
221
334
  case "TextInput":
222
335
  let field = result as! UITextField
223
336
  field.placeholder = props["placeholder"] as? String
337
+ field.textColor = color(style["color"]) ?? .label
338
+ field.font = textFont(style, default: field.font ?? .systemFont(ofSize: UIFont.systemFontSize))
339
+ field.textAlignment = textAlignment(style["textAlign"])
224
340
  updateField(field, value: props["value"] as? String)
225
341
  updateHandler(events["onChange"] ?? events["onChangeText"], for: field)
342
+ case "Image":
343
+ let image = result as! UIImageView
344
+ image.contentMode = imageContentMode(style["resizeMode"] ?? props["resizeMode"])
345
+ image.clipsToBounds = image.contentMode == .scaleAspectFill
346
+ updateImage(image, source: props["source"])
347
+ case "ScrollView":
348
+ let scroll = result as! CraftNativeScrollView
349
+ let direction = (props["horizontal"] as? Bool) == true || style["flexDirection"] as? String == "row"
350
+ ? NSLayoutConstraint.Axis.horizontal : .vertical
351
+ scroll.setAxis(direction)
352
+ configureStack(scroll.contentStack, style: style)
353
+ reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: style)
354
+ case "FlatList":
355
+ let list = result as! CraftNativeFlatList
356
+ reconcileFlatList(
357
+ children.compactMap { $0 as? [String: Any] },
358
+ in: list,
359
+ parent: current,
360
+ path: path,
361
+ props: props,
362
+ events: events
363
+ )
226
364
  default:
227
365
  let stack = result as! UIStackView
228
- stack.axis = style["flexDirection"] as? String == "row" ? .horizontal : .vertical
229
- stack.spacing = number(style["gap"]) ?? 0
230
- let padding = number(style["padding"]) ?? 0
231
- stack.layoutMargins = UIEdgeInsets(
232
- top: number(style["paddingTop"]) ?? padding,
233
- left: number(style["paddingLeft"]) ?? padding,
234
- bottom: number(style["paddingBottom"]) ?? padding,
235
- right: number(style["paddingRight"]) ?? padding
236
- )
237
- reconcileChildren(children, in: stack, parent: current, path: path)
366
+ configureStack(stack, style: style)
367
+ reconcileChildren(children, in: stack, parent: current, path: path, style: style)
238
368
  }
369
+ if type != "Button" && type != "TextInput" {
370
+ updatePressHandler(events["onPress"] ?? events["onClick"], for: result)
371
+ }
372
+ applyAccessibility(props, type: type, to: result)
239
373
  return current
240
374
  }
241
375
 
@@ -254,6 +388,12 @@ final class CraftNativeScreenController: UIViewController {
254
388
  field.borderStyle = .roundedRect
255
389
  field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
256
390
  return field
391
+ case "Image":
392
+ return UIImageView()
393
+ case "ScrollView":
394
+ return CraftNativeScrollView()
395
+ case "FlatList":
396
+ return CraftNativeFlatList()
257
397
  default:
258
398
  let stack = UIStackView()
259
399
  stack.alignment = .fill
@@ -266,12 +406,28 @@ final class CraftNativeScreenController: UIViewController {
266
406
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
267
407
  // The current stx-native compiler emits key in props; accept the IR
268
408
  // field too so keyed children keep working when the compiler adopts it.
269
- [node["key"], props["key"], props["testID"]]
409
+ [node["id"], node["key"], props["key"], props["testID"]]
410
+ .compactMap { $0 as? String }
411
+ .first { !$0.isEmpty }
412
+ }
413
+
414
+ private func accessibilityIdentifier(_ node: [String: Any], props: [String: Any]) -> String? {
415
+ [props["testID"], props["key"], node["key"], node["id"]]
270
416
  .compactMap { $0 as? String }
271
417
  .first { !$0.isEmpty }
272
418
  }
273
419
 
274
- private func reconcileChildren(_ children: [Any], in stack: UIStackView, parent: RenderedNode, path: String) {
420
+ private func reconcileChildren(
421
+ _ children: [Any],
422
+ in stack: UIStackView,
423
+ parent: RenderedNode,
424
+ path: String,
425
+ style: [String: Any]
426
+ ) {
427
+ for spacer in stack.arrangedSubviews.compactMap({ $0 as? CraftNativeFlexSpacer }) {
428
+ stack.removeArrangedSubview(spacer)
429
+ spacer.removeFromSuperview()
430
+ }
275
431
  let nodes = children.compactMap { $0 as? [String: Any] }
276
432
  let keys = nodes.compactMap { explicitKey($0, props: $0["props"] as? [String: Any] ?? [:]) }
277
433
  let counts = Dictionary(keys.map { ($0, 1) }, uniquingKeysWith: +)
@@ -286,6 +442,7 @@ final class CraftNativeScreenController: UIViewController {
286
442
  let child = reconcile(node, identity: identity, path: "\(path).\(index)", previous: old[identity])
287
443
  next.append(child)
288
444
  }
445
+ if (style["flexDirection"] as? String)?.hasSuffix("-reverse") == true { next.reverse() }
289
446
 
290
447
  for child in parent.children where !next.contains(where: { $0 === child }) {
291
448
  detach(child, from: stack)
@@ -298,14 +455,61 @@ final class CraftNativeScreenController: UIViewController {
298
455
  stack.insertArrangedSubview(child.view, at: index)
299
456
  }
300
457
  parent.children = next
301
- if path == "root" && stack.axis == .vertical {
302
- // Keep the initial screen pinned to the safe-area top.
303
- if stack.arrangedSubviews.count == next.count { stack.addArrangedSubview(UIView()) }
304
- } else if stack.arrangedSubviews.count > next.count {
305
- for filler in stack.arrangedSubviews.dropFirst(next.count) {
306
- stack.removeArrangedSubview(filler)
307
- filler.removeFromSuperview()
458
+ addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
459
+ }
460
+
461
+ private func reconcileFlatList(
462
+ _ children: [[String: Any]],
463
+ in list: CraftNativeFlatList,
464
+ parent: RenderedNode,
465
+ path: String,
466
+ props: [String: Any],
467
+ events: [String: String]
468
+ ) {
469
+ let listKey = ObjectIdentifier(list)
470
+ let owner = parent.protocolId ?? path
471
+ flatListOwners = flatListOwners.filter { $0.value != owner }
472
+ for child in children { registerFlatListOwnership(child, owner: owner) }
473
+
474
+ list.apply(
475
+ nodes: children,
476
+ horizontal: props["horizontal"] as? Bool == true,
477
+ columns: (props["numColumns"] as? NSNumber)?.intValue ?? 1,
478
+ inverted: props["inverted"] as? Bool == true,
479
+ endReachedThreshold: (props["onEndReachedThreshold"] as? NSNumber)?.doubleValue
480
+ ?? (props["threshold"] as? NSNumber)?.doubleValue
481
+ ?? 0.1,
482
+ renderItem: { [weak self] node, identity, _ in
483
+ guard let self = self else { return UIView() }
484
+ let previous = self.flatListRows[listKey]?[identity]
485
+ let next = self.reconcile(
486
+ node,
487
+ identity: "list:\(identity)",
488
+ path: "\(path).\(identity)",
489
+ previous: previous
490
+ )
491
+ self.flatListRows[listKey, default: [:]][identity] = next
492
+ return next.view
493
+ },
494
+ recycleItem: { [weak self] identity in
495
+ guard let self = self,
496
+ let row = self.flatListRows[listKey]?.removeValue(forKey: identity) else { return }
497
+ self.forgetHandlers(row)
498
+ },
499
+ endReached: events["onEndReached"].map { [weak self] handler in
500
+ { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
308
501
  }
502
+ )
503
+ parent.children = []
504
+ }
505
+
506
+ private func registerFlatListOwnership(_ node: [String: Any], owner: String) {
507
+ let props = node["props"] as? [String: Any] ?? [:]
508
+ for identity in [node["id"], node["key"], props["key"], props["testID"]].compactMap({ $0 as? String }) {
509
+ if !identity.isEmpty { flatListOwners[identity] = owner }
510
+ }
511
+ for child in (node["children"] as? [Any] ?? []).compactMap({ $0 as? [String: Any] }) {
512
+ registerFlatListOwnership(child, owner: owner)
309
513
  }
310
514
  }
311
515
 
@@ -316,7 +520,19 @@ final class CraftNativeScreenController: UIViewController {
316
520
  }
317
521
 
318
522
  private func forgetHandlers(_ node: RenderedNode) {
319
- handlers.removeValue(forKey: ObjectIdentifier(node.view))
523
+ if let list = node.view as? CraftNativeFlatList {
524
+ let listKey = ObjectIdentifier(list)
525
+ list.discardAll()
526
+ flatListRows.removeValue(forKey: listKey)
527
+ if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
528
+ }
529
+ let id = ObjectIdentifier(node.view)
530
+ handlers.removeValue(forKey: id)
531
+ if let recognizer = tapRecognizers.removeValue(forKey: id) {
532
+ node.view.removeGestureRecognizer(recognizer)
533
+ }
534
+ imageSources.removeValue(forKey: id)
535
+ imageTasks.removeValue(forKey: id)?.cancel()
320
536
  for child in node.children { forgetHandlers(child) }
321
537
  }
322
538
 
@@ -326,6 +542,19 @@ final class CraftNativeScreenController: UIViewController {
326
542
  else { handlers.removeValue(forKey: id) }
327
543
  }
328
544
 
545
+ private func updatePressHandler(_ handler: String?, for view: UIView) {
546
+ let id = ObjectIdentifier(view)
547
+ updateHandler(handler, for: view)
548
+ if handler != nil, tapRecognizers[id] == nil {
549
+ let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
550
+ tapRecognizers[id] = recognizer
551
+ view.addGestureRecognizer(recognizer)
552
+ } else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
553
+ view.removeGestureRecognizer(recognizer)
554
+ }
555
+ if view is UIImageView { view.isUserInteractionEnabled = handler != nil }
556
+ }
557
+
329
558
  private func updateField(_ field: UITextField, value: String?) {
330
559
  guard let value = value, field.text != value, field.markedTextRange == nil else { return }
331
560
  let selection = field.selectedTextRange
@@ -339,6 +568,201 @@ final class CraftNativeScreenController: UIViewController {
339
568
  }
340
569
  }
341
570
 
571
+ private func configureStack(_ stack: UIStackView, style: [String: Any]) {
572
+ let direction = style["flexDirection"] as? String
573
+ stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
574
+ stack.spacing = number(stack.axis == .horizontal ? style["columnGap"] : style["rowGap"])
575
+ ?? number(style["gap"]) ?? 0
576
+ switch style["alignItems"] as? String {
577
+ case "flex-start": stack.alignment = .leading
578
+ case "center": stack.alignment = .center
579
+ case "flex-end": stack.alignment = .trailing
580
+ case "baseline": stack.alignment = .firstBaseline
581
+ default: stack.alignment = .fill
582
+ }
583
+ switch style["justifyContent"] as? String {
584
+ case "space-between", "space-around", "space-evenly": stack.distribution = .equalSpacing
585
+ default: stack.distribution = .fill
586
+ }
587
+ let padding = number(style["padding"]) ?? 0
588
+ let horizontal = number(style["paddingHorizontal"]) ?? padding
589
+ let vertical = number(style["paddingVertical"]) ?? padding
590
+ stack.layoutMargins = UIEdgeInsets(
591
+ top: number(style["paddingTop"]) ?? vertical,
592
+ left: number(style["paddingLeft"]) ?? horizontal,
593
+ bottom: number(style["paddingBottom"]) ?? vertical,
594
+ right: number(style["paddingRight"]) ?? horizontal
595
+ )
596
+ }
597
+
598
+ private func addJustificationSpacers(to stack: UIStackView, value: String?) {
599
+ guard !stack.arrangedSubviews.isEmpty else { return }
600
+ let leading = value == "center" || value == "flex-end"
601
+ let trailing = value == nil || value == "flex-start" || value == "center"
602
+ if leading { stack.insertArrangedSubview(CraftNativeFlexSpacer(), at: 0) }
603
+ if trailing { stack.addArrangedSubview(CraftNativeFlexSpacer()) }
604
+ }
605
+
606
+ private func applyViewStyle(_ style: [String: Any], to view: UIView, node: RenderedNode) {
607
+ view.backgroundColor = color(style["backgroundColor"]) ?? .clear
608
+ view.alpha = number(style["opacity"]) ?? 1
609
+ view.isHidden = style["display"] as? String == "none"
610
+ view.layer.cornerRadius = number(style["borderRadius"]) ?? 0
611
+ view.layer.borderWidth = number(style["borderWidth"]) ?? 0
612
+ view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
613
+ view.clipsToBounds = style["overflow"] as? String == "hidden"
614
+ updateDimension(number(style["width"]), constraint: &node.widthConstraint, anchor: view.widthAnchor)
615
+ updateDimension(number(style["height"]), constraint: &node.heightConstraint, anchor: view.heightAnchor)
616
+ }
617
+
618
+ private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
619
+ let transformed: String
620
+ switch style["textTransform"] as? String {
621
+ case "uppercase": transformed = text.uppercased()
622
+ case "lowercase": transformed = text.lowercased()
623
+ case "capitalize": transformed = text.capitalized
624
+ default: transformed = text
625
+ }
626
+ label.textColor = color(style["color"]) ?? .label
627
+ label.font = textFont(style, default: .systemFont(ofSize: UIFont.systemFontSize))
628
+ label.textAlignment = textAlignment(style["textAlign"])
629
+ var attributes: [NSAttributedString.Key: Any] = [:]
630
+ if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
631
+ if let lineHeight = number(style["lineHeight"]) {
632
+ let paragraph = NSMutableParagraphStyle()
633
+ paragraph.minimumLineHeight = lineHeight
634
+ paragraph.maximumLineHeight = lineHeight
635
+ paragraph.alignment = label.textAlignment
636
+ attributes[.paragraphStyle] = paragraph
637
+ }
638
+ switch style["textDecorationLine"] as? String {
639
+ case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
640
+ case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
641
+ case "underline line-through":
642
+ attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
643
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
644
+ default: break
645
+ }
646
+ label.attributedText = attributes.isEmpty ? nil : NSAttributedString(string: transformed, attributes: attributes)
647
+ if attributes.isEmpty { label.text = transformed }
648
+ }
649
+
650
+ private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
651
+ let size = number(style["fontSize"]) ?? fallback.pointSize
652
+ let weight: UIFont.Weight
653
+ switch style["fontWeight"] as? String {
654
+ case "100": weight = .ultraLight
655
+ case "200": weight = .thin
656
+ case "300": weight = .light
657
+ case "500": weight = .medium
658
+ case "600": weight = .semibold
659
+ case "bold", "700": weight = .bold
660
+ case "800": weight = .heavy
661
+ case "900": weight = .black
662
+ default: weight = .regular
663
+ }
664
+ var font = (style["fontFamily"] as? String).flatMap { UIFont(name: $0, size: size) }
665
+ ?? .systemFont(ofSize: size, weight: weight)
666
+ if style["fontStyle"] as? String == "italic",
667
+ let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
668
+ font = UIFont(descriptor: descriptor, size: size)
669
+ }
670
+ return font
671
+ }
672
+
673
+ private func textAlignment(_ value: Any?) -> NSTextAlignment {
674
+ switch value as? String {
675
+ case "left": return .left
676
+ case "right": return .right
677
+ case "center": return .center
678
+ case "justify": return .justified
679
+ default: return .natural
680
+ }
681
+ }
682
+
683
+ private func imageContentMode(_ value: Any?) -> UIView.ContentMode {
684
+ switch value as? String {
685
+ case "cover": return .scaleAspectFill
686
+ case "stretch": return .scaleToFill
687
+ case "center": return .center
688
+ default: return .scaleAspectFit
689
+ }
690
+ }
691
+
692
+ private func updateImage(_ view: UIImageView, source: Any?) {
693
+ let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
694
+ let id = ObjectIdentifier(view)
695
+ guard let uri, !uri.isEmpty else {
696
+ imageTasks.removeValue(forKey: id)?.cancel()
697
+ imageSources.removeValue(forKey: id)
698
+ imageFailure(view, uri: nil, message: "Image source is missing")
699
+ return
700
+ }
701
+ guard imageSources[id] != uri else { return }
702
+ imageTasks.removeValue(forKey: id)?.cancel()
703
+ imageSources[id] = uri
704
+ view.image = nil
705
+ view.accessibilityValue = nil
706
+
707
+ if uri.hasPrefix("data:image/") {
708
+ guard let comma = uri.firstIndex(of: ","),
709
+ let data = Data(base64Encoded: String(uri[uri.index(after: comma)...])),
710
+ let image = UIImage(data: data) else {
711
+ imageFailure(view, uri: uri, message: "Image data is invalid")
712
+ return
713
+ }
714
+ view.image = image
715
+ return
716
+ }
717
+ if let url = URL(string: uri), url.scheme == "https" {
718
+ let task = URLSession.shared.dataTask(with: url) { [weak self, weak view] data, _, _ in
719
+ guard let self, let view else { return }
720
+ DispatchQueue.main.async {
721
+ guard self.imageSources[ObjectIdentifier(view)] == uri else { return }
722
+ self.imageTasks.removeValue(forKey: ObjectIdentifier(view))
723
+ guard let data, let image = UIImage(data: data) else {
724
+ self.imageFailure(view, uri: uri, message: "Image download failed")
725
+ return
726
+ }
727
+ view.image = image
728
+ }
729
+ }
730
+ imageTasks[id] = task
731
+ task.resume()
732
+ return
733
+ }
734
+ if !uri.contains(":") {
735
+ guard let image = UIImage(named: uri) else {
736
+ imageFailure(view, uri: uri, message: "Bundled image was not found")
737
+ return
738
+ }
739
+ view.image = image
740
+ return
741
+ }
742
+ imageFailure(view, uri: uri, message: "Unsupported image source")
743
+ }
744
+
745
+ private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
746
+ view.image = nil
747
+ view.accessibilityValue = message
748
+ NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
749
+ }
750
+
751
+ private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
752
+ view.accessibilityLabel = props["accessibilityLabel"] as? String
753
+ view.accessibilityHint = props["accessibilityHint"] as? String
754
+ let role = props["accessibilityRole"] as? String
755
+ view.isAccessibilityElement = view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Image", "TextInput"].contains(type)
756
+ switch role ?? type.lowercased() {
757
+ case "button": view.accessibilityTraits = .button
758
+ case "image": view.accessibilityTraits = .image
759
+ case "header": view.accessibilityTraits = .header
760
+ case "link": view.accessibilityTraits = .link
761
+ case "search": view.accessibilityTraits = .searchField
762
+ default: view.accessibilityTraits = []
763
+ }
764
+ }
765
+
342
766
  private func updateDimension(_ value: CGFloat?, constraint: inout NSLayoutConstraint?, anchor: NSLayoutDimension) {
343
767
  if let value = value {
344
768
  if let constraint = constraint { constraint.constant = value }
@@ -362,6 +786,11 @@ final class CraftNativeScreenController: UIViewController {
362
786
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
363
787
  }
364
788
 
789
+ @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
790
+ guard let view = sender.view, let handler = handlers[ObjectIdentifier(view)] else { return }
791
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
792
+ }
793
+
365
794
  private func send(type: String, payload: [String: Any], correlationId: String? = nil) {
366
795
  var message: [String: Any] = ["type": type, "payload": payload]
367
796
  if let correlationId = correlationId { message["correlationId"] = correlationId }