@craft-native/ios 0.0.247 → 0.0.248
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/README.md +1 -0
- package/dist/cli.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +54 -53
- package/package.json +1 -1
- package/templates/CraftApp.swift +37 -2
- package/templates/CraftHealth.swift +217 -0
- package/templates/CraftHeartRate.swift +6 -3
- package/templates/CraftHybrid.swift +18 -19
- package/templates/CraftLiveActivities.swift +1 -1
- package/templates/CraftNativeActions.swift +87 -8
- package/templates/CraftNativeFiles.swift +15 -3
- package/templates/CraftNativeFlatList.swift +18 -1
- package/templates/CraftNativeMutation.swift +36 -0
- package/templates/CraftNativeScreen.swift +813 -65
- package/templates/CraftNativeServices.swift +4 -2
- package/templates/CraftNativeUI.swift +10 -2
- package/templates/testing/DriverTests.swift.template +1 -0
- package/templates/testing/HostApp.swift +9 -2
|
@@ -59,6 +59,19 @@ private func craftNativeGridColumnCount(_ value: Any?) -> Int {
|
|
|
59
59
|
return max(1, tracks.count)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
private func craftNativeGridRowCount(_ value: Any?) -> Int? {
|
|
63
|
+
if let number = craftNativeNumber(value) { return max(1, number.intValue) }
|
|
64
|
+
guard let text = value as? String else { return nil }
|
|
65
|
+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
66
|
+
guard !trimmed.isEmpty else { return nil }
|
|
67
|
+
if trimmed.hasPrefix("repeat(") {
|
|
68
|
+
let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
|
|
69
|
+
if let count = Int(digits) { return max(1, count) }
|
|
70
|
+
}
|
|
71
|
+
let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
|
|
72
|
+
return tracks.isEmpty ? nil : max(1, tracks.count)
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
/// Optional bridge to the shared Zig flex rule. Native hosts retain their
|
|
63
76
|
/// local implementation when no Craft runtime is linked (the normal SDK
|
|
64
77
|
/// fallback), while runtime-backed apps get one cross-platform calculation.
|
|
@@ -240,12 +253,56 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
240
253
|
var gridAutoRows: Float = .nan
|
|
241
254
|
}
|
|
242
255
|
|
|
256
|
+
private struct StyleInputV5 {
|
|
257
|
+
var direction: UInt32 = 0
|
|
258
|
+
var width: Float = .nan
|
|
259
|
+
var height: Float = .nan
|
|
260
|
+
var intrinsicWidth: Float = .nan
|
|
261
|
+
var intrinsicHeight: Float = .nan
|
|
262
|
+
var baseline: Float = .nan
|
|
263
|
+
var aspectRatio: Float = .nan
|
|
264
|
+
var flexBasis: Float = .nan
|
|
265
|
+
var flexGrow: Float = 0
|
|
266
|
+
var flexShrink: Float = 0
|
|
267
|
+
var minWidth: Float = .nan
|
|
268
|
+
var maxWidth: Float = .nan
|
|
269
|
+
var minHeight: Float = .nan
|
|
270
|
+
var maxHeight: Float = .nan
|
|
271
|
+
var gap: Float = 0
|
|
272
|
+
var justifyContent: UInt32 = 0
|
|
273
|
+
var alignItems: UInt32 = 3
|
|
274
|
+
var alignSelf: UInt32 = 0
|
|
275
|
+
var flexWrap: UInt32 = 0
|
|
276
|
+
var alignContent: UInt32 = 5
|
|
277
|
+
var marginTop: Float = 0
|
|
278
|
+
var marginRight: Float = 0
|
|
279
|
+
var marginBottom: Float = 0
|
|
280
|
+
var marginLeft: Float = 0
|
|
281
|
+
var paddingTop: Float = 0
|
|
282
|
+
var paddingRight: Float = 0
|
|
283
|
+
var paddingBottom: Float = 0
|
|
284
|
+
var paddingLeft: Float = 0
|
|
285
|
+
var grid: UInt32 = 0
|
|
286
|
+
var gridColumns: UInt32 = 1
|
|
287
|
+
var rowGap: Float = 0
|
|
288
|
+
var columnGap: Float = 0
|
|
289
|
+
var reverse: UInt32 = 0
|
|
290
|
+
var gridAutoRows: Float = .nan
|
|
291
|
+
var gridRows: UInt32 = 0
|
|
292
|
+
}
|
|
293
|
+
|
|
243
294
|
private struct NodeInputV4 {
|
|
244
295
|
var style: StyleInputV4
|
|
245
296
|
var firstChild: UInt32
|
|
246
297
|
var childCount: UInt32
|
|
247
298
|
}
|
|
248
299
|
|
|
300
|
+
private struct NodeInputV5 {
|
|
301
|
+
var style: StyleInputV5
|
|
302
|
+
var firstChild: UInt32
|
|
303
|
+
var childCount: UInt32
|
|
304
|
+
}
|
|
305
|
+
|
|
249
306
|
fileprivate struct Frame {
|
|
250
307
|
var x: Float
|
|
251
308
|
var y: Float
|
|
@@ -269,6 +326,7 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
269
326
|
let padding: UIEdgeInsets
|
|
270
327
|
let grid: Bool
|
|
271
328
|
let gridColumns: Int
|
|
329
|
+
let gridRows: Int?
|
|
272
330
|
let gridAutoRows: CraftNativeLength?
|
|
273
331
|
let reverse: Bool
|
|
274
332
|
let wrapReverse: Bool
|
|
@@ -309,6 +367,11 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
309
367
|
return unsafeBitCast(symbol, to: FunctionV2.self)
|
|
310
368
|
}()
|
|
311
369
|
|
|
370
|
+
private static let functionV5: FunctionV2? = {
|
|
371
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree_v5") else { return nil }
|
|
372
|
+
return unsafeBitCast(symbol, to: FunctionV2.self)
|
|
373
|
+
}()
|
|
374
|
+
|
|
312
375
|
private static func finite(_ value: CGFloat?) -> Float {
|
|
313
376
|
guard let value, value.isFinite else { return .nan }
|
|
314
377
|
return Float(value)
|
|
@@ -421,6 +484,9 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
421
484
|
|
|
422
485
|
fileprivate static func treeFrames(root: TreeNode, width: CGFloat, height: CGFloat) -> TreeResult? {
|
|
423
486
|
guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
|
|
487
|
+
if containsGridRows(root) {
|
|
488
|
+
return treeFramesV5(root: root, width: width, height: height, function: functionV5)
|
|
489
|
+
}
|
|
424
490
|
if containsGridAutoRows(root) {
|
|
425
491
|
return treeFramesV4(root: root, width: width, height: height, function: functionV4)
|
|
426
492
|
}
|
|
@@ -529,6 +595,10 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
529
595
|
node.gridAutoRows != nil || node.children.contains(where: containsGridAutoRows)
|
|
530
596
|
}
|
|
531
597
|
|
|
598
|
+
private static func containsGridRows(_ node: TreeNode) -> Bool {
|
|
599
|
+
node.gridRows != nil || node.children.contains(where: containsGridRows)
|
|
600
|
+
}
|
|
601
|
+
|
|
532
602
|
private static func treeFramesV2(root: TreeNode, width: CGFloat, height: CGFloat, function: FunctionV2?) -> TreeResult? {
|
|
533
603
|
guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
|
|
534
604
|
var nodes: [TreeNode] = []
|
|
@@ -712,6 +782,100 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
712
782
|
}) else { return nil }
|
|
713
783
|
return TreeResult(nodes: nodes, parents: parents, frames: output)
|
|
714
784
|
}
|
|
785
|
+
|
|
786
|
+
/// v5 appends the explicit grid-template-rows count to v4's payload.
|
|
787
|
+
/// Older runtimes continue through v4 when no explicit rows are present.
|
|
788
|
+
private static func treeFramesV5(root: TreeNode, width: CGFloat, height: CGFloat, function: FunctionV2?) -> TreeResult? {
|
|
789
|
+
guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
|
|
790
|
+
var nodes: [TreeNode] = []
|
|
791
|
+
var parents: [Int] = []
|
|
792
|
+
func append(_ node: TreeNode, parent: Int) {
|
|
793
|
+
let index = nodes.count
|
|
794
|
+
nodes.append(node)
|
|
795
|
+
parents.append(parent)
|
|
796
|
+
node.children.forEach { append($0, parent: index) }
|
|
797
|
+
}
|
|
798
|
+
append(root, parent: -1)
|
|
799
|
+
|
|
800
|
+
func put(_ input: inout StyleInputV5, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
|
|
801
|
+
let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
|
|
802
|
+
let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
|
|
803
|
+
let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
|
|
804
|
+
input.direction = node.direction == .horizontal ? 0 : 1
|
|
805
|
+
input.width = rootNode ? Float(width) : length(node.style.width, base: parentWidth)
|
|
806
|
+
input.height = rootNode ? Float(height) : length(node.style.height, base: parentHeight)
|
|
807
|
+
input.intrinsicWidth = rootNode ? .nan : Float(max(0, node.intrinsic.width))
|
|
808
|
+
input.intrinsicHeight = rootNode ? .nan : Float(max(0, node.intrinsic.height))
|
|
809
|
+
input.baseline = rootNode ? .nan : Float(max(0, node.baseline))
|
|
810
|
+
input.aspectRatio = finite(node.style.aspectRatio)
|
|
811
|
+
input.flexBasis = length(node.style.flexBasis, base: mainSize)
|
|
812
|
+
input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
|
|
813
|
+
let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && parentDirection == .horizontal) ? 1 : 0
|
|
814
|
+
input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
|
|
815
|
+
input.minWidth = length(node.style.minWidth, base: contentWidth)
|
|
816
|
+
input.maxWidth = length(node.style.maxWidth, base: contentWidth)
|
|
817
|
+
input.minHeight = length(node.style.minHeight, base: contentHeight)
|
|
818
|
+
input.maxHeight = length(node.style.maxHeight, base: contentHeight)
|
|
819
|
+
input.gap = Float(max(0, node.gap))
|
|
820
|
+
input.justifyContent = justify(node.justifyContent)
|
|
821
|
+
input.alignItems = alignment(node.alignItems)
|
|
822
|
+
input.alignSelf = rootNode ? 0 : alignSelf(node.style.alignSelf)
|
|
823
|
+
input.flexWrap = node.wrap ? 1 : 0
|
|
824
|
+
input.alignContent = craftNativeAlignContentCode(node.alignContent)
|
|
825
|
+
input.marginTop = Float(max(0, node.style.marginTop))
|
|
826
|
+
input.marginRight = Float(max(0, node.style.marginRight))
|
|
827
|
+
input.marginBottom = Float(max(0, node.style.marginBottom))
|
|
828
|
+
input.marginLeft = Float(max(0, node.style.marginLeft))
|
|
829
|
+
input.paddingTop = Float(max(0, node.padding.top))
|
|
830
|
+
input.paddingRight = Float(max(0, node.padding.right))
|
|
831
|
+
input.paddingBottom = Float(max(0, node.padding.bottom))
|
|
832
|
+
input.paddingLeft = Float(max(0, node.padding.left))
|
|
833
|
+
input.grid = node.grid ? 1 : 0
|
|
834
|
+
input.gridColumns = UInt32(max(1, node.gridColumns))
|
|
835
|
+
input.rowGap = Float(max(0, node.rowGap))
|
|
836
|
+
input.columnGap = Float(max(0, node.columnGap))
|
|
837
|
+
input.reverse = (node.reverse ? 1 : 0) | (node.wrapReverse ? 2 : 0)
|
|
838
|
+
input.gridAutoRows = node.gridAutoRows.map { length($0, base: contentHeight) } ?? .nan
|
|
839
|
+
input.gridRows = UInt32(max(0, node.gridRows ?? 0))
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
var styles = [StyleInputV5](repeating: StyleInputV5(), count: nodes.count)
|
|
843
|
+
var metadata = [Float](repeating: 0, count: nodes.count * 2)
|
|
844
|
+
var cursor = 0
|
|
845
|
+
func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
|
|
846
|
+
let index = cursor
|
|
847
|
+
cursor += 1
|
|
848
|
+
put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode, parentDirection: parentDirection)
|
|
849
|
+
let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
|
|
850
|
+
let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
|
|
851
|
+
let firstChild = cursor
|
|
852
|
+
node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false, parentDirection: node.direction) }
|
|
853
|
+
metadata[index * 2] = Float(firstChild)
|
|
854
|
+
metadata[index * 2 + 1] = Float(cursor - firstChild)
|
|
855
|
+
}
|
|
856
|
+
write(root, parentWidth: width, parentHeight: height, rootNode: true, parentDirection: root.direction)
|
|
857
|
+
let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
|
|
858
|
+
NodeInputV5(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
|
|
862
|
+
let frameCount = output.count
|
|
863
|
+
let status = inputs.withUnsafeBufferPointer { inputBuffer in
|
|
864
|
+
metadata.withUnsafeBufferPointer { metadataBuffer in
|
|
865
|
+
output.withUnsafeMutableBufferPointer { outputBuffer in
|
|
866
|
+
function(
|
|
867
|
+
Float(width), Float(height), UnsafeRawPointer(inputBuffer.baseAddress!), inputs.count,
|
|
868
|
+
UnsafeMutableRawPointer(outputBuffer.baseAddress!), frameCount
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
guard status == 0, output.allSatisfy({ frame in
|
|
874
|
+
frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
|
|
875
|
+
frame.width >= 0 && frame.height >= 0
|
|
876
|
+
}) else { return nil }
|
|
877
|
+
return TreeResult(nodes: nodes, parents: parents, frames: output)
|
|
878
|
+
}
|
|
715
879
|
}
|
|
716
880
|
|
|
717
881
|
private enum CraftNativeFlexLineBridge {
|
|
@@ -1140,10 +1304,13 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
|
|
|
1140
1304
|
func stop() {
|
|
1141
1305
|
detachPlayer()
|
|
1142
1306
|
playerController.player = nil
|
|
1307
|
+
playerController.delegate = nil
|
|
1143
1308
|
sourceURI = ""
|
|
1144
1309
|
playIntent = nil
|
|
1145
1310
|
userPaused = false
|
|
1146
1311
|
ended = false
|
|
1312
|
+
handlers.removeAll()
|
|
1313
|
+
emit = nil
|
|
1147
1314
|
if playerController.parent != nil {
|
|
1148
1315
|
playerController.willMove(toParent: nil)
|
|
1149
1316
|
playerController.view.removeFromSuperview()
|
|
@@ -1160,6 +1327,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1160
1327
|
private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
|
|
1161
1328
|
private var overlayColors: [ObjectIdentifier: UIColor] = [:]
|
|
1162
1329
|
private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
|
|
1330
|
+
private var polygonsByID: [String: MKPolygon] = [:]
|
|
1331
|
+
private var polygonCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
|
|
1332
|
+
private var polygonFills: [ObjectIdentifier: UIColor] = [:]
|
|
1333
|
+
private var polygonStrokes: [ObjectIdentifier: UIColor] = [:]
|
|
1334
|
+
private var polygonWidths: [ObjectIdentifier: CGFloat] = [:]
|
|
1163
1335
|
private var initialCameraKey: String?
|
|
1164
1336
|
private var pendingFitKey: String?
|
|
1165
1337
|
private var pendingRegion: MKCoordinateRegion?
|
|
@@ -1193,6 +1365,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1193
1365
|
override init(frame: CGRect) {
|
|
1194
1366
|
super.init(frame: frame)
|
|
1195
1367
|
delegate = self
|
|
1368
|
+
refreshThemeDefaults()
|
|
1196
1369
|
showsCompass = false
|
|
1197
1370
|
showsScale = false
|
|
1198
1371
|
isRotateEnabled = false
|
|
@@ -1204,9 +1377,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1204
1377
|
required init?(coder: NSCoder) {
|
|
1205
1378
|
super.init(coder: coder)
|
|
1206
1379
|
delegate = self
|
|
1380
|
+
refreshThemeDefaults()
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
func refreshThemeDefaults() {
|
|
1384
|
+
tileAttribution.textColor = .secondaryLabel
|
|
1385
|
+
tileAttribution.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.85)
|
|
1386
|
+
// Marker images are rasterized from the resolved trait color. Refresh
|
|
1387
|
+
// mounted annotations as well as the utility chrome so a retained map
|
|
1388
|
+
// does not keep light-trait artwork after a dark-mode switch.
|
|
1389
|
+
Self.markerImages.removeAllObjects()
|
|
1390
|
+
for (id, annotation) in annotationsByID {
|
|
1391
|
+
if let view = view(for: annotation) { configureMarkerView(view, id: id) }
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
1396
|
+
super.traitCollectionDidChange(previousTraitCollection)
|
|
1397
|
+
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
|
|
1398
|
+
refreshThemeDefaults()
|
|
1207
1399
|
}
|
|
1208
1400
|
|
|
1209
1401
|
func configure(props: [String: Any], children: [Any], markerHandler: @escaping (String) -> Void) {
|
|
1402
|
+
delegate = self
|
|
1210
1403
|
onMarkerTap = markerHandler
|
|
1211
1404
|
isScrollEnabled = props["scrollEnabled"] as? Bool ?? true
|
|
1212
1405
|
isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
|
|
@@ -1235,6 +1428,31 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1235
1428
|
var nextHandlers: [String: String] = [:]
|
|
1236
1429
|
var nextPolylines: [String: (coordinates: [CLLocationCoordinate2D], color: UIColor, width: CGFloat)] = [:]
|
|
1237
1430
|
var nextPolylineIDs: [String] = []
|
|
1431
|
+
var nextPolygons: [String: (coordinates: [CLLocationCoordinate2D], fill: UIColor, stroke: UIColor, width: CGFloat)] = [:]
|
|
1432
|
+
if let polylines = props["polylines"] as? [[String: Any]] {
|
|
1433
|
+
for overlay in polylines {
|
|
1434
|
+
let points = Self.coordinates(overlay["geometry"])
|
|
1435
|
+
guard let id = (overlay["id"] as? CustomStringConvertible)?.description,
|
|
1436
|
+
points.count > 1 else { continue }
|
|
1437
|
+
let authoredWidth = Self.numeric(overlay["strokeWidth"]) ?? 3
|
|
1438
|
+
let width = CGFloat(authoredWidth)
|
|
1439
|
+
guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
|
|
1440
|
+
nextPolylineIDs.append(id)
|
|
1441
|
+
nextPolylines[id] = (points, Self.color(overlay["strokeColor"] as? String), width)
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if let markers = props["markers"] as? [[String: Any]] {
|
|
1445
|
+
for overlay in markers {
|
|
1446
|
+
let points = Self.coordinates(overlay["geometry"])
|
|
1447
|
+
guard let id = (overlay["id"] as? CustomStringConvertible)?.description,
|
|
1448
|
+
let point = points.first else { continue }
|
|
1449
|
+
nextMarkers[id] = (point, overlay["title"] as? String)
|
|
1450
|
+
if let color = overlay["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
|
|
1451
|
+
if let bearing = Self.numeric(overlay["bearing"]), bearing.isFinite {
|
|
1452
|
+
nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1238
1456
|
for (index, child) in children.enumerated() {
|
|
1239
1457
|
guard let node = child as? [String: Any], let rawType = node["type"] as? String else { continue }
|
|
1240
1458
|
let type = rawType.lowercased()
|
|
@@ -1262,6 +1480,14 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1262
1480
|
guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
|
|
1263
1481
|
if nextPolylines[id] == nil { nextPolylineIDs.append(id) }
|
|
1264
1482
|
nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
|
|
1483
|
+
} else if type == "mappolygon" || type == "polygon" {
|
|
1484
|
+
guard points.count > 2 else { continue }
|
|
1485
|
+
let authoredWidth = Self.numeric(childProps["strokeWidth"]) ?? 2
|
|
1486
|
+
let width = CGFloat(authoredWidth)
|
|
1487
|
+
guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
|
|
1488
|
+
let fill = Self.color(childProps["fillColor"] as? String).withAlphaComponent(0.28)
|
|
1489
|
+
let stroke = Self.color(childProps["strokeColor"] as? String)
|
|
1490
|
+
nextPolygons[id] = (points, fill, stroke, width)
|
|
1265
1491
|
}
|
|
1266
1492
|
}
|
|
1267
1493
|
|
|
@@ -1314,6 +1540,39 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1314
1540
|
overlayWidths[ObjectIdentifier(overlay)] = value.width
|
|
1315
1541
|
addOverlay(overlay)
|
|
1316
1542
|
}
|
|
1543
|
+
let stalePolygons = polygonsByID.keys.filter { nextPolygons[$0] == nil }
|
|
1544
|
+
for id in stalePolygons {
|
|
1545
|
+
if let polygon = polygonsByID.removeValue(forKey: id) {
|
|
1546
|
+
removeOverlay(polygon)
|
|
1547
|
+
polygonFills.removeValue(forKey: ObjectIdentifier(polygon))
|
|
1548
|
+
polygonStrokes.removeValue(forKey: ObjectIdentifier(polygon))
|
|
1549
|
+
polygonWidths.removeValue(forKey: ObjectIdentifier(polygon))
|
|
1550
|
+
}
|
|
1551
|
+
polygonCoordinatesByID.removeValue(forKey: id)
|
|
1552
|
+
}
|
|
1553
|
+
for (id, value) in nextPolygons {
|
|
1554
|
+
if let polygon = polygonsByID[id],
|
|
1555
|
+
let coordinates = polygonCoordinatesByID[id],
|
|
1556
|
+
Self.coordinatesEqual(coordinates, value.coordinates),
|
|
1557
|
+
polygonFills[ObjectIdentifier(polygon)]?.isEqual(value.fill) == true,
|
|
1558
|
+
polygonStrokes[ObjectIdentifier(polygon)]?.isEqual(value.stroke) == true,
|
|
1559
|
+
polygonWidths[ObjectIdentifier(polygon)] == value.width {
|
|
1560
|
+
continue
|
|
1561
|
+
}
|
|
1562
|
+
if let previous = polygonsByID.removeValue(forKey: id) {
|
|
1563
|
+
removeOverlay(previous)
|
|
1564
|
+
polygonFills.removeValue(forKey: ObjectIdentifier(previous))
|
|
1565
|
+
polygonStrokes.removeValue(forKey: ObjectIdentifier(previous))
|
|
1566
|
+
polygonWidths.removeValue(forKey: ObjectIdentifier(previous))
|
|
1567
|
+
}
|
|
1568
|
+
let polygon = MKPolygon(coordinates: value.coordinates, count: value.coordinates.count)
|
|
1569
|
+
polygonsByID[id] = polygon
|
|
1570
|
+
polygonCoordinatesByID[id] = value.coordinates
|
|
1571
|
+
polygonFills[ObjectIdentifier(polygon)] = value.fill
|
|
1572
|
+
polygonStrokes[ObjectIdentifier(polygon)] = value.stroke
|
|
1573
|
+
polygonWidths[ObjectIdentifier(polygon)] = value.width
|
|
1574
|
+
addOverlay(polygon)
|
|
1575
|
+
}
|
|
1317
1576
|
// Keep authored casing/foreground order when an earlier line changes
|
|
1318
1577
|
// or retained children move. Exchanging native overlays preserves the
|
|
1319
1578
|
// unchanged objects, tile levels and current camera.
|
|
@@ -1349,7 +1608,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1349
1608
|
initialCameraKey = key
|
|
1350
1609
|
}
|
|
1351
1610
|
} else if props["fitToBounds"] as? Bool == true {
|
|
1352
|
-
let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates })):\(edgePadding)"
|
|
1611
|
+
let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates }, polygons: nextPolygons.mapValues { $0.coordinates })):\(edgePadding)"
|
|
1353
1612
|
if initialCameraKey != key {
|
|
1354
1613
|
// A native screen can reconcile before UIKit assigns the map
|
|
1355
1614
|
// its final safe-area-constrained bounds. Keep the fit
|
|
@@ -1368,6 +1627,39 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1368
1627
|
applyCameraOrientation()
|
|
1369
1628
|
}
|
|
1370
1629
|
|
|
1630
|
+
/// Release callbacks and native overlays before a map leaves a screen or
|
|
1631
|
+
/// is recycled for another row. `configure` restores the delegate when
|
|
1632
|
+
/// the view is adopted again.
|
|
1633
|
+
func destroy() {
|
|
1634
|
+
delegate = nil
|
|
1635
|
+
onMarkerTap = nil
|
|
1636
|
+
onMapPress = nil
|
|
1637
|
+
onRegionChangeComplete = nil
|
|
1638
|
+
markerHandlers.removeAll()
|
|
1639
|
+
markerBearings.removeAll()
|
|
1640
|
+
markerPinColors.removeAll()
|
|
1641
|
+
removeAnnotations(Array(annotationsByID.values))
|
|
1642
|
+
annotationsByID.removeAll()
|
|
1643
|
+
removeOverlays(Array(overlaysByID.values))
|
|
1644
|
+
overlaysByID.removeAll()
|
|
1645
|
+
overlayCoordinatesByID.removeAll()
|
|
1646
|
+
overlayColors.removeAll()
|
|
1647
|
+
overlayWidths.removeAll()
|
|
1648
|
+
removeOverlays(Array(polygonsByID.values))
|
|
1649
|
+
polygonsByID.removeAll()
|
|
1650
|
+
polygonCoordinatesByID.removeAll()
|
|
1651
|
+
polygonFills.removeAll()
|
|
1652
|
+
polygonStrokes.removeAll()
|
|
1653
|
+
polygonWidths.removeAll()
|
|
1654
|
+
if let tileOverlay {
|
|
1655
|
+
removeOverlay(tileOverlay)
|
|
1656
|
+
self.tileOverlay = nil
|
|
1657
|
+
}
|
|
1658
|
+
tileTemplate = nil
|
|
1659
|
+
tileAttribution.text = nil
|
|
1660
|
+
tileAttribution.isHidden = true
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1371
1663
|
private func configureTiles(_ options: [String: Any]?) {
|
|
1372
1664
|
let template = options?["urlTemplate"] as? String
|
|
1373
1665
|
let credit = options?["attribution"] as? String ?? ""
|
|
@@ -1503,6 +1795,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1503
1795
|
guard bounds.width > 0, bounds.height > 0 else { return false }
|
|
1504
1796
|
var rect = MKMapRect.null
|
|
1505
1797
|
for overlay in overlaysByID.values { rect = rect.union(overlay.boundingMapRect) }
|
|
1798
|
+
for polygon in polygonsByID.values { rect = rect.union(polygon.boundingMapRect) }
|
|
1506
1799
|
for annotation in annotationsByID.values {
|
|
1507
1800
|
let point = MKMapPoint(annotation.coordinate)
|
|
1508
1801
|
rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
|
|
@@ -1543,6 +1836,13 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1543
1836
|
|
|
1544
1837
|
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
|
|
1545
1838
|
if let tiles = overlay as? MKTileOverlay { return MKTileOverlayRenderer(tileOverlay: tiles) }
|
|
1839
|
+
if let polygon = overlay as? MKPolygon {
|
|
1840
|
+
let renderer = MKPolygonRenderer(polygon: polygon)
|
|
1841
|
+
renderer.fillColor = polygonFills[ObjectIdentifier(polygon)] ?? UIColor.systemBlue.withAlphaComponent(0.28)
|
|
1842
|
+
renderer.strokeColor = polygonStrokes[ObjectIdentifier(polygon)] ?? .systemBlue
|
|
1843
|
+
renderer.lineWidth = polygonWidths[ObjectIdentifier(polygon)] ?? 2
|
|
1844
|
+
return renderer
|
|
1845
|
+
}
|
|
1546
1846
|
let renderer = MKPolylineRenderer(overlay: overlay)
|
|
1547
1847
|
renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
|
|
1548
1848
|
renderer.lineWidth = overlayWidths[ObjectIdentifier(overlay)] ?? 3
|
|
@@ -1575,7 +1875,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1575
1875
|
|
|
1576
1876
|
private static func overlayKey(
|
|
1577
1877
|
markers: [String: CLLocationCoordinate2D],
|
|
1578
|
-
polylines: [String: [CLLocationCoordinate2D]]
|
|
1878
|
+
polylines: [String: [CLLocationCoordinate2D]],
|
|
1879
|
+
polygons: [String: [CLLocationCoordinate2D]]
|
|
1579
1880
|
) -> String {
|
|
1580
1881
|
let markerKey = markers.keys.sorted().compactMap { id in
|
|
1581
1882
|
guard let point = markers[id] else { return nil }
|
|
@@ -1585,7 +1886,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1585
1886
|
guard let points = polylines[id] else { return nil }
|
|
1586
1887
|
return "\(id):" + points.map { "\($0.latitude),\($0.longitude)" }.joined(separator: ";")
|
|
1587
1888
|
}.joined(separator: "|")
|
|
1588
|
-
|
|
1889
|
+
let polygonKey = polygons.keys.sorted().compactMap { id in
|
|
1890
|
+
guard let points = polygons[id] else { return nil }
|
|
1891
|
+
return "\(id):" + points.map { "\($0.latitude),\($0.longitude)" }.joined(separator: ";")
|
|
1892
|
+
}.joined(separator: "|")
|
|
1893
|
+
return "m[\(markerKey)]p[\(polylineKey)]g[\(polygonKey)]"
|
|
1589
1894
|
}
|
|
1590
1895
|
|
|
1591
1896
|
private static func region(_ value: Any?) -> MKCoordinateRegion? {
|
|
@@ -1615,6 +1920,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1615
1920
|
|
|
1616
1921
|
private static func color(_ value: String?) -> UIColor {
|
|
1617
1922
|
switch value?.lowercased() {
|
|
1923
|
+
case "label": return .label
|
|
1924
|
+
case "secondarylabel", "secondary-label": return .secondaryLabel
|
|
1618
1925
|
case "red": return .systemRed
|
|
1619
1926
|
case "green": return .systemGreen
|
|
1620
1927
|
case "orange": return .systemOrange
|
|
@@ -1726,6 +2033,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
1726
2033
|
let marginLeft: CGFloat
|
|
1727
2034
|
let display: String
|
|
1728
2035
|
let gridColumns: Int
|
|
2036
|
+
let gridRows: Int?
|
|
1729
2037
|
let gridAutoRows: CraftNativeLength?
|
|
1730
2038
|
|
|
1731
2039
|
static let empty = CraftNativeLayoutStyle([:])
|
|
@@ -1767,6 +2075,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
1767
2075
|
marginLeft = number(raw["marginLeft"]) ?? horizontalMargin
|
|
1768
2076
|
display = raw["display"] as? String ?? "flex"
|
|
1769
2077
|
gridColumns = craftNativeGridColumnCount(raw["gridTemplateColumns"] ?? raw["gridColumns"])
|
|
2078
|
+
gridRows = craftNativeGridRowCount(raw["gridTemplateRows"])
|
|
1770
2079
|
gridAutoRows = CraftNativeLength(raw["gridAutoRows"])
|
|
1771
2080
|
}
|
|
1772
2081
|
}
|
|
@@ -2276,6 +2585,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2276
2585
|
var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
|
|
2277
2586
|
var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
|
|
2278
2587
|
var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
|
|
2588
|
+
var gridRows: Int? { didSet { if gridRows != oldValue { invalidateMeasurements() } } }
|
|
2279
2589
|
var gridAutoRows: CraftNativeLength? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
|
|
2280
2590
|
var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
|
|
2281
2591
|
var wrapReverse = false { didSet { if wrapReverse != oldValue { invalidateMeasurements() } } }
|
|
@@ -2450,6 +2760,11 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2450
2760
|
|
|
2451
2761
|
func removeLayoutStyle(for view: UIView) {
|
|
2452
2762
|
view.craftLayoutStyle = .empty
|
|
2763
|
+
// Detaching a retained child must invalidate the parent's cached
|
|
2764
|
+
// intrinsic measurements too. The normal arranged-subview path does
|
|
2765
|
+
// this after removal, but callers that clear a style before deciding
|
|
2766
|
+
// whether to detach must not leave the old dimensions memoized.
|
|
2767
|
+
invalidateMeasurements()
|
|
2453
2768
|
}
|
|
2454
2769
|
|
|
2455
2770
|
/// Forgets measured sizes and lays out again on the next pass.
|
|
@@ -2622,6 +2937,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2622
2937
|
padding: nested?.effectivePadding ?? .zero,
|
|
2623
2938
|
grid: nested?.grid ?? false,
|
|
2624
2939
|
gridColumns: nested?.gridColumns ?? 1,
|
|
2940
|
+
gridRows: nested?.gridRows,
|
|
2625
2941
|
gridAutoRows: nested?.gridAutoRows,
|
|
2626
2942
|
reverse: nested?.reverse ?? false,
|
|
2627
2943
|
wrapReverse: nested?.wrapReverse ?? false,
|
|
@@ -2645,6 +2961,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2645
2961
|
padding: effectivePadding,
|
|
2646
2962
|
grid: grid,
|
|
2647
2963
|
gridColumns: gridColumns,
|
|
2964
|
+
gridRows: gridRows,
|
|
2648
2965
|
gridAutoRows: gridAutoRows,
|
|
2649
2966
|
reverse: reverse,
|
|
2650
2967
|
wrapReverse: wrapReverse,
|
|
@@ -2677,6 +2994,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2677
2994
|
padding: nested?.effectivePadding ?? .zero,
|
|
2678
2995
|
grid: nested?.grid ?? false,
|
|
2679
2996
|
gridColumns: nested?.gridColumns ?? 1,
|
|
2997
|
+
gridRows: nested?.gridRows,
|
|
2680
2998
|
gridAutoRows: nested?.gridAutoRows,
|
|
2681
2999
|
reverse: nested?.reverse ?? false,
|
|
2682
3000
|
wrapReverse: nested?.wrapReverse ?? false,
|
|
@@ -2700,6 +3018,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2700
3018
|
padding: effectivePadding,
|
|
2701
3019
|
grid: grid,
|
|
2702
3020
|
gridColumns: gridColumns,
|
|
3021
|
+
gridRows: gridRows,
|
|
2703
3022
|
gridAutoRows: gridAutoRows,
|
|
2704
3023
|
reverse: reverse,
|
|
2705
3024
|
wrapReverse: wrapReverse,
|
|
@@ -3249,10 +3568,17 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
3249
3568
|
rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
|
|
3250
3569
|
}
|
|
3251
3570
|
let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
|
|
3571
|
+
if let explicitRows = gridRows, let innerHeight {
|
|
3572
|
+
let count = max(explicitRows, rowHeights.count)
|
|
3573
|
+
while rowHeights.count < count { rowHeights.append(0) }
|
|
3574
|
+
let available = max(0, innerHeight - CGFloat(max(0, count - 1)) * verticalGap)
|
|
3575
|
+
let equal = available / CGFloat(max(1, count))
|
|
3576
|
+
rowHeights = rowHeights.map { max($0, equal) }
|
|
3577
|
+
}
|
|
3252
3578
|
let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
3253
3579
|
// Older runtimes return nil for the optional symbol; the local layout
|
|
3254
3580
|
// below remains the compatibility path in that case.
|
|
3255
|
-
let sharedFrames = CraftNativeGridBridge.frames(
|
|
3581
|
+
let sharedFrames = gridRows == nil ? CraftNativeGridBridge.frames(
|
|
3256
3582
|
availableWidth: innerWidth ?? width,
|
|
3257
3583
|
availableHeight: innerHeight ?? height,
|
|
3258
3584
|
columns: columns,
|
|
@@ -3280,7 +3606,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
3280
3606
|
},
|
|
3281
3607
|
autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 },
|
|
3282
3608
|
alignContent: craftNativeAlignContentCode(alignContent)
|
|
3283
|
-
)
|
|
3609
|
+
) : nil
|
|
3284
3610
|
if place {
|
|
3285
3611
|
if let sharedFrames {
|
|
3286
3612
|
for (index, child) in children.enumerated() {
|
|
@@ -3457,13 +3783,14 @@ private final class CraftNativeImageBackgroundView: UIImageView {
|
|
|
3457
3783
|
}
|
|
3458
3784
|
}
|
|
3459
3785
|
|
|
3460
|
-
private final class CraftNativeModalView: UIView {
|
|
3786
|
+
private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
3461
3787
|
let contentStack = CraftNativeFlowView()
|
|
3462
3788
|
private let blocker = UIView()
|
|
3789
|
+
private let backdropTap = UITapGestureRecognizer()
|
|
3463
3790
|
private var renderedVisible = false
|
|
3464
3791
|
private var dismissSuppressedByRequest = false
|
|
3465
3792
|
private var animationGeneration = 0
|
|
3466
|
-
var onRequestClose: (() -> Void)?
|
|
3793
|
+
var onRequestClose: ((String) -> Void)?
|
|
3467
3794
|
|
|
3468
3795
|
var transparent = false {
|
|
3469
3796
|
didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
|
|
@@ -3474,7 +3801,10 @@ private final class CraftNativeModalView: UIView {
|
|
|
3474
3801
|
isUserInteractionEnabled = true
|
|
3475
3802
|
blocker.isUserInteractionEnabled = true
|
|
3476
3803
|
blocker.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
|
3477
|
-
|
|
3804
|
+
backdropTap.addTarget(self, action: #selector(backdropTapped))
|
|
3805
|
+
backdropTap.cancelsTouchesInView = false
|
|
3806
|
+
backdropTap.delegate = self
|
|
3807
|
+
addGestureRecognizer(backdropTap)
|
|
3478
3808
|
addSubview(blocker)
|
|
3479
3809
|
contentStack.backgroundColor = .clear
|
|
3480
3810
|
addSubview(contentStack)
|
|
@@ -3551,11 +3881,11 @@ private final class CraftNativeModalView: UIView {
|
|
|
3551
3881
|
onRequestClose = nil
|
|
3552
3882
|
}
|
|
3553
3883
|
|
|
3554
|
-
func requestClose() {
|
|
3884
|
+
func requestClose(reason: String = "backdrop") {
|
|
3555
3885
|
guard let onRequestClose else { return }
|
|
3556
3886
|
dismissSuppressedByRequest = true
|
|
3557
3887
|
isUserInteractionEnabled = false
|
|
3558
|
-
onRequestClose()
|
|
3888
|
+
onRequestClose(reason)
|
|
3559
3889
|
}
|
|
3560
3890
|
|
|
3561
3891
|
func consumeDismissSuppression() -> Bool {
|
|
@@ -3566,7 +3896,35 @@ private final class CraftNativeModalView: UIView {
|
|
|
3566
3896
|
|
|
3567
3897
|
func clearDismissSuppression() { dismissSuppressedByRequest = false }
|
|
3568
3898
|
|
|
3569
|
-
@objc private func
|
|
3899
|
+
@objc private func backdropTapped() { requestClose(reason: "backdrop") }
|
|
3900
|
+
|
|
3901
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
3902
|
+
guard gestureRecognizer === backdropTap else { return true }
|
|
3903
|
+
var candidate = touch.view
|
|
3904
|
+
while let view = candidate, view !== self {
|
|
3905
|
+
if view === blocker { return true }
|
|
3906
|
+
if view is UIControl || !(view.gestureRecognizers?.isEmpty ?? true) { return false }
|
|
3907
|
+
candidate = view.superview
|
|
3908
|
+
}
|
|
3909
|
+
return true
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
3913
|
+
guard !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
|
|
3914
|
+
let contentPoint = contentStack.convert(point, from: self)
|
|
3915
|
+
if contentStack.bounds.contains(contentPoint), let hit = contentStack.hitTest(contentPoint, with: event), hit !== contentStack {
|
|
3916
|
+
// Flow/layout views occupy the modal's entire content rect, but a
|
|
3917
|
+
// blank part of one must still be a scrim tap. Preserve touches
|
|
3918
|
+
// only when the hit view (or one of its ancestors) actually owns
|
|
3919
|
+
// a control or gesture recognizer.
|
|
3920
|
+
var candidate: UIView? = hit
|
|
3921
|
+
while let view = candidate, view !== contentStack {
|
|
3922
|
+
if view is UIControl || !(view.gestureRecognizers?.isEmpty ?? true) { return hit }
|
|
3923
|
+
candidate = view.superview
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
return blocker.hitTest(blocker.convert(point, from: self), with: event)
|
|
3927
|
+
}
|
|
3570
3928
|
|
|
3571
3929
|
override func layoutSubviews() {
|
|
3572
3930
|
super.layoutSubviews()
|
|
@@ -3618,6 +3976,11 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
3618
3976
|
|
|
3619
3977
|
override init(frame: CGRect) {
|
|
3620
3978
|
super.init(frame: frame)
|
|
3979
|
+
// Let native controls finish tracking taps before the scroll view decides
|
|
3980
|
+
// whether the gesture is a drag. This matches UIKit's table-view
|
|
3981
|
+
// behavior and prevents switches, sliders, and date pickers from
|
|
3982
|
+
// losing most taps when they live inside a ScrollView.
|
|
3983
|
+
delaysContentTouches = false
|
|
3621
3984
|
keyboardTapGesture.cancelsTouchesInView = false
|
|
3622
3985
|
keyboardTapGesture.delegate = self
|
|
3623
3986
|
keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
|
|
@@ -3629,6 +3992,11 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
3629
3992
|
|
|
3630
3993
|
required init?(coder: NSCoder) { nil }
|
|
3631
3994
|
|
|
3995
|
+
override func touchesShouldCancel(in view: UIView) -> Bool {
|
|
3996
|
+
if view is UIControl { return false }
|
|
3997
|
+
return super.touchesShouldCancel(in: view)
|
|
3998
|
+
}
|
|
3999
|
+
|
|
3632
4000
|
var isVertical: Bool { contentStack.axis == .vertical }
|
|
3633
4001
|
|
|
3634
4002
|
/// Lays the content out again on the next pass. Scrolling alone does not:
|
|
@@ -4266,6 +4634,35 @@ private final class CraftNativeLabel: UILabel {
|
|
|
4266
4634
|
}
|
|
4267
4635
|
}
|
|
4268
4636
|
|
|
4637
|
+
/// A non-editable text view used when a native Text explicitly opts into
|
|
4638
|
+
/// selection. UILabel has no selection model; UITextView supplies UIKit's
|
|
4639
|
+
/// standard loupe, menu and copy behavior while remaining a plain text leaf.
|
|
4640
|
+
private final class CraftNativeSelectableTextView: UITextView {
|
|
4641
|
+
convenience init() {
|
|
4642
|
+
self.init(frame: .zero, textContainer: nil)
|
|
4643
|
+
}
|
|
4644
|
+
|
|
4645
|
+
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
4646
|
+
super.init(frame: frame, textContainer: textContainer)
|
|
4647
|
+
isEditable = false
|
|
4648
|
+
isSelectable = true
|
|
4649
|
+
isScrollEnabled = false
|
|
4650
|
+
backgroundColor = .clear
|
|
4651
|
+
textContainerInset = .zero
|
|
4652
|
+
self.textContainer.lineFragmentPadding = 0
|
|
4653
|
+
}
|
|
4654
|
+
|
|
4655
|
+
required init?(coder: NSCoder) {
|
|
4656
|
+
super.init(coder: coder)
|
|
4657
|
+
isEditable = false
|
|
4658
|
+
isSelectable = true
|
|
4659
|
+
isScrollEnabled = false
|
|
4660
|
+
backgroundColor = .clear
|
|
4661
|
+
textContainerInset = .zero
|
|
4662
|
+
self.textContainer.lineFragmentPadding = 0
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4269
4666
|
/// A one-line TextInput. Its style's padding is where the text and the
|
|
4270
4667
|
/// placeholder start, as in a web input; UITextField has no padding of its own.
|
|
4271
4668
|
final class CraftNativeTextField: UITextField {
|
|
@@ -4302,6 +4699,22 @@ private final class CraftNativeFlexSpacer: UIView {}
|
|
|
4302
4699
|
private final class CraftNativeIconView: UIImageView {
|
|
4303
4700
|
var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
|
|
4304
4701
|
|
|
4702
|
+
/// An icon says only what the screen labels it. iOS gives a symbol image
|
|
4703
|
+
/// a description of its own ("Circle", "Chevron"), which VoiceOver read
|
|
4704
|
+
/// as an extra stop and a row read as part of its text; an unlabelled
|
|
4705
|
+
/// icon is decoration, as UIKit's own glyph accessories are.
|
|
4706
|
+
private var authoredLabel: String?
|
|
4707
|
+
|
|
4708
|
+
override var accessibilityLabel: String? {
|
|
4709
|
+
get { authoredLabel }
|
|
4710
|
+
set { authoredLabel = newValue }
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
override var isAccessibilityElement: Bool {
|
|
4714
|
+
get { authoredLabel != nil && super.isAccessibilityElement }
|
|
4715
|
+
set { super.isAccessibilityElement = newValue }
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4305
4718
|
override var intrinsicContentSize: CGSize {
|
|
4306
4719
|
if let defaultBox { return CGSize(width: defaultBox, height: defaultBox) }
|
|
4307
4720
|
return super.intrinsicContentSize
|
|
@@ -4511,9 +4924,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4511
4924
|
}
|
|
4512
4925
|
}
|
|
4513
4926
|
|
|
4927
|
+
// A reparented popup must keep the live native view that owns its draft,
|
|
4928
|
+
// focus and selection. The mutation protocol identifies nodes globally;
|
|
4929
|
+
// retain the previous render by that ID when the new parent has no local
|
|
4930
|
+
// identity entry for it.
|
|
4931
|
+
private var previousNodesByProtocolID: [String: RenderedNode] = [:]
|
|
4932
|
+
private var adoptedPreviousProtocolIDs = Set<String>()
|
|
4933
|
+
|
|
4514
4934
|
private let config: CraftConfig
|
|
4515
4935
|
private let routeName: String?
|
|
4516
4936
|
private(set) var routeParams: [String: Any]
|
|
4937
|
+
private(set) var routeProps: [String: Any]
|
|
4517
4938
|
private let jsContext = JSContext(virtualMachine: CraftNativeBundle.virtualMachine)!
|
|
4518
4939
|
private let rootStack = CraftNativeFlowView()
|
|
4519
4940
|
private var handlers: [ObjectIdentifier: String] = [:]
|
|
@@ -4538,6 +4959,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4538
4959
|
private var sliderSteps: [ObjectIdentifier: Float] = [:]
|
|
4539
4960
|
private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
|
|
4540
4961
|
private var modalVisibility: [ObjectIdentifier: Bool] = [:]
|
|
4962
|
+
private var modalFocusRestore: [ObjectIdentifier: UIView] = [:]
|
|
4541
4963
|
private var layoutHandlers: [ObjectIdentifier: String] = [:]
|
|
4542
4964
|
private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
|
|
4543
4965
|
private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
|
|
@@ -4595,10 +5017,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4595
5017
|
/// A request a screen-owned module answers, by request token, and how to
|
|
4596
5018
|
/// call it off when it times out or is cancelled.
|
|
4597
5019
|
private var screenRequestCancellers: [String: () -> Void] = [:]
|
|
4598
|
-
///
|
|
4599
|
-
|
|
4600
|
-
///
|
|
4601
|
-
|
|
5020
|
+
/// Whether the script was last told the screen is on screen; nil until the
|
|
5021
|
+
/// first report. A screen first reported hidden - an adjacent tab the
|
|
5022
|
+
/// shell prepares ahead of a swipe - must hear that, or its script takes
|
|
5023
|
+
/// itself to be in front and acts on it: a sign-in guard on a prepared
|
|
5024
|
+
/// Record tab pushed Login over the Feed at launch.
|
|
5025
|
+
private var reportedVisible: Bool?
|
|
4602
5026
|
private let capabilityScope = UUID().uuidString
|
|
4603
5027
|
private var pendingCapabilityRequests = Set<String>()
|
|
4604
5028
|
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
@@ -4624,10 +5048,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4624
5048
|
/// `DEEP_LINK`; it can open paths but not push, pop or style a bar.
|
|
4625
5049
|
let isAppScript: Bool
|
|
4626
5050
|
|
|
4627
|
-
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:], isAppScript: Bool = false) {
|
|
5051
|
+
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:], routeProps: [String: Any] = [:], isAppScript: Bool = false) {
|
|
4628
5052
|
self.config = config
|
|
4629
5053
|
self.routeName = routeName
|
|
4630
5054
|
self.routeParams = routeParams
|
|
5055
|
+
self.routeProps = routeProps
|
|
4631
5056
|
self.isAppScript = isAppScript
|
|
4632
5057
|
super.init(nibName: nil, bundle: nil)
|
|
4633
5058
|
}
|
|
@@ -4783,8 +5208,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4783
5208
|
/// The screen came on screen or went off it: a screen pushed over it was
|
|
4784
5209
|
/// popped, or the hybrid shell switched to or away from its tab (which
|
|
4785
5210
|
/// hides a stack rather than moving it, so UIKit says nothing). The first
|
|
4786
|
-
/// appearance is the
|
|
4787
|
-
/// `SCREEN_FOCUS { focused: true }`, and going away is
|
|
5211
|
+
/// appearance of a screen shown as it mounts is the mount, not news; every
|
|
5212
|
+
/// later one is `SCREEN_FOCUS { focused: true }`, and going away is
|
|
5213
|
+
/// `focused: false`. A screen that mounts hidden is told `focused: false`
|
|
5214
|
+
/// first, so its first showing is news too.
|
|
4788
5215
|
func screenVisibilityChanged(_ visible: Bool) {
|
|
4789
5216
|
guard !runtimeDisposed else { return }
|
|
4790
5217
|
if !visible, isViewLoaded { stopCelebrations(in: view) }
|
|
@@ -4793,31 +5220,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4793
5220
|
// chrome even when no new SCREEN_FOCUS event is needed.
|
|
4794
5221
|
if visible { restoreShellChrome() }
|
|
4795
5222
|
guard visible != reportedVisible else { return }
|
|
5223
|
+
let first = reportedVisible == nil
|
|
4796
5224
|
reportedVisible = visible
|
|
4797
5225
|
if visible { DeepLinkManager.shared.replayNativeLinks() }
|
|
4798
|
-
if
|
|
4799
|
-
hasAppeared = true
|
|
4800
|
-
return
|
|
4801
|
-
}
|
|
5226
|
+
if first, visible { return }
|
|
4802
5227
|
send(type: "SCREEN_FOCUS", payload: ["focused": visible])
|
|
4803
5228
|
}
|
|
4804
5229
|
|
|
4805
|
-
/// New params for the screen already showing a path: opened
|
|
4806
|
-
/// another query
|
|
4807
|
-
///
|
|
4808
|
-
|
|
4809
|
-
/// starts with them.
|
|
4810
|
-
func receiveRouteParams(_ params: [String: Any]) {
|
|
5230
|
+
/// New params or props for the screen already showing a path: opened
|
|
5231
|
+
/// again with another query or a structured handoff. The two namespaces
|
|
5232
|
+
/// stay separate so props cannot collide with path/query params.
|
|
5233
|
+
func receiveRouteParams(_ params: [String: Any], props: [String: Any] = [:]) {
|
|
4811
5234
|
routeParams = params
|
|
5235
|
+
routeProps = props
|
|
4812
5236
|
guard isViewLoaded, !runtimeDisposed else { return }
|
|
4813
5237
|
let context = jsContext
|
|
4814
|
-
CraftNativeScriptThread.run {
|
|
4815
|
-
|
|
5238
|
+
CraftNativeScriptThread.run {
|
|
5239
|
+
context.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
5240
|
+
context.setObject(props as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
|
|
5241
|
+
}
|
|
5242
|
+
send(type: "ROUTE_PARAMS", payload: ["params": params, "props": props])
|
|
4816
5243
|
}
|
|
4817
5244
|
|
|
4818
5245
|
private func disposeRuntime() {
|
|
4819
5246
|
guard !runtimeDisposed else { return }
|
|
4820
5247
|
runtimeDisposed = true
|
|
5248
|
+
disposeRenderedTree()
|
|
4821
5249
|
let context = jsContext
|
|
4822
5250
|
CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
|
|
4823
5251
|
audio.close()
|
|
@@ -4827,6 +5255,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4827
5255
|
screenRequestCancellers.removeAll()
|
|
4828
5256
|
}
|
|
4829
5257
|
|
|
5258
|
+
/// Runtime disposal happens before UIKit necessarily deallocates the
|
|
5259
|
+
/// controller. Release the rendered tree now so recycled rows, native
|
|
5260
|
+
/// delegates, media, and map callbacks cannot outlive their screen.
|
|
5261
|
+
private func disposeRenderedTree() {
|
|
5262
|
+
guard let root = renderedRoot else { return }
|
|
5263
|
+
forgetHandlers(root)
|
|
5264
|
+
rootStack.removeArrangedSubview(root.view)
|
|
5265
|
+
root.view.removeFromSuperview()
|
|
5266
|
+
renderedRoot = nil
|
|
5267
|
+
flatListRows.removeAll()
|
|
5268
|
+
flatListRecycled.removeAll()
|
|
5269
|
+
flatListOwners.removeAll()
|
|
5270
|
+
svgOwners.removeAll()
|
|
5271
|
+
}
|
|
5272
|
+
|
|
4830
5273
|
private var appliedTraitDefaults: String?
|
|
4831
5274
|
|
|
4832
5275
|
private func refreshTraitDefaults() {
|
|
@@ -4847,6 +5290,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4847
5290
|
|
|
4848
5291
|
private func refreshTraitDefaults(in node: RenderedNode?) {
|
|
4849
5292
|
guard let node else { return }
|
|
5293
|
+
(node.view as? CraftNativeMapView)?.refreshThemeDefaults()
|
|
4850
5294
|
(node.view as? CraftNativeFlatList)?.refreshThemeDefaults()
|
|
4851
5295
|
node.children.forEach { refreshTraitDefaults(in: $0) }
|
|
4852
5296
|
}
|
|
@@ -4908,6 +5352,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4908
5352
|
jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
|
|
4909
5353
|
}
|
|
4910
5354
|
jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
5355
|
+
jsContext.setObject(routeProps as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
|
|
5356
|
+
// Hidden before its script ran: the SCREEN_FOCUS saying so reached no
|
|
5357
|
+
// callback, so the script reads it as it starts instead.
|
|
5358
|
+
if reportedVisible == false {
|
|
5359
|
+
jsContext.setObject(false, forKeyedSubscript: "__stxNativeFocused" as NSString)
|
|
5360
|
+
}
|
|
4911
5361
|
jsContext.exceptionHandler = { [weak self] context, exception in
|
|
4912
5362
|
// Never stringify an arbitrary JS object here. JavaScriptCore can
|
|
4913
5363
|
// re-enter this handler while coercing an exception whose
|
|
@@ -4998,6 +5448,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4998
5448
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
4999
5449
|
};
|
|
5000
5450
|
""")
|
|
5451
|
+
jsContext.evaluateScript(Self.nativeWebAPICompatibilityPrelude)
|
|
5001
5452
|
installHostAPIs()
|
|
5002
5453
|
jsContext.evaluateScript("""
|
|
5003
5454
|
if (typeof globalThis.setTimeout !== 'function') {
|
|
@@ -5054,6 +5505,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5054
5505
|
""")
|
|
5055
5506
|
}
|
|
5056
5507
|
|
|
5508
|
+
/// JavaScriptCore has the language core but not browser URL/encoding
|
|
5509
|
+
/// globals. Keep their absence actionable for shared modules instead of
|
|
5510
|
+
/// surfacing an opaque ReferenceError from an unrelated load path. Full
|
|
5511
|
+
/// implementations belong to the shared STX runtime.
|
|
5512
|
+
private static let nativeWebAPICompatibilityPrelude = """
|
|
5513
|
+
(function() {
|
|
5514
|
+
function unavailable(name, hint) {
|
|
5515
|
+
var failure = function() {
|
|
5516
|
+
throw new ReferenceError(name + ' is not available on a native screen — ' + hint);
|
|
5517
|
+
};
|
|
5518
|
+
failure.prototype.constructor = failure;
|
|
5519
|
+
return failure;
|
|
5520
|
+
}
|
|
5521
|
+
if (typeof globalThis.URL === 'undefined') globalThis.URL = unavailable('URL', 'use an absolute URL or build the path with encodeURIComponent');
|
|
5522
|
+
if (typeof globalThis.URLSearchParams === 'undefined') globalThis.URLSearchParams = unavailable('URLSearchParams', 'build query strings with encodeURIComponent');
|
|
5523
|
+
if (typeof globalThis.TextEncoder === 'undefined') globalThis.TextEncoder = unavailable('TextEncoder', 'use a native-supported encoding helper');
|
|
5524
|
+
})();
|
|
5525
|
+
"""
|
|
5526
|
+
|
|
5057
5527
|
/// `value` as a JavaScript string literal (JSON), quotes and all.
|
|
5058
5528
|
static func jsonString(_ value: String) -> String {
|
|
5059
5529
|
guard let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
@@ -5222,14 +5692,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5222
5692
|
}));
|
|
5223
5693
|
});
|
|
5224
5694
|
}
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5695
|
+
// Native capability ownership wins over any browser-shaped
|
|
5696
|
+
// dialog object the bundle installed before the bridge ran.
|
|
5697
|
+
// Leaving the old guard here sends actionSheet through the
|
|
5698
|
+
// DOM fallback even though this screen can present UIKit.
|
|
5699
|
+
function installNativeDialog() {
|
|
5700
|
+
var craft = globalThis.craft = globalThis.craft || {};
|
|
5701
|
+
if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('dialog') >= 0) {
|
|
5702
|
+
craft.dialog = {
|
|
5703
|
+
alert: function(options) { return request('alert', [options || {}]).then(function() {}); },
|
|
5704
|
+
confirm: function(options) { return request('confirm', [options || {}]).then(Boolean); },
|
|
5705
|
+
actionSheet: function(options) { return request('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); }
|
|
5706
|
+
};
|
|
5707
|
+
}
|
|
5232
5708
|
}
|
|
5709
|
+
globalThis.__craftInstallNativeDialog = installNativeDialog;
|
|
5710
|
+
installNativeDialog();
|
|
5233
5711
|
})();
|
|
5234
5712
|
""")
|
|
5235
5713
|
// `AudioContext` and the rest of Web Audio, rendered natively.
|
|
@@ -5443,6 +5921,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5443
5921
|
jsContext.evaluateScript(runtime.source, withSourceURL: runtime.url)
|
|
5444
5922
|
}
|
|
5445
5923
|
jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
|
|
5924
|
+
// The STX runtime may install its browser-shaped craft.dialog after
|
|
5925
|
+
// the host prelude. Re-assert the native capability once the bundle
|
|
5926
|
+
// has finished so native screens cannot fall through to a DOM sheet.
|
|
5927
|
+
jsContext.evaluateScript("if (typeof globalThis.__craftInstallNativeDialog === 'function') globalThis.__craftInstallNativeDialog();")
|
|
5446
5928
|
// A bundle that names no route leaves `__stxNativeRoute` undefined;
|
|
5447
5929
|
// its title is the app's, not the word "undefined". A title the
|
|
5448
5930
|
// script set with `navigation.setOptions` wins over both.
|
|
@@ -5943,7 +6425,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5943
6425
|
guard !isAppScript else { return }
|
|
5944
6426
|
deepLinkListener = DeepLinkManager.shared.addNativeListener(canReceive: { [weak self] in
|
|
5945
6427
|
guard let self = self else { return false }
|
|
5946
|
-
return self.reportedVisible && !self.runtimeDisposed && self.navigationController?.topViewController === self
|
|
6428
|
+
return self.reportedVisible == true && !self.runtimeDisposed && self.navigationController?.topViewController === self
|
|
5947
6429
|
}) { [weak self] url, initial in
|
|
5948
6430
|
guard let self = self else { return }
|
|
5949
6431
|
var payload = CraftNativeActions.deepLinkData(url)
|
|
@@ -6155,7 +6637,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6155
6637
|
// A path rather than a screen: the hybrid shell opens it, natively
|
|
6156
6638
|
// or in the web view.
|
|
6157
6639
|
guard let path = payload["path"] as? String, !path.isEmpty else { return }
|
|
6158
|
-
hybridEvents?.nativeScreen(self, open: path)
|
|
6640
|
+
hybridEvents?.nativeScreen(self, open: path, props: payload["props"] as? [String: Any] ?? [:])
|
|
6159
6641
|
case "NAVIGATE_BACK":
|
|
6160
6642
|
if !isAppScript, navigationController?.topViewController === self {
|
|
6161
6643
|
navigationController?.popViewController(animated: true)
|
|
@@ -6255,6 +6737,124 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6255
6737
|
}
|
|
6256
6738
|
let id = args.first as? String ?? ""
|
|
6257
6739
|
switch (module, method) {
|
|
6740
|
+
case ("View", "focus"):
|
|
6741
|
+
let options = args.first as? [String: Any]
|
|
6742
|
+
let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
|
|
6743
|
+
let mode = options?["mode"] as? String ?? (args.count > 1 ? args[1] as? String : nil) ?? "keyboard"
|
|
6744
|
+
guard !nodeId.isEmpty else {
|
|
6745
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.focus needs a nodeId")))
|
|
6746
|
+
return true
|
|
6747
|
+
}
|
|
6748
|
+
guard let node = renderedNode(nodeId) else {
|
|
6749
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_FOUND", message: "View.focus could not find mounted node \(nodeId)")))
|
|
6750
|
+
return true
|
|
6751
|
+
}
|
|
6752
|
+
guard node.view.window != nil else {
|
|
6753
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_MOUNTED", message: "View.focus requires a mounted node")))
|
|
6754
|
+
return true
|
|
6755
|
+
}
|
|
6756
|
+
switch mode {
|
|
6757
|
+
case "keyboard":
|
|
6758
|
+
guard node.view is UITextField || node.view is UITextView else {
|
|
6759
|
+
reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "Keyboard focus is supported only for text inputs")))
|
|
6760
|
+
return true
|
|
6761
|
+
}
|
|
6762
|
+
guard node.view.becomeFirstResponder() else {
|
|
6763
|
+
reply(.failure(CraftNativeActionError(code: "FOCUS_FAILED", message: "The mounted view refused keyboard focus")))
|
|
6764
|
+
return true
|
|
6765
|
+
}
|
|
6766
|
+
lastFocusedInput = node.view
|
|
6767
|
+
reply(.success(true))
|
|
6768
|
+
case "accessibility":
|
|
6769
|
+
UIAccessibility.post(notification: .layoutChanged, argument: node.view)
|
|
6770
|
+
reply(.success(true))
|
|
6771
|
+
default:
|
|
6772
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.focus mode must be keyboard or accessibility")))
|
|
6773
|
+
}
|
|
6774
|
+
return true
|
|
6775
|
+
case ("View", "measure"):
|
|
6776
|
+
let options = args.first as? [String: Any]
|
|
6777
|
+
let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
|
|
6778
|
+
guard !nodeId.isEmpty else {
|
|
6779
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.measure needs a nodeId")))
|
|
6780
|
+
return true
|
|
6781
|
+
}
|
|
6782
|
+
guard let node = renderedNode(nodeId) else {
|
|
6783
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_FOUND", message: "View.measure could not find mounted node \(nodeId)")))
|
|
6784
|
+
return true
|
|
6785
|
+
}
|
|
6786
|
+
guard node.view.window != nil else {
|
|
6787
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_MOUNTED", message: "View.measure requires a mounted node")))
|
|
6788
|
+
return true
|
|
6789
|
+
}
|
|
6790
|
+
view.layoutIfNeeded()
|
|
6791
|
+
let bounds = node.view.convert(node.view.bounds, to: view)
|
|
6792
|
+
guard bounds.origin.x.isFinite, bounds.origin.y.isFinite, bounds.width.isFinite, bounds.height.isFinite else {
|
|
6793
|
+
reply(.failure(CraftNativeActionError(code: "MEASURE_FAILED", message: "The mounted view has no finite layout")))
|
|
6794
|
+
return true
|
|
6795
|
+
}
|
|
6796
|
+
reply(.success([
|
|
6797
|
+
"x": bounds.origin.x,
|
|
6798
|
+
"y": bounds.origin.y,
|
|
6799
|
+
"width": bounds.width,
|
|
6800
|
+
"height": bounds.height,
|
|
6801
|
+
"viewportWidth": view.bounds.width,
|
|
6802
|
+
"viewportHeight": view.bounds.height,
|
|
6803
|
+
"unit": "points",
|
|
6804
|
+
"coordinateSpace": "screenViewport",
|
|
6805
|
+
]))
|
|
6806
|
+
return true
|
|
6807
|
+
case ("View", "scrollTo"):
|
|
6808
|
+
let options = args.first as? [String: Any]
|
|
6809
|
+
let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
|
|
6810
|
+
guard !nodeId.isEmpty else {
|
|
6811
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo needs a nodeId")))
|
|
6812
|
+
return true
|
|
6813
|
+
}
|
|
6814
|
+
guard let node = renderedNode(nodeId) else {
|
|
6815
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_FOUND", message: "View.scrollTo could not find mounted node \(nodeId)")))
|
|
6816
|
+
return true
|
|
6817
|
+
}
|
|
6818
|
+
guard node.view.window != nil else {
|
|
6819
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_MOUNTED", message: "View.scrollTo requires a mounted node")))
|
|
6820
|
+
return true
|
|
6821
|
+
}
|
|
6822
|
+
guard let scrollView = node.view as? UIScrollView else {
|
|
6823
|
+
reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "View.scrollTo is supported only for scroll views")))
|
|
6824
|
+
return true
|
|
6825
|
+
}
|
|
6826
|
+
let x: Double
|
|
6827
|
+
if let raw = options?["x"] {
|
|
6828
|
+
guard let number = craftNativeNumber(raw), number.doubleValue.isFinite else {
|
|
6829
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo coordinates must be finite numbers")))
|
|
6830
|
+
return true
|
|
6831
|
+
}
|
|
6832
|
+
x = number.doubleValue
|
|
6833
|
+
} else {
|
|
6834
|
+
x = scrollView.contentOffset.x
|
|
6835
|
+
}
|
|
6836
|
+
let y: Double
|
|
6837
|
+
if let raw = options?["y"] {
|
|
6838
|
+
guard let number = craftNativeNumber(raw), number.doubleValue.isFinite else {
|
|
6839
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo coordinates must be finite numbers")))
|
|
6840
|
+
return true
|
|
6841
|
+
}
|
|
6842
|
+
y = number.doubleValue
|
|
6843
|
+
} else {
|
|
6844
|
+
y = scrollView.contentOffset.y
|
|
6845
|
+
}
|
|
6846
|
+
guard x.isFinite, y.isFinite else {
|
|
6847
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo coordinates must be finite numbers")))
|
|
6848
|
+
return true
|
|
6849
|
+
}
|
|
6850
|
+
let minX = -scrollView.adjustedContentInset.left
|
|
6851
|
+
let minY = -scrollView.adjustedContentInset.top
|
|
6852
|
+
let maxX = max(minX, scrollView.contentSize.width - scrollView.bounds.width + scrollView.adjustedContentInset.right)
|
|
6853
|
+
let maxY = max(minY, scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom)
|
|
6854
|
+
let target = CGPoint(x: min(max(x, minX), maxX), y: min(max(y, minY), maxY))
|
|
6855
|
+
scrollView.setContentOffset(target, animated: options?["animated"] as? Bool ?? false)
|
|
6856
|
+
reply(.success(["x": target.x, "y": target.y, "unit": "points"]))
|
|
6857
|
+
return true
|
|
6258
6858
|
case ("Network", "stream"):
|
|
6259
6859
|
let options = args.first as? [String: Any] ?? [:]
|
|
6260
6860
|
let streamId = options["id"] as? String ?? ""
|
|
@@ -6398,7 +6998,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6398
6998
|
statusBarStyleOverride = nil
|
|
6399
6999
|
statusBarHiddenOverride = nil
|
|
6400
7000
|
let previous = renderedRoot
|
|
7001
|
+
previousNodesByProtocolID.removeAll(keepingCapacity: true)
|
|
7002
|
+
adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
|
|
7003
|
+
indexPreviousNodes(previous)
|
|
6401
7004
|
let next = reconcile(document, identity: "root", path: "root", previous: previous)
|
|
7005
|
+
previousNodesByProtocolID.removeAll(keepingCapacity: true)
|
|
7006
|
+
adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
|
|
6402
7007
|
if previous?.view !== next.view {
|
|
6403
7008
|
if let old = previous { detach(old, from: rootStack) }
|
|
6404
7009
|
}
|
|
@@ -6460,6 +7065,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6460
7065
|
private func canReuseView(_ node: [String: Any], previous: RenderedNode) -> Bool {
|
|
6461
7066
|
let type = canonicalType(node["type"] as? String ?? "View")
|
|
6462
7067
|
guard previous.type == type else { return false }
|
|
7068
|
+
if type == "Text" {
|
|
7069
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
7070
|
+
return (previous.view is CraftNativeSelectableTextView) == (props["selectable"] as? Bool == true)
|
|
7071
|
+
}
|
|
6463
7072
|
guard type == "TextInput" else { return true }
|
|
6464
7073
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
6465
7074
|
return (previous.view is UITextView) == (props["multiline"] as? Bool == true)
|
|
@@ -6486,16 +7095,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6486
7095
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
6487
7096
|
let events = node["events"] as? [String: String] ?? [:]
|
|
6488
7097
|
let children = node["children"] as? [Any] ?? []
|
|
7098
|
+
let protocolId = node["id"] as? String
|
|
7099
|
+
let retained = previous ?? protocolId.flatMap { id in
|
|
7100
|
+
guard !adoptedPreviousProtocolIDs.contains(id) else { return nil }
|
|
7101
|
+
return previousNodesByProtocolID[id]
|
|
7102
|
+
}
|
|
7103
|
+
let adopted = previous == nil && retained != nil
|
|
6489
7104
|
let current: RenderedNode
|
|
6490
|
-
if let
|
|
6491
|
-
current =
|
|
7105
|
+
if let retained, (retained.identity == identity || adopting || adopted), canReuseView(node, previous: retained) {
|
|
7106
|
+
current = retained
|
|
7107
|
+
if let protocolId { adoptedPreviousProtocolIDs.insert(protocolId) }
|
|
6492
7108
|
current.identity = identity
|
|
6493
7109
|
} else {
|
|
6494
7110
|
current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
|
|
6495
7111
|
}
|
|
6496
7112
|
|
|
6497
7113
|
let result = current.view
|
|
6498
|
-
current.protocolId =
|
|
7114
|
+
current.protocolId = protocolId
|
|
6499
7115
|
current.style = style
|
|
6500
7116
|
let linkTarget = ["href", "to"].lazy.compactMap { props[$0] as? String }
|
|
6501
7117
|
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
|
|
@@ -6529,11 +7145,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6529
7145
|
result.isHidden = true
|
|
6530
7146
|
result.isUserInteractionEnabled = false
|
|
6531
7147
|
case "Text":
|
|
6532
|
-
let
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
7148
|
+
let text = children.compactMap { $0 as? String }.joined()
|
|
7149
|
+
if let selectable = result as? CraftNativeSelectableTextView {
|
|
7150
|
+
selectable.textContainer.maximumNumberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
|
|
7151
|
+
selectable.textContainer.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
7152
|
+
configureSelectableText(selectable, text: text, style: style)
|
|
7153
|
+
} else {
|
|
7154
|
+
let label = result as! UILabel
|
|
7155
|
+
label.numberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
|
|
7156
|
+
label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
7157
|
+
(label as? CraftNativeLabel)?.insets = paddingInsets(style)
|
|
7158
|
+
configureText(label, text: text, style: style)
|
|
7159
|
+
}
|
|
6537
7160
|
case "Button", "Link":
|
|
6538
7161
|
let button = result as! UIButton
|
|
6539
7162
|
let title = props["title"] as? String ?? children.compactMap { $0 as? String }.joined()
|
|
@@ -6704,6 +7327,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6704
7327
|
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
|
|
6705
7328
|
case "Modal":
|
|
6706
7329
|
let modal = result as! CraftNativeModalView
|
|
7330
|
+
let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
6707
7331
|
modal.transparent = props["transparent"] as? Bool == true
|
|
6708
7332
|
let visible: Bool
|
|
6709
7333
|
if let propVisible = props["visible"] as? Bool {
|
|
@@ -6718,10 +7342,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6718
7342
|
let modalID = ObjectIdentifier(modal)
|
|
6719
7343
|
let wasVisible = modalVisibility[modalID]
|
|
6720
7344
|
modalVisibility[modalID] = visible
|
|
7345
|
+
if visible, wasVisible != true, let focusBeforeContent,
|
|
7346
|
+
!focusBeforeContent.isDescendant(of: modal) {
|
|
7347
|
+
modalFocusRestore[modalID] = focusBeforeContent
|
|
7348
|
+
}
|
|
6721
7349
|
if visible, wasVisible != true { positionModals() }
|
|
6722
7350
|
modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
|
|
7351
|
+
if !visible, wasVisible == true {
|
|
7352
|
+
let restore = modalFocusRestore.removeValue(forKey: modalID)
|
|
7353
|
+
DispatchQueue.main.async { [weak self, weak restore] in
|
|
7354
|
+
guard let self, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
|
|
7355
|
+
self.lastFocusedInput = restore
|
|
7356
|
+
_ = restore.becomeFirstResponder()
|
|
7357
|
+
}
|
|
7358
|
+
}
|
|
6723
7359
|
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
6724
|
-
{ [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
7360
|
+
{ [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
|
|
6725
7361
|
}
|
|
6726
7362
|
if visible { modal.clearDismissSuppression() }
|
|
6727
7363
|
let suppressDismiss = !visible && modal.consumeDismissSuppression()
|
|
@@ -6744,10 +7380,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6744
7380
|
pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
|
|
6745
7381
|
case "Switch":
|
|
6746
7382
|
let toggle = result as! UISwitch
|
|
6747
|
-
|
|
6748
|
-
//
|
|
6749
|
-
//
|
|
6750
|
-
|
|
7383
|
+
// A supplied value controls the switch; without one, UIKit owns
|
|
7384
|
+
// the state and a reconciliation must not erase a user's tap.
|
|
7385
|
+
// `value: null` deliberately falls through to `checked`, matching
|
|
7386
|
+
// the host-neutral precedence used by the other renderer.
|
|
7387
|
+
if let value = props["value"] as? Bool ?? props["checked"] as? Bool {
|
|
7388
|
+
// UIKit has already changed the value before emitting the
|
|
7389
|
+
// event. Echoing it through the script must not restart its
|
|
7390
|
+
// native animation.
|
|
7391
|
+
if toggle.isOn != value { toggle.isOn = value }
|
|
7392
|
+
}
|
|
6751
7393
|
toggle.isEnabled = props["disabled"] as? Bool != true
|
|
6752
7394
|
if let track = props["trackColor"] as? [String: Any] {
|
|
6753
7395
|
toggle.onTintColor = color(track["true"])
|
|
@@ -6795,8 +7437,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6795
7437
|
indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
|
|
6796
7438
|
if props["animating"] as? Bool == false {
|
|
6797
7439
|
indicator.stopAnimating()
|
|
7440
|
+
// UIKit does not retroactively reveal an indicator when an
|
|
7441
|
+
// already-stopped view changes hidesWhenStopped to false.
|
|
7442
|
+
// Android applies the requested visibility on every render;
|
|
7443
|
+
// keep the two hosts in lockstep for dynamic prop changes.
|
|
7444
|
+
indicator.isHidden = indicator.hidesWhenStopped
|
|
6798
7445
|
} else {
|
|
6799
7446
|
indicator.startAnimating()
|
|
7447
|
+
indicator.isHidden = false
|
|
6800
7448
|
}
|
|
6801
7449
|
case "Svg":
|
|
6802
7450
|
let drawing = result as! CraftNativeSvgView
|
|
@@ -6822,7 +7470,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6822
7470
|
if let current = image.image { image.image = imageForDisplay(current, view: image) }
|
|
6823
7471
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6824
7472
|
image.contentMode = imageContentMode(requestedResizeMode)
|
|
6825
|
-
image
|
|
7473
|
+
applyImageClipping(image, contentMode: image.contentMode, style: style)
|
|
6826
7474
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
|
|
6827
7475
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
|
|
6828
7476
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
@@ -6841,7 +7489,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6841
7489
|
if let current = background.image { background.image = imageForDisplay(current, view: background) }
|
|
6842
7490
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6843
7491
|
background.contentMode = imageContentMode(requestedResizeMode)
|
|
6844
|
-
background
|
|
7492
|
+
applyImageClipping(background, contentMode: background.contentMode, style: style)
|
|
6845
7493
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
|
|
6846
7494
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
|
|
6847
7495
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
|
|
@@ -6985,6 +7633,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6985
7633
|
private func makeView(_ type: String, props: [String: Any]) -> UIView {
|
|
6986
7634
|
switch type {
|
|
6987
7635
|
case "Text":
|
|
7636
|
+
if props["selectable"] as? Bool == true {
|
|
7637
|
+
return CraftNativeSelectableTextView()
|
|
7638
|
+
}
|
|
6988
7639
|
let label = CraftNativeLabel()
|
|
6989
7640
|
label.numberOfLines = 0
|
|
6990
7641
|
return label
|
|
@@ -7169,7 +7820,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7169
7820
|
let key = explicitKey(node, props: node["props"] as? [String: Any] ?? [:])
|
|
7170
7821
|
// Keys are local to a parent. Ambiguous sibling keys fall back to
|
|
7171
7822
|
// position so a move never attaches the wrong live control.
|
|
7172
|
-
let
|
|
7823
|
+
let localIdentity = key.flatMap { counts[$0] == 1 ? "key:\($0)" : nil } ?? "index:\(index)"
|
|
7824
|
+
// A keyed row can live in either FlatList or an ordinary
|
|
7825
|
+
// ScrollView. Scope its descendants to the row so two rows cannot
|
|
7826
|
+
// share an uncontrolled input draft or retained native handler.
|
|
7827
|
+
let identity = parent.identity.hasPrefix("list:") || parent.identity.hasPrefix("key:")
|
|
7828
|
+
? "\(parent.identity)/\(localIdentity)"
|
|
7829
|
+
: localIdentity
|
|
7173
7830
|
let child = reconcile(node, identity: identity, path: "\(path).\(index)", previous: old[identity])
|
|
7174
7831
|
next.append(child)
|
|
7175
7832
|
}
|
|
@@ -7302,6 +7959,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7302
7959
|
recycled[ObjectIdentifier(row.view)] = row
|
|
7303
7960
|
self.flatListRecycled[listKey] = recycled
|
|
7304
7961
|
},
|
|
7962
|
+
removeItem: { [weak self] identity in
|
|
7963
|
+
guard let self else { return }
|
|
7964
|
+
let scopedIdentity = "list:\(identity)"
|
|
7965
|
+
if let row = self.flatListRows[listKey]?.removeValue(forKey: identity) {
|
|
7966
|
+
self.forgetHandlers(row)
|
|
7967
|
+
}
|
|
7968
|
+
guard var recycled = self.flatListRecycled[listKey] else { return }
|
|
7969
|
+
let staleViewIDs = recycled.compactMap { viewID, row in
|
|
7970
|
+
row.identity == scopedIdentity ? viewID : nil
|
|
7971
|
+
}
|
|
7972
|
+
for viewID in staleViewIDs {
|
|
7973
|
+
if let row = recycled.removeValue(forKey: viewID) { self.forgetHandlers(row) }
|
|
7974
|
+
}
|
|
7975
|
+
self.flatListRecycled[listKey] = recycled
|
|
7976
|
+
},
|
|
7305
7977
|
endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
|
|
7306
7978
|
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
7307
7979
|
}
|
|
@@ -7333,24 +8005,52 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7333
8005
|
}
|
|
7334
8006
|
|
|
7335
8007
|
private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
|
|
7336
|
-
|
|
8008
|
+
let retainedByReparent = node.protocolId.map { adoptedPreviousProtocolIDs.contains($0) } == true
|
|
8009
|
+
if !retainedByReparent { forgetHandlers(node) }
|
|
7337
8010
|
if node.type == "Modal" {
|
|
7338
|
-
node.view.removeFromSuperview()
|
|
8011
|
+
if node.view.superview === stack { node.view.removeFromSuperview() }
|
|
7339
8012
|
return
|
|
7340
8013
|
}
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
|
|
8014
|
+
if node.view.superview === stack {
|
|
8015
|
+
stack.removeLayoutStyle(for: node.view)
|
|
8016
|
+
stack.removeArrangedSubview(node.view)
|
|
8017
|
+
node.view.removeFromSuperview()
|
|
8018
|
+
}
|
|
8019
|
+
}
|
|
8020
|
+
|
|
8021
|
+
private func indexPreviousNodes(_ node: RenderedNode?) {
|
|
8022
|
+
guard let node else { return }
|
|
8023
|
+
if let id = node.protocolId, !id.isEmpty { previousNodesByProtocolID[id] = node }
|
|
8024
|
+
node.children.forEach { indexPreviousNodes($0) }
|
|
7344
8025
|
}
|
|
7345
8026
|
|
|
7346
8027
|
private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
|
|
7347
8028
|
(node.view as? CraftNativeScrollView)?.setRefreshHandler(nil, refreshing: false)
|
|
8029
|
+
// Recycled list rows keep their native delegates and targets until
|
|
8030
|
+
// they are rebound. A permanently detached node must release those
|
|
8031
|
+
// UIKit hooks as well as the handler maps below, otherwise a picker,
|
|
8032
|
+
// text view, or control can still call back into this screen after
|
|
8033
|
+
// its subtree is gone.
|
|
8034
|
+
if !preservingInputDrafts {
|
|
8035
|
+
if let textView = node.view as? UITextView { textView.delegate = nil }
|
|
8036
|
+
if let picker = node.view as? UIPickerView {
|
|
8037
|
+
picker.delegate = nil
|
|
8038
|
+
picker.dataSource = nil
|
|
8039
|
+
}
|
|
8040
|
+
if let scroll = node.view as? UIScrollView { scroll.delegate = nil }
|
|
8041
|
+
(node.view as? UIControl)?.removeTarget(self, action: nil, for: .allEvents)
|
|
8042
|
+
}
|
|
7348
8043
|
if let list = node.view as? CraftNativeFlatList {
|
|
7349
8044
|
let listKey = ObjectIdentifier(list)
|
|
7350
8045
|
list.setRefreshHandler(nil, refreshing: false)
|
|
7351
8046
|
list.discardAll()
|
|
8047
|
+
if !preservingInputDrafts { list.resetForTeardown() }
|
|
7352
8048
|
flatListRows.removeValue(forKey: listKey)
|
|
7353
|
-
|
|
8049
|
+
if !preservingInputDrafts {
|
|
8050
|
+
flatListRecycled.removeValue(forKey: listKey)?.values.forEach { forgetHandlers($0) }
|
|
8051
|
+
} else {
|
|
8052
|
+
flatListRecycled.removeValue(forKey: listKey)
|
|
8053
|
+
}
|
|
7354
8054
|
if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
|
|
7355
8055
|
}
|
|
7356
8056
|
if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
|
|
@@ -7408,6 +8108,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7408
8108
|
imageErrorHandlers.removeValue(forKey: id)
|
|
7409
8109
|
checkboxIndeterminate.removeValue(forKey: id)
|
|
7410
8110
|
if let video = node.view as? CraftNativeVideoView { video.stop() }
|
|
8111
|
+
if let map = node.view as? CraftNativeMapView { map.destroy() }
|
|
7411
8112
|
for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
|
|
7412
8113
|
}
|
|
7413
8114
|
|
|
@@ -7773,6 +8474,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7773
8474
|
stack.columnGap = max(0, number(style["columnGap"]) ?? gap)
|
|
7774
8475
|
stack.grid = style["display"] as? String == "grid"
|
|
7775
8476
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
8477
|
+
stack.gridRows = craftNativeGridRowCount(style["gridTemplateRows"])
|
|
7776
8478
|
stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
|
|
7777
8479
|
stack.padding = paddingInsets(style)
|
|
7778
8480
|
stack.invalidateMeasurements()
|
|
@@ -7939,6 +8641,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7939
8641
|
label.lineBreakMode = lineBreakMode
|
|
7940
8642
|
}
|
|
7941
8643
|
|
|
8644
|
+
private func configureSelectableText(_ textView: UITextView, text: String, style: [String: Any]) {
|
|
8645
|
+
let label = CraftNativeLabel()
|
|
8646
|
+
label.numberOfLines = 0
|
|
8647
|
+
configureText(label, text: text, style: style)
|
|
8648
|
+
textView.attributedText = label.attributedText
|
|
8649
|
+
if label.attributedText == nil { textView.text = label.text }
|
|
8650
|
+
textView.font = label.font
|
|
8651
|
+
textView.textColor = label.textColor
|
|
8652
|
+
textView.textAlignment = label.textAlignment
|
|
8653
|
+
textView.textContainerInset = paddingInsets(style)
|
|
8654
|
+
textView.textContainer.lineFragmentPadding = 0
|
|
8655
|
+
textView.isEditable = false
|
|
8656
|
+
textView.isSelectable = true
|
|
8657
|
+
textView.isScrollEnabled = false
|
|
8658
|
+
textView.backgroundColor = .clear
|
|
8659
|
+
}
|
|
8660
|
+
|
|
7942
8661
|
private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
|
|
7943
8662
|
switch value as? String {
|
|
7944
8663
|
case "head": return .byTruncatingHead
|
|
@@ -8186,6 +8905,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8186
8905
|
return ["localhost", "127.0.0.1", "::1"].contains(bare) || bare.hasSuffix(".localhost")
|
|
8187
8906
|
}
|
|
8188
8907
|
|
|
8908
|
+
/// Native screens have no document URL to resolve a path against. A
|
|
8909
|
+
/// leading slash is an app URL (for example `/api/avatar`), while a bare
|
|
8910
|
+
/// name remains a bundled asset for backwards compatibility.
|
|
8911
|
+
private func imageURL(for uri: String) -> URL? {
|
|
8912
|
+
let candidate: URL?
|
|
8913
|
+
if uri.hasPrefix("/") {
|
|
8914
|
+
guard let base = config.devServerURL.flatMap(URL.init(string:)) else { return nil }
|
|
8915
|
+
candidate = URL(string: uri, relativeTo: base)?.absoluteURL
|
|
8916
|
+
} else {
|
|
8917
|
+
candidate = URL(string: uri)
|
|
8918
|
+
}
|
|
8919
|
+
guard let candidate, Self.loadsImage(from: candidate) else { return nil }
|
|
8920
|
+
return candidate
|
|
8921
|
+
}
|
|
8922
|
+
|
|
8923
|
+
private func applyImageClipping(_ view: UIImageView, contentMode: UIView.ContentMode, style: [String: Any]) {
|
|
8924
|
+
let authored = (style["overflow"] as? String) == "hidden"
|
|
8925
|
+
|| (view.craftRequestedCornerRadius ?? 0) > 0
|
|
8926
|
+
|| view.craftRequestedCornerRadii != nil
|
|
8927
|
+
let clips = authored || contentMode == .scaleAspectFill
|
|
8928
|
+
view.clipsToBounds = clips
|
|
8929
|
+
view.layer.masksToBounds = clips
|
|
8930
|
+
}
|
|
8931
|
+
|
|
8189
8932
|
private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
|
|
8190
8933
|
let sourceObject = source as? [String: Any]
|
|
8191
8934
|
let uri = (source as? String) ?? sourceObject?["uri"] as? String
|
|
@@ -8225,7 +8968,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8225
8968
|
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
8226
8969
|
return
|
|
8227
8970
|
}
|
|
8228
|
-
if let url =
|
|
8971
|
+
if let url = imageURL(for: uri) {
|
|
8229
8972
|
// Decoded at the size it is drawn, when the style fixes that size.
|
|
8230
8973
|
let pixels = Self.displayPixels(of: view)
|
|
8231
8974
|
let fill = view.contentMode == .scaleAspectFill
|
|
@@ -8267,6 +9010,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8267
9010
|
task.resume()
|
|
8268
9011
|
return
|
|
8269
9012
|
}
|
|
9013
|
+
if uri.hasPrefix("/") {
|
|
9014
|
+
imageFailure(view, uri: uri, message: "Relative image source requires devServerURL")
|
|
9015
|
+
return
|
|
9016
|
+
}
|
|
8270
9017
|
if !uri.contains(":") {
|
|
8271
9018
|
guard let image = UIImage(named: uri) else {
|
|
8272
9019
|
imageFailure(view, uri: uri, message: "Bundled image was not found")
|
|
@@ -8341,7 +9088,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8341
9088
|
}
|
|
8342
9089
|
let hasLinkTarget = ["href", "to"].contains { !(props[$0] as? String ?? "").isEmpty }
|
|
8343
9090
|
let linkedControl = ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type) && hasLinkTarget
|
|
8344
|
-
let
|
|
9091
|
+
let touchable = ["TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type)
|
|
9092
|
+
let role = (props["accessibilityRole"] as? String) ?? (linkedControl ? "link" : touchable ? "button" : nil)
|
|
8345
9093
|
// A scroll view, a list or a group of controls holds what VoiceOver
|
|
8346
9094
|
// reads: its label names the group, as a tab strip or a pager's
|
|
8347
9095
|
// name, and never stands in for its content. As an element of its
|
|
@@ -8428,7 +9176,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8428
9176
|
@objc private func buttonPressed(_ sender: UIButton) {
|
|
8429
9177
|
let focused = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
8430
9178
|
if let target = linkTargets[ObjectIdentifier(sender)] {
|
|
8431
|
-
|
|
9179
|
+
hybridEvents?.nativeScreen(self, open: target, props: [:])
|
|
8432
9180
|
return
|
|
8433
9181
|
}
|
|
8434
9182
|
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
@@ -8719,7 +9467,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8719
9467
|
guard let view = sender.view else { return }
|
|
8720
9468
|
let id = ObjectIdentifier(view)
|
|
8721
9469
|
if let target = linkTargets[id] {
|
|
8722
|
-
|
|
9470
|
+
hybridEvents?.nativeScreen(self, open: target, props: [:])
|
|
8723
9471
|
return
|
|
8724
9472
|
}
|
|
8725
9473
|
guard let handler = handlers[id] else { return }
|