craft-native 0.0.112 → 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.
- package/dist/android/src/index.js +6 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +181 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +335 -0
- package/dist/android/templates/MainActivityNative.kt.template +493 -42
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +8 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -1
- package/dist/ios/src/index.js +2 -0
- package/dist/ios/templates/CraftNativeFlatList.swift +323 -0
- package/dist/ios/templates/CraftNativeMutation.swift +365 -0
- package/dist/ios/templates/CraftNativeScreen.swift +460 -31
- package/dist/types.d.ts +81 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
207
|
-
result.layer.cornerRadius = number(style["borderRadius"]) ?? 0
|
|
208
|
-
updateDimension(number(style["width"]), constraint: ¤t.widthConstraint, anchor: result.widthAnchor)
|
|
209
|
-
updateDimension(number(style["height"]), constraint: ¤t.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
|
|
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
|
|
229
|
-
stack
|
|
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(
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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 }
|