craft-native 0.0.123 → 0.0.124

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.
@@ -5,6 +5,18 @@ import UIKit
5
5
 
6
6
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
7
7
 
8
+ private func craftNativeGridColumnCount(_ value: Any?) -> Int {
9
+ if let number = value as? NSNumber { return max(1, number.intValue) }
10
+ guard let text = value as? String else { return 1 }
11
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
12
+ if trimmed.hasPrefix("repeat(") {
13
+ let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
14
+ if let count = Int(digits) { return max(1, count) }
15
+ }
16
+ let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
17
+ return max(1, tracks.count)
18
+ }
19
+
8
20
  /// A WebView-free host for the first stx-native vertical slice. The bundled
9
21
  /// JavaScript sends whole, compiled view trees to UIKit and receives control
10
22
  /// events and Craft API replies through JavaScriptCore.
@@ -31,6 +43,16 @@ private struct CraftNativeLayoutStyle {
31
43
  let bottom: CGFloat?
32
44
  let left: CGFloat?
33
45
  let alignSelf: String?
46
+ let flexGrow: CGFloat
47
+ let flexShrink: CGFloat
48
+ let flexBasis: CGFloat?
49
+ let marginTop: CGFloat
50
+ let marginRight: CGFloat
51
+ let marginBottom: CGFloat
52
+ let marginLeft: CGFloat
53
+ let display: String
54
+ let gridColumns: Int
55
+ let gridAutoRows: CGFloat?
34
56
 
35
57
  init(_ raw: [String: Any]) {
36
58
  func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
@@ -46,6 +68,19 @@ private struct CraftNativeLayoutStyle {
46
68
  bottom = number(raw["bottom"])
47
69
  left = number(raw["left"])
48
70
  alignSelf = raw["alignSelf"] as? String
71
+ flexGrow = number(raw["flexGrow"] ?? raw["flex"]) ?? 0
72
+ flexShrink = number(raw["flexShrink"]) ?? 0
73
+ flexBasis = number(raw["flexBasis"])
74
+ let margin = number(raw["margin"]) ?? 0
75
+ let horizontalMargin = number(raw["marginHorizontal"]) ?? margin
76
+ let verticalMargin = number(raw["marginVertical"]) ?? margin
77
+ marginTop = number(raw["marginTop"]) ?? verticalMargin
78
+ marginRight = number(raw["marginRight"]) ?? horizontalMargin
79
+ marginBottom = number(raw["marginBottom"]) ?? verticalMargin
80
+ marginLeft = number(raw["marginLeft"]) ?? horizontalMargin
81
+ display = raw["display"] as? String ?? "flex"
82
+ gridColumns = craftNativeGridColumnCount(raw["gridTemplateColumns"] ?? raw["gridColumns"])
83
+ gridAutoRows = number(raw["gridAutoRows"])
49
84
  }
50
85
  }
51
86
 
@@ -56,6 +91,9 @@ private final class CraftNativeFlowView: UIStackView {
56
91
  var gap: CGFloat = 0 { didSet { spacing = gap; invalidateIntrinsicContentSize(); setNeedsLayout() } }
57
92
  var rowGap: CGFloat? { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
58
93
  var columnGap: CGFloat? { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
94
+ var grid = false { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
95
+ var gridColumns = 1 { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
96
+ var gridAutoRows: CGFloat? { didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } }
59
97
  var padding = UIEdgeInsets.zero {
60
98
  didSet {
61
99
  layoutMargins = padding
@@ -112,11 +150,20 @@ private final class CraftNativeFlowView: UIStackView {
112
150
  }
113
151
 
114
152
  override var intrinsicContentSize: CGSize {
115
- let children = subviews.filter { !$0.isHidden && !($0 is CraftNativeFlexSpacer) }
153
+ let children = subviews.filter {
154
+ !$0.isHidden && !($0 is CraftNativeFlexSpacer)
155
+ && (childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:])).position != "absolute"
156
+ }
116
157
  guard !children.isEmpty else { return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) }
158
+ if grid { return gridIntrinsicContentSize(children) }
117
159
  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
160
+ let styles = children.map { childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:]) }
161
+ let main = sizes.enumerated().map { index, size in
162
+ (axis == .horizontal ? size.width : size.height) + mainMargins(styles[index])
163
+ }.reduce(0, +)
164
+ let cross = sizes.enumerated().map { index, size in
165
+ (axis == .horizontal ? size.height : size.width) + crossMargins(styles[index])
166
+ }.max() ?? 0
120
167
  let mainGap = max(0, CGFloat(max(0, children.count - 1))) * mainGapValue
121
168
  if axis == .horizontal {
122
169
  return CGSize(width: padding.left + padding.right + main + mainGap, height: padding.top + padding.bottom + cross)
@@ -127,6 +174,10 @@ private final class CraftNativeFlowView: UIStackView {
127
174
  override func layoutSubviews() {
128
175
  super.layoutSubviews()
129
176
  let content = bounds.inset(by: padding)
177
+ if grid {
178
+ layoutGrid(content: content)
179
+ return
180
+ }
130
181
  let availableMain = axis == .horizontal ? content.width : content.height
131
182
  var lines: [([UIView], [CGSize], CGFloat, CGFloat)] = []
132
183
  var items: [UIView] = []
@@ -144,8 +195,8 @@ private final class CraftNativeFlowView: UIStackView {
144
195
  let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
145
196
  if style.position == "absolute" { continue }
146
197
  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
198
+ let childMain = (axis == .horizontal ? size.width : size.height) + mainMargins(style)
199
+ let childCross = (axis == .horizontal ? size.height : size.width) + crossMargins(style)
149
200
  let next = items.isEmpty ? childMain : main + mainGapValue + childMain
150
201
  if wrap && !items.isEmpty && next > availableMain { flush() }
151
202
  items.append(child); sizes.append(size)
@@ -156,29 +207,39 @@ private final class CraftNativeFlowView: UIStackView {
156
207
 
157
208
  var crossOffset: CGFloat = 0
158
209
  for (lineItems, lineSizes, lineMain, lineCross) in lines {
159
- let free = max(0, availableMain - lineMain)
210
+ let styles = lineItems.map { childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:]) }
211
+ let distributed = distributeMainAxisSizes(lineSizes, styles: styles, available: availableMain, lineMain: lineMain)
212
+ let distributedMain = distributed.enumerated().reduce(0) { total, entry in
213
+ total + (axis == .horizontal ? entry.element.width : entry.element.height) + mainMargins(styles[entry.offset])
214
+ } + CGFloat(max(0, lineItems.count - 1)) * mainGapValue
215
+ let free = max(0, availableMain - distributedMain)
160
216
  let (leading, between) = distribution(free: free, count: lineItems.count)
161
217
  var mainOffset = leading
162
218
  for (index, child) in lineItems.enumerated() {
163
219
  let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
164
- let size = lineSizes[index]
220
+ let size = distributed[index]
165
221
  let childCross = axis == .horizontal ? size.height : size.width
222
+ let mainLeading = axis == .horizontal ? style.marginLeft : style.marginTop
223
+ let mainTrailing = axis == .horizontal ? style.marginRight : style.marginBottom
224
+ let crossLeading = axis == .horizontal ? style.marginTop : style.marginLeft
225
+ let crossTrailing = axis == .horizontal ? style.marginBottom : style.marginRight
166
226
  let alignment = style.alignSelf ?? alignItems
167
- let crossSize: CGFloat = alignment == "stretch" && styleCrossDimension(style) == nil ? lineCross : childCross
227
+ let crossSize: CGFloat = alignment == "stretch" && styleCrossDimension(style) == nil
228
+ ? max(0, lineCross - crossLeading - crossTrailing) : childCross
168
229
  let crossPosition: CGFloat
169
230
  switch alignment {
170
- case "center": crossPosition = (lineCross - crossSize) / 2
171
- case "flex-end": crossPosition = lineCross - crossSize
172
- default: crossPosition = 0
231
+ case "center": crossPosition = crossLeading + (lineCross - crossLeading - crossTrailing - crossSize) / 2
232
+ case "flex-end": crossPosition = lineCross - crossTrailing - crossSize
233
+ default: crossPosition = crossLeading
173
234
  }
174
235
  let frame: CGRect
175
236
  if axis == .horizontal {
176
- frame = CGRect(x: content.minX + mainOffset, y: content.minY + crossOffset + crossPosition, width: size.width, height: crossSize)
237
+ frame = CGRect(x: content.minX + mainOffset + mainLeading, y: content.minY + crossOffset + crossPosition, width: size.width, height: crossSize)
177
238
  } else {
178
- frame = CGRect(x: content.minX + crossOffset + crossPosition, y: content.minY + mainOffset, width: crossSize, height: size.height)
239
+ frame = CGRect(x: content.minX + crossOffset + crossPosition, y: content.minY + mainOffset + mainLeading, width: crossSize, height: size.height)
179
240
  }
180
241
  child.frame = frame.integral
181
- mainOffset += (axis == .horizontal ? size.width : size.height) + mainGapValue + between
242
+ mainOffset += mainLeading + (axis == .horizontal ? size.width : size.height) + mainTrailing + mainGapValue + between
182
243
  }
183
244
  crossOffset += lineCross + crossGapValue
184
245
  }
@@ -195,9 +256,93 @@ private final class CraftNativeFlowView: UIStackView {
195
256
  }
196
257
  }
197
258
 
259
+ private func gridIntrinsicContentSize(_ children: [UIView]) -> CGSize {
260
+ let columns = max(1, gridColumns)
261
+ let horizontalGap = columnGap ?? gap
262
+ let verticalGap = rowGap ?? gap
263
+ var columnWidth: CGFloat = 0
264
+ var rowHeights: [CGFloat] = []
265
+ let flowChildren = children.filter {
266
+ (childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:])).position != "absolute"
267
+ }
268
+ for (index, child) in flowChildren.enumerated() {
269
+ let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
270
+ let size = measuredSize(for: child, available: CGSize(width: 10_000, height: 10_000), style: style)
271
+ let row = index / columns
272
+ while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
273
+ columnWidth = max(columnWidth, size.width + style.marginLeft + style.marginRight)
274
+ rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
275
+ }
276
+ let width = padding.left + padding.right + CGFloat(columns) * columnWidth + CGFloat(max(0, columns - 1)) * horizontalGap
277
+ let height = padding.top + padding.bottom + rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
278
+ return CGSize(width: width, height: height)
279
+ }
280
+
281
+ private func layoutGrid(content: CGRect) {
282
+ let children = subviews.filter { !$0.isHidden && !($0 is CraftNativeFlexSpacer) }
283
+ let flowChildren = children.filter {
284
+ (childStyles[ObjectIdentifier($0)] ?? CraftNativeLayoutStyle([:])).position != "absolute"
285
+ }
286
+ let columns = max(1, gridColumns)
287
+ let horizontalGap = columnGap ?? gap
288
+ let verticalGap = rowGap ?? gap
289
+ let cellWidth = max(0, (content.width - CGFloat(max(0, columns - 1)) * horizontalGap) / CGFloat(columns))
290
+ var rowHeights: [CGFloat] = []
291
+ var sizes: [CGSize] = []
292
+ for (index, child) in flowChildren.enumerated() {
293
+ let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
294
+ let size = measuredSize(
295
+ for: child,
296
+ available: CGSize(width: max(0, cellWidth - style.marginLeft - style.marginRight), height: content.height),
297
+ style: style
298
+ )
299
+ sizes.append(size)
300
+ let row = index / columns
301
+ while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
302
+ rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
303
+ }
304
+ var rowTop = content.minY
305
+ for (index, child) in flowChildren.enumerated() {
306
+ let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
307
+ let row = index / columns
308
+ let column = index % columns
309
+ let rowHeight = rowHeights[row]
310
+ let size = sizes[index]
311
+ let align = style.alignSelf ?? alignItems
312
+ let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
313
+ let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
314
+ let width = align == "stretch" && style.width == nil ? availableWidth : min(availableWidth, size.width)
315
+ let height = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
316
+ let x = content.minX + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
317
+ + (align == "center" ? (availableWidth - width) / 2 : align == "flex-end" ? availableWidth - width : 0)
318
+ let y = rowTop + style.marginTop
319
+ + (align == "center" ? (availableHeight - height) / 2 : align == "flex-end" ? availableHeight - height : 0)
320
+ child.frame = CGRect(x: x, y: y, width: width, height: height).integral
321
+ if column == columns - 1 || index == flowChildren.count - 1 { rowTop += rowHeight + verticalGap }
322
+ }
323
+ for child in children {
324
+ let style = childStyles[ObjectIdentifier(child)] ?? CraftNativeLayoutStyle([:])
325
+ guard style.position == "absolute" else { continue }
326
+ let size = measuredSize(for: child, available: content.size, style: style)
327
+ let width = style.left != nil && style.right != nil ? max(0, content.width - (style.left ?? 0) - (style.right ?? 0)) : size.width
328
+ let height = style.top != nil && style.bottom != nil ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0)) : size.height
329
+ let x = style.left ?? (style.right.map { content.width - $0 - width } ?? 0)
330
+ let y = style.top ?? (style.bottom.map { content.height - $0 - height } ?? 0)
331
+ child.frame = CGRect(x: content.minX + x, y: content.minY + y, width: width, height: height).integral
332
+ }
333
+ }
334
+
198
335
  private var mainGapValue: CGFloat { axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap) }
199
336
  private var crossGapValue: CGFloat { axis == .horizontal ? (rowGap ?? 0) : (columnGap ?? 0) }
200
337
 
338
+ private func mainMargins(_ style: CraftNativeLayoutStyle) -> CGFloat {
339
+ axis == .horizontal ? style.marginLeft + style.marginRight : style.marginTop + style.marginBottom
340
+ }
341
+
342
+ private func crossMargins(_ style: CraftNativeLayoutStyle) -> CGFloat {
343
+ axis == .horizontal ? style.marginTop + style.marginBottom : style.marginLeft + style.marginRight
344
+ }
345
+
201
346
  private func styleCrossDimension(_ style: CraftNativeLayoutStyle) -> CGFloat? {
202
347
  axis == .horizontal ? style.height : style.width
203
348
  }
@@ -206,11 +351,31 @@ private final class CraftNativeFlowView: UIStackView {
206
351
  let style = style ?? childStyles[ObjectIdentifier(view)] ?? CraftNativeLayoutStyle([:])
207
352
  let intrinsic = view.intrinsicContentSize
208
353
  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))
354
+ let width = (axis == .horizontal ? style.flexBasis : nil) ?? style.width ?? (intrinsic.width > 0 && intrinsic.width != UIView.noIntrinsicMetric ? intrinsic.width : max(0, fitted.width))
355
+ let height = (axis == .vertical ? style.flexBasis : nil) ?? style.height ?? (intrinsic.height > 0 && intrinsic.height != UIView.noIntrinsicMetric ? intrinsic.height : max(0, fitted.height))
211
356
  return CGSize(width: clamp(width, min: style.minWidth, max: style.maxWidth), height: clamp(height, min: style.minHeight, max: style.maxHeight))
212
357
  }
213
358
 
359
+ private func distributeMainAxisSizes(
360
+ _ sizes: [CGSize],
361
+ styles: [CraftNativeLayoutStyle],
362
+ available: CGFloat,
363
+ lineMain: CGFloat
364
+ ) -> [CGSize] {
365
+ let free = available - lineMain
366
+ let factors = free >= 0 ? styles.map(\.flexGrow) : styles.map(\.flexShrink)
367
+ let total = factors.reduce(0, +)
368
+ guard total > 0, free != 0 else { return sizes }
369
+ return sizes.enumerated().map { index, size in
370
+ let delta = free * factors[index] / total
371
+ let style = styles[index]
372
+ if axis == .horizontal {
373
+ return CGSize(width: clamp(max(0, size.width + delta), min: style.minWidth, max: style.maxWidth), height: size.height)
374
+ }
375
+ return CGSize(width: size.width, height: clamp(max(0, size.height + delta), min: style.minHeight, max: style.maxHeight))
376
+ }
377
+ }
378
+
214
379
  private func clamp(_ value: CGFloat, min lower: CGFloat?, max upper: CGFloat?) -> CGFloat {
215
380
  var result = value
216
381
  if let lower { result = max(result, lower) }
@@ -264,7 +429,60 @@ private final class CraftNativeScrollView: UIScrollView {
264
429
 
265
430
  private final class CraftNativeFlexSpacer: UIView {}
266
431
 
267
- final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate {
432
+ private final class CraftNativeTextView: UITextView {
433
+ private let placeholderLabel = UILabel()
434
+ var placeholder: String? {
435
+ didSet { refreshPlaceholder() }
436
+ }
437
+ var placeholderColor: UIColor? {
438
+ didSet { placeholderLabel.textColor = placeholderColor ?? .placeholderText }
439
+ }
440
+ var placeholderFont: UIFont? {
441
+ didSet { placeholderLabel.font = placeholderFont ?? .systemFont(ofSize: 16) }
442
+ }
443
+
444
+ override init(frame: CGRect, textContainer: NSTextContainer?) {
445
+ super.init(frame: frame, textContainer: textContainer)
446
+ configurePlaceholder()
447
+ }
448
+
449
+ convenience init() {
450
+ self.init(frame: .zero, textContainer: nil)
451
+ }
452
+
453
+ required init?(coder: NSCoder) {
454
+ super.init(coder: coder)
455
+ configurePlaceholder()
456
+ }
457
+
458
+ override func layoutSubviews() {
459
+ super.layoutSubviews()
460
+ let inset = textContainerInset
461
+ let padding = textContainer.lineFragmentPadding
462
+ placeholderLabel.frame = CGRect(
463
+ x: inset.left + padding,
464
+ y: inset.top,
465
+ width: max(0, bounds.width - inset.left - inset.right - padding * 2),
466
+ height: max(0, bounds.height - inset.top - inset.bottom)
467
+ )
468
+ }
469
+
470
+ func refreshPlaceholder() {
471
+ placeholderLabel.text = placeholder
472
+ placeholderLabel.isHidden = !(text?.isEmpty ?? true) || placeholder?.isEmpty != false
473
+ }
474
+
475
+ private func configurePlaceholder() {
476
+ placeholderLabel.numberOfLines = 0
477
+ placeholderLabel.font = font
478
+ placeholderLabel.textColor = .placeholderText
479
+ placeholderLabel.isUserInteractionEnabled = false
480
+ addSubview(placeholderLabel)
481
+ refreshPlaceholder()
482
+ }
483
+ }
484
+
485
+ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate {
268
486
  private final class RenderedNode {
269
487
  let identity: String
270
488
  let type: String
@@ -290,15 +508,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
290
508
  private var handlers: [ObjectIdentifier: String] = [:]
291
509
  private var focusHandlers: [ObjectIdentifier: String] = [:]
292
510
  private var blurHandlers: [ObjectIdentifier: String] = [:]
511
+ private var endEditingHandlers: [ObjectIdentifier: String] = [:]
293
512
  private var submitHandlers: [ObjectIdentifier: String] = [:]
513
+ private var inputIdentities: [ObjectIdentifier: String] = [:]
514
+ private var inputDrafts: [String: String] = [:]
515
+ private var longPressHandlers: [ObjectIdentifier: String] = [:]
516
+ private var textMaxLengths: [ObjectIdentifier: Int] = [:]
294
517
  private var scrollHandlers: [ObjectIdentifier: String] = [:]
295
518
  private var scrollBeginHandlers: [ObjectIdentifier: String] = [:]
296
519
  private var scrollEndHandlers: [ObjectIdentifier: String] = [:]
520
+ private var scrollMomentumBeginHandlers: [ObjectIdentifier: String] = [:]
521
+ private var scrollMomentumEndHandlers: [ObjectIdentifier: String] = [:]
522
+ private var sliderCompleteHandlers: [ObjectIdentifier: String] = [:]
523
+ private var sliderSteps: [ObjectIdentifier: Float] = [:]
524
+ private var layoutHandlers: [ObjectIdentifier: String] = [:]
525
+ private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
297
526
  private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
527
+ private var longPressRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
298
528
  private var imageSources: [ObjectIdentifier: String] = [:]
299
529
  private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
530
+ private var imageErrors: [ObjectIdentifier: String] = [:]
531
+ private var tintedImages = Set<ObjectIdentifier>()
532
+ private var imageLoadStartHandlers: [ObjectIdentifier: String] = [:]
533
+ private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
534
+ private var imageLoadEndHandlers: [ObjectIdentifier: String] = [:]
535
+ private var imageErrorHandlers: [ObjectIdentifier: String] = [:]
300
536
  private var renderedRoot: RenderedNode?
301
- private weak var lastFocusedInput: UITextField?
537
+ private weak var lastFocusedInput: UIView?
302
538
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
303
539
  private var flatListOwners: [String: String] = [:]
304
540
  private let mutationDocument = CraftNativeMutationDocument()
@@ -346,6 +582,38 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
346
582
  observeNativeEvents()
347
583
  }
348
584
 
585
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
586
+ super.traitCollectionDidChange(previousTraitCollection)
587
+ guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
588
+ refreshTraitDefaults()
589
+ }
590
+
591
+ override func viewWillAppear(_ animated: Bool) {
592
+ super.viewWillAppear(animated)
593
+ refreshTraitDefaults()
594
+ }
595
+
596
+ private func refreshTraitDefaults() {
597
+ view.backgroundColor = config.resolvedBackgroundColor
598
+ if let document = mutationDocument.node("root") {
599
+ renderCommitted(document)
600
+ } else {
601
+ rootStack.setNeedsLayout()
602
+ }
603
+ refreshTraitDefaults(in: renderedRoot)
604
+ }
605
+
606
+ private func refreshTraitDefaults(in node: RenderedNode?) {
607
+ guard let node else { return }
608
+ (node.view as? CraftNativeFlatList)?.refreshThemeDefaults()
609
+ node.children.forEach { refreshTraitDefaults(in: $0) }
610
+ }
611
+
612
+ override func viewDidLayoutSubviews() {
613
+ super.viewDidLayoutSubviews()
614
+ emitLayoutEvents()
615
+ }
616
+
349
617
  private func setupJavaScript() {
350
618
  if let routeName = routeName {
351
619
  jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
@@ -610,10 +878,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
610
878
  let events = node["events"] as? [String: String] ?? [:]
611
879
  let children = node["children"] as? [Any] ?? []
612
880
  let current: RenderedNode
613
- if let previous = previous, previous.identity == identity, previous.type == type {
881
+ let wantsMultilineTextView = type == "TextInput" && props["multiline"] as? Bool == true
882
+ let canReuseInput = type != "TextInput" || ((previous?.view is UITextView) == wantsMultilineTextView)
883
+ if let previous = previous, previous.identity == identity, previous.type == type, canReuseInput {
614
884
  current = previous
615
885
  } else {
616
- current = RenderedNode(identity: identity, type: type, view: makeView(type))
886
+ current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
617
887
  }
618
888
 
619
889
  let result = current.view
@@ -624,26 +894,68 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
624
894
  result.setContentHuggingPriority(.required, for: .vertical)
625
895
  }
626
896
  applyViewStyle(style, to: result, node: current)
897
+ updateLayoutHandler(events["onLayout"], for: result)
627
898
 
628
899
  switch type {
629
900
  case "Text":
630
901
  let label = result as! UILabel
902
+ label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
903
+ label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
631
904
  configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
632
- case "Button":
905
+ case "Button", "Link":
633
906
  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(
907
+ let title = props["title"] as? String ?? children.compactMap { $0 as? String }.joined()
908
+ let transformedTitle = transformedText(title, style: style)
909
+ let titleColor = color(props["color"]) ?? color(style["color"]) ?? .systemBlue
910
+ let font = textFont(
637
911
  style,
638
- default: button.titleLabel?.font ?? .systemFont(ofSize: UIFont.buttonFontSize)
912
+ default: .systemFont(ofSize: 16)
639
913
  )
640
- updateHandler(events["onPress"] ?? events["onClick"], for: button)
914
+ button.setAttributedTitle(NSAttributedString(
915
+ string: transformedTitle,
916
+ attributes: buttonTitleAttributes(style, color: titleColor, font: font, forceUnderline: type == "Link")
917
+ ), for: .normal)
918
+ button.contentHorizontalAlignment = buttonAlignment(style["textAlign"])
919
+ button.isEnabled = props["disabled"] as? Bool != true
920
+ updateHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: button)
641
921
  case "TextInput":
922
+ if let textView = result as? UITextView {
923
+ inputIdentities[ObjectIdentifier(textView)] = current.identity
924
+ configureTextView(
925
+ textView,
926
+ props: props,
927
+ style: style,
928
+ events: events,
929
+ defaultValue: previous?.view === textView
930
+ ? nil
931
+ : inputDrafts[current.identity] ?? props["defaultValue"] as? String
932
+ )
933
+ break
934
+ }
642
935
  let field = result as! UITextField
643
936
  field.placeholder = props["placeholder"] as? String
937
+ if let placeholder = field.placeholder,
938
+ let placeholderColor = color(props["placeholderTextColor"]) {
939
+ field.attributedPlaceholder = NSAttributedString(
940
+ string: placeholder,
941
+ attributes: [.foregroundColor: placeholderColor]
942
+ )
943
+ } else {
944
+ field.attributedPlaceholder = nil
945
+ }
946
+ field.tintColor = color(props["selectionColor"])
947
+ let fieldId = ObjectIdentifier(field)
948
+ inputIdentities[fieldId] = current.identity
949
+ if let maxLength = (props["maxLength"] as? NSNumber)?.intValue, maxLength > 0 {
950
+ textMaxLengths[fieldId] = maxLength
951
+ } else {
952
+ textMaxLengths.removeValue(forKey: fieldId)
953
+ }
954
+ field.delegate = self
644
955
  field.textColor = color(style["color"]) ?? .label
645
- field.font = textFont(style, default: field.font ?? .systemFont(ofSize: UIFont.systemFontSize))
956
+ field.font = textFont(style, default: .systemFont(ofSize: 16))
646
957
  field.textAlignment = textAlignment(style["textAlign"])
958
+ field.defaultTextAttributes = inputTextAttributes(style, font: field.font ?? .systemFont(ofSize: 16), alignment: field.textAlignment)
647
959
  let desiredKeyboardType = keyboardType(props["keyboardType"])
648
960
  if field.keyboardType != desiredKeyboardType { field.keyboardType = desiredKeyboardType }
649
961
  let desiredReturnKeyType = returnKeyType(props["returnKeyType"])
@@ -656,26 +968,99 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
656
968
  if field.isSecureTextEntry != desiredSecureEntry { field.isSecureTextEntry = desiredSecureEntry }
657
969
  let desiredEnabled = props["editable"] as? Bool != false
658
970
  if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
659
- updateField(field, value: props["value"] as? String)
660
- updateHandler(events["onChange"] ?? events["onChangeText"], for: field)
971
+ if let value = props["value"] as? String {
972
+ updateField(field, value: value)
973
+ } else if previous?.view !== field {
974
+ updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
975
+ }
976
+ updateHandler(nonEmptyHandler(events["onChange"]) ?? nonEmptyHandler(events["onChangeText"]), for: field)
661
977
  updateAuxiliaryHandler(events["onFocus"], in: &focusHandlers, for: field)
662
- updateAuxiliaryHandler(events["onBlur"] ?? events["onEndEditing"], in: &blurHandlers, for: field)
978
+ updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: field)
979
+ updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
663
980
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
664
981
  if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
665
982
  DispatchQueue.main.async { _ = field.becomeFirstResponder() }
666
983
  }
984
+ case "Switch":
985
+ let toggle = result as! UISwitch
986
+ toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
987
+ toggle.isEnabled = props["disabled"] as? Bool != true
988
+ if let track = props["trackColor"] as? [String: Any] {
989
+ toggle.onTintColor = color(track["true"])
990
+ toggle.tintColor = color(track["false"])
991
+ } else {
992
+ toggle.onTintColor = nil
993
+ toggle.tintColor = nil
994
+ }
995
+ toggle.thumbTintColor = color(props["thumbColor"])
996
+ if let background = color(props["ios_backgroundColor"]) { toggle.tintColor = background }
997
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)
998
+ case "Slider":
999
+ let slider = result as! UISlider
1000
+ slider.minimumValue = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
1001
+ slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, (props["maximumValue"] as? NSNumber)?.floatValue ?? 1)
1002
+ let value = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
1003
+ if let step = (props["step"] as? NSNumber)?.floatValue, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
1004
+ else { sliderSteps.removeValue(forKey: ObjectIdentifier(slider)) }
1005
+ slider.value = snappedSliderValue(value, for: slider)
1006
+ slider.minimumTrackTintColor = color(props["minimumTrackTintColor"])
1007
+ slider.maximumTrackTintColor = color(props["maximumTrackTintColor"])
1008
+ slider.thumbTintColor = color(props["thumbTintColor"])
1009
+ slider.isEnabled = props["disabled"] as? Bool != true
1010
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: slider)
1011
+ updateAuxiliaryHandler(events["onSlidingComplete"], in: &sliderCompleteHandlers, for: slider)
1012
+ case "ActivityIndicator":
1013
+ let indicator = result as! UIActivityIndicatorView
1014
+ let requestedSize = props["size"]
1015
+ indicator.style = (requestedSize as? String) == "large" ? .large : .medium
1016
+ let scale: CGFloat
1017
+ if let numericSize = requestedSize as? NSNumber {
1018
+ scale = max(0.5, CGFloat(numericSize.doubleValue / 20.0))
1019
+ } else if (requestedSize as? String) == "small" {
1020
+ scale = 0.75
1021
+ } else {
1022
+ scale = 1
1023
+ }
1024
+ indicator.transform = CGAffineTransform(scaleX: scale, y: scale)
1025
+ indicator.color = color(props["color"]) ?? .tintColor
1026
+ indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
1027
+ if props["animating"] as? Bool == false {
1028
+ indicator.stopAnimating()
1029
+ } else {
1030
+ indicator.startAnimating()
1031
+ }
667
1032
  case "Image":
668
1033
  let image = result as! UIImageView
669
- image.contentMode = imageContentMode(style["resizeMode"] ?? props["resizeMode"])
1034
+ let imageID = ObjectIdentifier(image)
1035
+ if let tint = color(style["tintColor"]) {
1036
+ tintedImages.insert(imageID)
1037
+ image.tintColor = tint
1038
+ } else {
1039
+ tintedImages.remove(imageID)
1040
+ image.tintColor = nil
1041
+ }
1042
+ if let current = image.image { image.image = imageForDisplay(current, view: image) }
1043
+ let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
1044
+ image.contentMode = imageContentMode(requestedResizeMode)
670
1045
  image.clipsToBounds = image.contentMode == .scaleAspectFill
1046
+ updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
1047
+ updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
1048
+ updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
1049
+ updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
671
1050
  updateImage(image, source: props["source"])
672
1051
  case "ScrollView":
673
1052
  let scroll = result as! CraftNativeScrollView
674
- let direction = (props["horizontal"] as? Bool) == true || style["flexDirection"] as? String == "row"
1053
+ let flexDirection = style["flexDirection"] as? String
1054
+ let direction = (props["horizontal"] as? Bool) == true
1055
+ || flexDirection == "row"
1056
+ || flexDirection == "row-reverse"
675
1057
  ? NSLayoutConstraint.Axis.horizontal : .vertical
676
1058
  scroll.setAxis(direction)
677
1059
  scroll.delegate = self
678
1060
  scroll.isScrollEnabled = props["scrollEnabled"] as? Bool != false
1061
+ scroll.bounces = props["bounces"] as? Bool ?? true
1062
+ scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
1063
+ scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
679
1064
  scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
680
1065
  scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
681
1066
  scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
@@ -683,6 +1068,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
683
1068
  updateAuxiliaryHandler(events["onScroll"], in: &scrollHandlers, for: scroll)
684
1069
  updateAuxiliaryHandler(events["onScrollBeginDrag"], in: &scrollBeginHandlers, for: scroll)
685
1070
  updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
1071
+ updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
1072
+ updateAuxiliaryHandler(events["onMomentumScrollEnd"], in: &scrollMomentumEndHandlers, for: scroll)
686
1073
  configureStack(scroll.contentStack, style: style)
687
1074
  reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: style)
688
1075
  case "FlatList":
@@ -700,24 +1087,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
700
1087
  configureStack(stack, style: style)
701
1088
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
702
1089
  }
703
- if type != "Button" && type != "TextInput" {
704
- updatePressHandler(events["onPress"] ?? events["onClick"], for: result)
1090
+ updateLongPressHandler(events["onLongPress"], for: result)
1091
+ if type != "Button" && type != "Link" && type != "TextInput" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
1092
+ updatePressHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
705
1093
  }
706
1094
  applyAccessibility(props, type: type, to: result)
707
1095
  return current
708
1096
  }
709
1097
 
710
- private func makeView(_ type: String) -> UIView {
1098
+ private func makeView(_ type: String, props: [String: Any]) -> UIView {
711
1099
  switch type {
712
1100
  case "Text":
713
1101
  let label = UILabel()
714
1102
  label.numberOfLines = 0
715
1103
  return label
716
- case "Button":
1104
+ case "Button", "Link":
717
1105
  let button = UIButton(type: .system)
718
1106
  button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
719
1107
  return button
720
1108
  case "TextInput":
1109
+ if props["multiline"] as? Bool == true {
1110
+ let textView = CraftNativeTextView()
1111
+ textView.delegate = self
1112
+ return textView
1113
+ }
721
1114
  let field = UITextField()
722
1115
  field.borderStyle = .roundedRect
723
1116
  field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
@@ -725,12 +1118,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
725
1118
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
726
1119
  field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
727
1120
  return field
1121
+ case "Switch":
1122
+ let toggle = UISwitch()
1123
+ toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
1124
+ return toggle
1125
+ case "Slider":
1126
+ let slider = UISlider()
1127
+ slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
1128
+ slider.addTarget(self, action: #selector(sliderFinished(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel])
1129
+ return slider
1130
+ case "ActivityIndicator":
1131
+ return UIActivityIndicatorView(style: .medium)
728
1132
  case "Image":
729
1133
  return UIImageView()
730
1134
  case "ScrollView":
731
1135
  return CraftNativeScrollView()
732
1136
  case "FlatList":
733
1137
  return CraftNativeFlatList()
1138
+ case "View", "SafeAreaView":
1139
+ return CraftNativeFlowView()
734
1140
  default:
735
1141
  return CraftNativeFlowView()
736
1142
  }
@@ -805,6 +1211,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
805
1211
  let owner = parent.protocolId ?? path
806
1212
  flatListOwners = flatListOwners.filter { $0.value != owner }
807
1213
  for child in children { registerFlatListOwnership(child, owner: owner) }
1214
+ list.bounces = props["bounces"] as? Bool ?? true
1215
+ list.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
1216
+ list.onScrollEvent = nonEmptyHandler(events["onScroll"]).map { handler in
1217
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
1218
+ }
1219
+ list.onScrollBeginDrag = nonEmptyHandler(events["onScrollBeginDrag"]).map { handler in
1220
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
1221
+ }
1222
+ list.onScrollEndDrag = nonEmptyHandler(events["onScrollEndDrag"]).map { handler in
1223
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
1224
+ }
1225
+ list.onMomentumScrollBegin = nonEmptyHandler(events["onMomentumScrollBegin"]).map { handler in
1226
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
1227
+ }
1228
+ list.onMomentumScrollEnd = nonEmptyHandler(events["onMomentumScrollEnd"]).map { handler in
1229
+ { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
1230
+ }
808
1231
 
809
1232
  list.apply(
810
1233
  nodes: children,
@@ -823,15 +1246,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
823
1246
  path: "\(path).\(identity)",
824
1247
  previous: previous
825
1248
  )
1249
+ if let previous, previous.view !== next.view {
1250
+ self.forgetHandlers(previous)
1251
+ }
826
1252
  self.flatListRows[listKey, default: [:]][identity] = next
827
1253
  return next.view
828
1254
  },
829
1255
  recycleItem: { [weak self] identity in
830
1256
  guard let self = self,
831
1257
  let row = self.flatListRows[listKey]?.removeValue(forKey: identity) else { return }
832
- self.forgetHandlers(row)
1258
+ self.forgetHandlers(row, preservingInputDrafts: true)
833
1259
  },
834
- endReached: events["onEndReached"].map { [weak self] handler in
1260
+ endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
835
1261
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
836
1262
  }
837
1263
  )
@@ -855,7 +1281,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
855
1281
  node.view.removeFromSuperview()
856
1282
  }
857
1283
 
858
- private func forgetHandlers(_ node: RenderedNode) {
1284
+ private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
859
1285
  if let list = node.view as? CraftNativeFlatList {
860
1286
  let listKey = ObjectIdentifier(list)
861
1287
  list.discardAll()
@@ -864,26 +1290,92 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
864
1290
  }
865
1291
  let id = ObjectIdentifier(node.view)
866
1292
  handlers.removeValue(forKey: id)
1293
+ layoutHandlers.removeValue(forKey: id)
1294
+ lastLayoutFrames.removeValue(forKey: id)
867
1295
  focusHandlers.removeValue(forKey: id)
868
1296
  blurHandlers.removeValue(forKey: id)
1297
+ endEditingHandlers.removeValue(forKey: id)
869
1298
  submitHandlers.removeValue(forKey: id)
1299
+ inputIdentities.removeValue(forKey: id)
1300
+ if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
1301
+ longPressHandlers.removeValue(forKey: id)
1302
+ textMaxLengths.removeValue(forKey: id)
870
1303
  scrollHandlers.removeValue(forKey: id)
871
1304
  scrollBeginHandlers.removeValue(forKey: id)
872
1305
  scrollEndHandlers.removeValue(forKey: id)
1306
+ scrollMomentumBeginHandlers.removeValue(forKey: id)
1307
+ scrollMomentumEndHandlers.removeValue(forKey: id)
1308
+ sliderCompleteHandlers.removeValue(forKey: id)
1309
+ sliderSteps.removeValue(forKey: id)
873
1310
  if let recognizer = tapRecognizers.removeValue(forKey: id) {
874
1311
  node.view.removeGestureRecognizer(recognizer)
875
1312
  }
1313
+ if let recognizer = longPressRecognizers.removeValue(forKey: id) {
1314
+ node.view.removeGestureRecognizer(recognizer)
1315
+ }
876
1316
  imageSources.removeValue(forKey: id)
877
1317
  imageTasks.removeValue(forKey: id)?.cancel()
878
- for child in node.children { forgetHandlers(child) }
1318
+ imageErrors.removeValue(forKey: id)
1319
+ tintedImages.remove(id)
1320
+ imageLoadStartHandlers.removeValue(forKey: id)
1321
+ imageLoadHandlers.removeValue(forKey: id)
1322
+ imageLoadEndHandlers.removeValue(forKey: id)
1323
+ imageErrorHandlers.removeValue(forKey: id)
1324
+ for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
879
1325
  }
880
1326
 
881
1327
  private func updateHandler(_ handler: String?, for view: UIView) {
882
1328
  let id = ObjectIdentifier(view)
883
- if let handler = handler { handlers[id] = handler }
1329
+ if let handler, !handler.isEmpty { handlers[id] = handler }
884
1330
  else { handlers.removeValue(forKey: id) }
885
1331
  }
886
1332
 
1333
+ private func snappedSliderValue(_ value: Float, for slider: UISlider) -> Float {
1334
+ let bounded = min(slider.maximumValue, max(slider.minimumValue, value))
1335
+ guard let step = sliderSteps[ObjectIdentifier(slider)], step > 0 else { return bounded }
1336
+ let steps = ((bounded - slider.minimumValue) / step).rounded()
1337
+ return min(slider.maximumValue, max(slider.minimumValue, slider.minimumValue + steps * step))
1338
+ }
1339
+
1340
+ private func updateLayoutHandler(_ handler: String?, for view: UIView) {
1341
+ let id = ObjectIdentifier(view)
1342
+ guard let handler, !handler.isEmpty else {
1343
+ layoutHandlers.removeValue(forKey: id)
1344
+ lastLayoutFrames.removeValue(forKey: id)
1345
+ return
1346
+ }
1347
+ if layoutHandlers[id] != handler { lastLayoutFrames.removeValue(forKey: id) }
1348
+ layoutHandlers[id] = handler
1349
+ }
1350
+
1351
+ private func emitLayoutEvents() {
1352
+ guard let renderedRoot else { return }
1353
+ emitLayoutEvents(for: renderedRoot)
1354
+ }
1355
+
1356
+ private func emitLayoutEvents(for node: RenderedNode) {
1357
+ let id = ObjectIdentifier(node.view)
1358
+ if let handler = layoutHandlers[id], node.view.window != nil {
1359
+ let frame = node.view.frame
1360
+ if lastLayoutFrames[id] != frame {
1361
+ lastLayoutFrames[id] = frame
1362
+ send(type: "EVENT", payload: [
1363
+ "handlerName": handler,
1364
+ "nativeEvent": ["layout": [
1365
+ "x": frame.minX,
1366
+ "y": frame.minY,
1367
+ "width": frame.width,
1368
+ "height": frame.height,
1369
+ ]],
1370
+ ])
1371
+ }
1372
+ }
1373
+ for child in node.children { emitLayoutEvents(for: child) }
1374
+ if let list = node.view as? CraftNativeFlatList {
1375
+ flatListRows[ObjectIdentifier(list)]?.values.forEach { emitLayoutEvents(for: $0) }
1376
+ }
1377
+ }
1378
+
887
1379
  private func updateAuxiliaryHandler(
888
1380
  _ handler: String?,
889
1381
  in handlers: inout [ObjectIdentifier: String],
@@ -894,8 +1386,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
894
1386
  else { handlers.removeValue(forKey: id) }
895
1387
  }
896
1388
 
1389
+ private func nonEmptyHandler(_ handler: String?) -> String? {
1390
+ guard let handler, !handler.isEmpty else { return nil }
1391
+ return handler
1392
+ }
1393
+
897
1394
  private func updatePressHandler(_ handler: String?, for view: UIView) {
898
1395
  let id = ObjectIdentifier(view)
1396
+ let handler = handler?.isEmpty == false ? handler : nil
899
1397
  updateHandler(handler, for: view)
900
1398
  if handler != nil, tapRecognizers[id] == nil {
901
1399
  let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
@@ -904,7 +1402,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
904
1402
  } else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
905
1403
  view.removeGestureRecognizer(recognizer)
906
1404
  }
907
- if view is UIImageView { view.isUserInteractionEnabled = handler != nil }
1405
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil }
1406
+ }
1407
+
1408
+ private func updateLongPressHandler(_ handler: String?, for view: UIView) {
1409
+ let id = ObjectIdentifier(view)
1410
+ if let handler, !handler.isEmpty {
1411
+ longPressHandlers[id] = handler
1412
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
1413
+ if longPressRecognizers[id] == nil {
1414
+ let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewLongPressed(_:)))
1415
+ longPressRecognizers[id] = recognizer
1416
+ view.addGestureRecognizer(recognizer)
1417
+ }
1418
+ } else {
1419
+ longPressHandlers.removeValue(forKey: id)
1420
+ if let recognizer = longPressRecognizers.removeValue(forKey: id) {
1421
+ view.removeGestureRecognizer(recognizer)
1422
+ }
1423
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil }
1424
+ }
908
1425
  }
909
1426
 
910
1427
  private func updateField(_ field: UITextField, value: String?) {
@@ -920,15 +1437,86 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
920
1437
  }
921
1438
  }
922
1439
 
1440
+ private func updateTextView(_ textView: UITextView, value: String?) {
1441
+ guard let value = value, textView.text != value, textView.markedTextRange == nil else { return }
1442
+ let selection = textView.selectedTextRange
1443
+ let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
1444
+ let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
1445
+ textView.text = value
1446
+ if let start = start, let end = end,
1447
+ let from = textView.position(from: textView.beginningOfDocument, offset: min(start, value.utf16.count)),
1448
+ let to = textView.position(from: textView.beginningOfDocument, offset: min(end, value.utf16.count)) {
1449
+ textView.selectedTextRange = textView.textRange(from: from, to: to)
1450
+ }
1451
+ }
1452
+
1453
+ private func configureTextView(
1454
+ _ textView: UITextView,
1455
+ props: [String: Any],
1456
+ style: [String: Any],
1457
+ events: [String: String],
1458
+ defaultValue: String?
1459
+ ) {
1460
+ let id = ObjectIdentifier(textView)
1461
+ textView.delegate = self
1462
+ textView.textColor = color(style["color"]) ?? .label
1463
+ textView.font = textFont(style, default: .systemFont(ofSize: 16))
1464
+ textView.textAlignment = textAlignment(style["textAlign"])
1465
+ textView.tintColor = color(props["selectionColor"])
1466
+ textView.keyboardType = keyboardType(props["keyboardType"])
1467
+ textView.returnKeyType = returnKeyType(props["returnKeyType"])
1468
+ textView.autocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
1469
+ textView.autocapitalizationType = capitalizationType(props["autoCapitalize"])
1470
+ textView.isSecureTextEntry = props["secureTextEntry"] as? Bool == true
1471
+ textView.isEditable = props["editable"] as? Bool != false
1472
+ textView.isScrollEnabled = false
1473
+ if let numberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue, numberOfLines > 0 {
1474
+ textView.textContainer.maximumNumberOfLines = numberOfLines
1475
+ } else {
1476
+ textView.textContainer.maximumNumberOfLines = 0
1477
+ }
1478
+ if let maxLength = (props["maxLength"] as? NSNumber)?.intValue, maxLength > 0 {
1479
+ textMaxLengths[id] = maxLength
1480
+ } else {
1481
+ textMaxLengths.removeValue(forKey: id)
1482
+ }
1483
+ if let craftTextView = textView as? CraftNativeTextView {
1484
+ craftTextView.placeholder = props["placeholder"] as? String
1485
+ craftTextView.placeholderColor = color(props["placeholderTextColor"])
1486
+ craftTextView.placeholderFont = textView.font
1487
+ }
1488
+ updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
1489
+ let selectedRange = textView.selectedRange
1490
+ let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
1491
+ textView.typingAttributes = attributes
1492
+ if !textView.text.isEmpty {
1493
+ textView.attributedText = NSAttributedString(string: textView.text, attributes: attributes)
1494
+ textView.selectedRange = selectedRange
1495
+ }
1496
+ (textView as? CraftNativeTextView)?.refreshPlaceholder()
1497
+ updateHandler(nonEmptyHandler(events["onChange"]) ?? nonEmptyHandler(events["onChangeText"]), for: textView)
1498
+ updateAuxiliaryHandler(events["onFocus"], in: &focusHandlers, for: textView)
1499
+ updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: textView)
1500
+ updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
1501
+ updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
1502
+ if props["autoFocus"] as? Bool == true, !textView.isFirstResponder {
1503
+ DispatchQueue.main.async { _ = textView.becomeFirstResponder() }
1504
+ }
1505
+ }
1506
+
923
1507
  private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
924
1508
  let direction = style["flexDirection"] as? String
925
1509
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
926
1510
  stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
927
1511
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
928
1512
  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"])
1513
+ let gap = number(style["gap"]) ?? 0
1514
+ stack.gap = gap
1515
+ stack.rowGap = number(style["rowGap"]) ?? gap
1516
+ stack.columnGap = number(style["columnGap"]) ?? gap
1517
+ stack.grid = style["display"] as? String == "grid"
1518
+ stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
1519
+ stack.gridAutoRows = number(style["gridAutoRows"])
932
1520
  let padding = number(style["padding"]) ?? 0
933
1521
  let horizontal = number(style["paddingHorizontal"]) ?? padding
934
1522
  let vertical = number(style["paddingVertical"]) ?? padding
@@ -953,19 +1541,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
953
1541
  view.layer.cornerRadius = number(style["borderRadius"]) ?? 0
954
1542
  view.layer.borderWidth = number(style["borderWidth"]) ?? 0
955
1543
  view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
1544
+ let elevation = max(0, number(style["elevation"]) ?? 0)
1545
+ view.layer.shadowColor = UIColor.black.cgColor
1546
+ view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
1547
+ view.layer.shadowRadius = elevation * 0.5
1548
+ view.layer.shadowOffset = CGSize(width: 0, height: elevation * 0.25)
956
1549
  view.clipsToBounds = style["overflow"] as? String == "hidden"
1550
+ view.layer.masksToBounds = view.clipsToBounds
957
1551
  }
958
1552
 
959
1553
  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
- }
1554
+ let transformed = transformedText(text, style: style)
967
1555
  label.textColor = color(style["color"]) ?? .label
968
- label.font = textFont(style, default: .systemFont(ofSize: UIFont.systemFontSize))
1556
+ label.font = textFont(style, default: .systemFont(ofSize: 16))
969
1557
  label.textAlignment = textAlignment(style["textAlign"])
970
1558
  var attributes: [NSAttributedString.Key: Any] = [:]
971
1559
  if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
@@ -988,6 +1576,85 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
988
1576
  if attributes.isEmpty { label.text = transformed }
989
1577
  }
990
1578
 
1579
+ private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
1580
+ switch value as? String {
1581
+ case "head": return .byTruncatingHead
1582
+ case "middle": return .byTruncatingMiddle
1583
+ case "tail": return .byTruncatingTail
1584
+ case "clip": return .byClipping
1585
+ default: return .byTruncatingTail
1586
+ }
1587
+ }
1588
+
1589
+ private func buttonTitleAttributes(
1590
+ _ style: [String: Any],
1591
+ color: UIColor,
1592
+ font: UIFont,
1593
+ forceUnderline: Bool
1594
+ ) -> [NSAttributedString.Key: Any] {
1595
+ var attributes: [NSAttributedString.Key: Any] = [
1596
+ .foregroundColor: color,
1597
+ .font: font,
1598
+ ]
1599
+ if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
1600
+ if let lineHeight = number(style["lineHeight"]) {
1601
+ let paragraph = NSMutableParagraphStyle()
1602
+ paragraph.minimumLineHeight = lineHeight
1603
+ paragraph.maximumLineHeight = lineHeight
1604
+ attributes[.paragraphStyle] = paragraph
1605
+ }
1606
+ switch style["textDecorationLine"] as? String {
1607
+ case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
1608
+ case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
1609
+ case "underline line-through":
1610
+ attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
1611
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
1612
+ default: break
1613
+ }
1614
+ if forceUnderline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue }
1615
+ return attributes
1616
+ }
1617
+
1618
+ private func inputTextAttributes(
1619
+ _ style: [String: Any],
1620
+ font: UIFont,
1621
+ alignment: NSTextAlignment
1622
+ ) -> [NSAttributedString.Key: Any] {
1623
+ var attributes: [NSAttributedString.Key: Any] = [
1624
+ .foregroundColor: color(style["color"]) ?? .label,
1625
+ .font: font,
1626
+ ]
1627
+ if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
1628
+ if let lineHeight = number(style["lineHeight"]) {
1629
+ let paragraph = NSMutableParagraphStyle()
1630
+ paragraph.minimumLineHeight = lineHeight
1631
+ paragraph.maximumLineHeight = lineHeight
1632
+ paragraph.alignment = alignment
1633
+ attributes[.paragraphStyle] = paragraph
1634
+ }
1635
+ switch style["textDecorationLine"] as? String {
1636
+ case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
1637
+ case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
1638
+ case "underline line-through":
1639
+ attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
1640
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
1641
+ default: break
1642
+ }
1643
+ return attributes
1644
+ }
1645
+
1646
+ private func transformedText(_ text: String, style: [String: Any]) -> String {
1647
+ switch style["textTransform"] as? String {
1648
+ case "uppercase": return text.uppercased()
1649
+ case "lowercase": return text.lowercased()
1650
+ case "capitalize": return text.split(separator: " ", omittingEmptySubsequences: false).map { word in
1651
+ guard let first = word.first else { return String(word) }
1652
+ return String(first).uppercased() + String(word.dropFirst())
1653
+ }.joined(separator: " ")
1654
+ default: return text
1655
+ }
1656
+ }
1657
+
991
1658
  private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
992
1659
  let size = number(style["fontSize"]) ?? fallback.pointSize
993
1660
  let weight: UIFont.Weight
@@ -1021,6 +1688,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1021
1688
  }
1022
1689
  }
1023
1690
 
1691
+ private func buttonAlignment(_ value: Any?) -> UIControl.ContentHorizontalAlignment {
1692
+ switch value as? String {
1693
+ case "left": return .left
1694
+ case "right": return .right
1695
+ case "center": return .center
1696
+ default: return .center
1697
+ }
1698
+ }
1699
+
1024
1700
  private func imageContentMode(_ value: Any?) -> UIView.ContentMode {
1025
1701
  switch value as? String {
1026
1702
  case "cover": return .scaleAspectFill
@@ -1030,6 +1706,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1030
1706
  }
1031
1707
  }
1032
1708
 
1709
+ private func keyboardDismissMode(_ value: Any?) -> UIScrollView.KeyboardDismissMode {
1710
+ switch value as? String {
1711
+ case "on-drag": return .onDrag
1712
+ case "interactive": return .interactive
1713
+ default: return .none
1714
+ }
1715
+ }
1716
+
1033
1717
  private func updateImage(_ view: UIImageView, source: Any?) {
1034
1718
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
1035
1719
  let id = ObjectIdentifier(view)
@@ -1042,8 +1726,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1042
1726
  guard imageSources[id] != uri else { return }
1043
1727
  imageTasks.removeValue(forKey: id)?.cancel()
1044
1728
  imageSources[id] = uri
1729
+ imageErrors.removeValue(forKey: id)
1045
1730
  view.image = nil
1046
1731
  view.accessibilityValue = nil
1732
+ emitImageEvent(view, handler: imageLoadStartHandlers[id])
1047
1733
 
1048
1734
  if uri.hasPrefix("data:image/") {
1049
1735
  guard let comma = uri.firstIndex(of: ","),
@@ -1052,7 +1738,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1052
1738
  imageFailure(view, uri: uri, message: "Image data is invalid")
1053
1739
  return
1054
1740
  }
1055
- view.image = image
1741
+ view.image = imageForDisplay(image, view: view)
1742
+ emitImageEvent(view, handler: imageLoadHandlers[id])
1743
+ emitImageEvent(view, handler: imageLoadEndHandlers[id])
1056
1744
  return
1057
1745
  }
1058
1746
  if let url = URL(string: uri), url.scheme == "https" {
@@ -1065,7 +1753,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1065
1753
  self.imageFailure(view, uri: uri, message: "Image download failed")
1066
1754
  return
1067
1755
  }
1068
- view.image = image
1756
+ view.image = self.imageForDisplay(image, view: view)
1757
+ self.emitImageEvent(view, handler: self.imageLoadHandlers[ObjectIdentifier(view)])
1758
+ self.emitImageEvent(view, handler: self.imageLoadEndHandlers[ObjectIdentifier(view)])
1069
1759
  }
1070
1760
  }
1071
1761
  imageTasks[id] = task
@@ -1077,26 +1767,48 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1077
1767
  imageFailure(view, uri: uri, message: "Bundled image was not found")
1078
1768
  return
1079
1769
  }
1080
- view.image = image
1770
+ view.image = imageForDisplay(image, view: view)
1771
+ emitImageEvent(view, handler: imageLoadHandlers[id])
1772
+ emitImageEvent(view, handler: imageLoadEndHandlers[id])
1081
1773
  return
1082
1774
  }
1083
1775
  imageFailure(view, uri: uri, message: "Unsupported image source")
1084
1776
  }
1085
1777
 
1086
1778
  private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
1779
+ imageErrors[ObjectIdentifier(view)] = message
1087
1780
  view.image = nil
1088
1781
  view.accessibilityValue = message
1782
+ let id = ObjectIdentifier(view)
1783
+ emitImageEvent(view, handler: imageErrorHandlers[id], nativeEvent: ["error": ["message": message]])
1784
+ emitImageEvent(view, handler: imageLoadEndHandlers[id])
1089
1785
  NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
1090
1786
  }
1091
1787
 
1788
+ private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
1789
+ image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
1790
+ }
1791
+
1792
+ private func emitImageEvent(
1793
+ _ view: UIImageView,
1794
+ handler: String?,
1795
+ nativeEvent: [String: Any] = [:]
1796
+ ) {
1797
+ guard let handler, !handler.isEmpty else { return }
1798
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
1799
+ }
1800
+
1092
1801
  private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
1093
- view.accessibilityLabel = props["accessibilityLabel"] as? String
1802
+ view.accessibilityLabel = (props["accessibilityLabel"] as? String)
1803
+ ?? (type == "Image" ? props["alt"] as? String : nil)
1094
1804
  view.accessibilityHint = props["accessibilityHint"] as? String
1095
1805
  if let value = props["accessibilityValue"] {
1096
1806
  view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
1807
+ } else if type != "TextInput" {
1808
+ view.accessibilityValue = nil
1097
1809
  }
1098
1810
  let role = props["accessibilityRole"] as? String
1099
- view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Image", "TextInput"].contains(type))
1811
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Switch", "Slider"].contains(type))
1100
1812
  var traits: UIAccessibilityTraits = []
1101
1813
  switch role ?? type.lowercased() {
1102
1814
  case "button": traits.insert(.button)
@@ -1107,11 +1819,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1107
1819
  default: break
1108
1820
  }
1109
1821
  if let state = props["accessibilityState"] as? [String: Any] {
1110
- if state["disabled"] as? Bool == true { traits.insert(.notEnabled) }
1822
+ if state["disabled"] as? Bool == true {
1823
+ traits.insert(.notEnabled)
1824
+ if let control = view as? UIControl {
1825
+ control.isEnabled = false
1826
+ } else if handlers[ObjectIdentifier(view)] != nil || longPressHandlers[ObjectIdentifier(view)] != nil {
1827
+ view.isUserInteractionEnabled = false
1828
+ }
1829
+ }
1111
1830
  if state["selected"] as? Bool == true { traits.insert(.selected) }
1112
1831
  if state["checked"] as? Bool == true { traits.insert(.selected) }
1113
1832
  }
1114
1833
  if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
1834
+ if type == "Image", let message = imageErrors[ObjectIdentifier(view)] {
1835
+ let current = view.accessibilityValue
1836
+ if current?.contains(message) != true {
1837
+ view.accessibilityValue = [current, message].compactMap { $0 }.joined(separator: ", ")
1838
+ }
1839
+ }
1115
1840
  view.accessibilityTraits = traits
1116
1841
  }
1117
1842
 
@@ -1138,10 +1863,55 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1138
1863
  }
1139
1864
  }
1140
1865
 
1866
+ func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
1867
+ guard let maxLength = textMaxLengths[ObjectIdentifier(textField)],
1868
+ let current = textField.text,
1869
+ let stringRange = Range(range, in: current) else { return true }
1870
+ return current.replacingCharacters(in: stringRange, with: string).utf16.count <= maxLength
1871
+ }
1872
+
1873
+ func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
1874
+ if text == "\n", let handler = submitHandlers[ObjectIdentifier(textView)] {
1875
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
1876
+ }
1877
+ guard let maxLength = textMaxLengths[ObjectIdentifier(textView)],
1878
+ let current = textView.text,
1879
+ let stringRange = Range(range, in: current) else { return true }
1880
+ return current.replacingCharacters(in: stringRange, with: text).utf16.count <= maxLength
1881
+ }
1882
+
1883
+ func textViewDidChange(_ textView: UITextView) {
1884
+ lastFocusedInput = textView
1885
+ let id = ObjectIdentifier(textView)
1886
+ inputIdentities[id].map { inputDrafts[$0] = textView.text ?? "" }
1887
+ (textView as? CraftNativeTextView)?.refreshPlaceholder()
1888
+ guard let handler = handlers[id] else { return }
1889
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
1890
+ }
1891
+
1892
+ func textViewDidBeginEditing(_ textView: UITextView) {
1893
+ lastFocusedInput = textView
1894
+ guard let handler = focusHandlers[ObjectIdentifier(textView)] else { return }
1895
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
1896
+ }
1897
+
1898
+ func textViewDidEndEditing(_ textView: UITextView) {
1899
+ let id = ObjectIdentifier(textView)
1900
+ let nativeEvent: [String: Any] = ["text": textView.text ?? ""]
1901
+ if let handler = blurHandlers[id] {
1902
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
1903
+ }
1904
+ if let handler = endEditingHandlers[id] {
1905
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
1906
+ }
1907
+ }
1908
+
1141
1909
  @objc private func textChanged(_ sender: UITextField) {
1142
1910
  lastFocusedInput = sender
1911
+ let id = ObjectIdentifier(sender)
1912
+ inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
1143
1913
  let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
1144
- guard let handler = handlers[ObjectIdentifier(sender)] else { return }
1914
+ guard let handler = handlers[id] else { return }
1145
1915
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
1146
1916
  restoreFocus(focused)
1147
1917
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
@@ -1149,15 +1919,38 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1149
1919
  }
1150
1920
  }
1151
1921
 
1922
+ @objc private func switchChanged(_ sender: UISwitch) {
1923
+ guard let handler = handlers[ObjectIdentifier(sender)] else { return }
1924
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
1925
+ }
1926
+
1927
+ @objc private func sliderChanged(_ sender: UISlider) {
1928
+ let value = snappedSliderValue(sender.value, for: sender)
1929
+ if sender.value != value { sender.value = value }
1930
+ guard let handler = handlers[ObjectIdentifier(sender)] else { return }
1931
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value]])
1932
+ }
1933
+
1934
+ @objc private func sliderFinished(_ sender: UISlider) {
1935
+ guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
1936
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
1937
+ }
1938
+
1152
1939
  @objc private func textFocused(_ sender: UITextField) {
1153
1940
  lastFocusedInput = sender
1154
1941
  guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
1155
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
1942
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
1156
1943
  }
1157
1944
 
1158
1945
  @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 ?? ""]])
1946
+ let id = ObjectIdentifier(sender)
1947
+ let nativeEvent: [String: Any] = ["text": sender.text ?? ""]
1948
+ if let handler = blurHandlers[id] {
1949
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
1950
+ }
1951
+ if let handler = endEditingHandlers[id] {
1952
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
1953
+ }
1161
1954
  }
1162
1955
 
1163
1956
  @objc private func textSubmitted(_ sender: UITextField) {
@@ -1170,6 +1963,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1170
1963
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
1171
1964
  }
1172
1965
 
1966
+ @objc private func viewLongPressed(_ sender: UILongPressGestureRecognizer) {
1967
+ guard sender.state == .began,
1968
+ let view = sender.view,
1969
+ let handler = longPressHandlers[ObjectIdentifier(view)] else { return }
1970
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
1971
+ }
1972
+
1173
1973
  func scrollViewDidScroll(_ scrollView: UIScrollView) {
1174
1974
  guard let handler = scrollHandlers[ObjectIdentifier(scrollView)] else { return }
1175
1975
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
@@ -1185,6 +1985,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1185
1985
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
1186
1986
  }
1187
1987
 
1988
+ func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
1989
+ guard let handler = scrollMomentumBeginHandlers[ObjectIdentifier(scrollView)] else { return }
1990
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
1991
+ }
1992
+
1993
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
1994
+ guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
1995
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
1996
+ }
1997
+
1188
1998
  private func scrollEvent(_ scrollView: UIScrollView) -> [String: Any] {
1189
1999
  [
1190
2000
  "contentOffset": ["x": scrollView.contentOffset.x, "y": scrollView.contentOffset.y],
@@ -1208,10 +2018,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
1208
2018
  private func keyboardType(_ value: Any?) -> UIKeyboardType {
1209
2019
  switch value as? String {
1210
2020
  case "email-address": return .emailAddress
1211
- case "numeric": return .numberPad
2021
+ case "numeric", "number-pad": return .numberPad
1212
2022
  case "phone-pad": return .phonePad
1213
2023
  case "decimal-pad": return .decimalPad
1214
2024
  case "url": return .URL
2025
+ case "web-search": return .webSearch
2026
+ case "visible-password": return .asciiCapable
1215
2027
  default: return .default
1216
2028
  }
1217
2029
  }