@craft-native/ios 0.0.247 → 0.0.249
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 +44 -3
- package/templates/CraftHealth.swift +217 -0
- package/templates/CraftHeartRate.swift +6 -3
- package/templates/CraftHybrid.swift +30 -22
- 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 +826 -69
- 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,14 @@ 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
|
+
// A drag beginning on a button scrolls; lifting without dragging
|
|
3997
|
+
// remains a tap. Sliders, switches and text fields keep tracking.
|
|
3998
|
+
if view is UIButton { return true }
|
|
3999
|
+
if view is UIControl { return false }
|
|
4000
|
+
return super.touchesShouldCancel(in: view)
|
|
4001
|
+
}
|
|
4002
|
+
|
|
3632
4003
|
var isVertical: Bool { contentStack.axis == .vertical }
|
|
3633
4004
|
|
|
3634
4005
|
/// Lays the content out again on the next pass. Scrolling alone does not:
|
|
@@ -4266,6 +4637,35 @@ private final class CraftNativeLabel: UILabel {
|
|
|
4266
4637
|
}
|
|
4267
4638
|
}
|
|
4268
4639
|
|
|
4640
|
+
/// A non-editable text view used when a native Text explicitly opts into
|
|
4641
|
+
/// selection. UILabel has no selection model; UITextView supplies UIKit's
|
|
4642
|
+
/// standard loupe, menu and copy behavior while remaining a plain text leaf.
|
|
4643
|
+
private final class CraftNativeSelectableTextView: UITextView {
|
|
4644
|
+
convenience init() {
|
|
4645
|
+
self.init(frame: .zero, textContainer: nil)
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
4649
|
+
super.init(frame: frame, textContainer: textContainer)
|
|
4650
|
+
isEditable = false
|
|
4651
|
+
isSelectable = true
|
|
4652
|
+
isScrollEnabled = false
|
|
4653
|
+
backgroundColor = .clear
|
|
4654
|
+
textContainerInset = .zero
|
|
4655
|
+
self.textContainer.lineFragmentPadding = 0
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4658
|
+
required init?(coder: NSCoder) {
|
|
4659
|
+
super.init(coder: coder)
|
|
4660
|
+
isEditable = false
|
|
4661
|
+
isSelectable = true
|
|
4662
|
+
isScrollEnabled = false
|
|
4663
|
+
backgroundColor = .clear
|
|
4664
|
+
textContainerInset = .zero
|
|
4665
|
+
self.textContainer.lineFragmentPadding = 0
|
|
4666
|
+
}
|
|
4667
|
+
}
|
|
4668
|
+
|
|
4269
4669
|
/// A one-line TextInput. Its style's padding is where the text and the
|
|
4270
4670
|
/// placeholder start, as in a web input; UITextField has no padding of its own.
|
|
4271
4671
|
final class CraftNativeTextField: UITextField {
|
|
@@ -4302,6 +4702,22 @@ private final class CraftNativeFlexSpacer: UIView {}
|
|
|
4302
4702
|
private final class CraftNativeIconView: UIImageView {
|
|
4303
4703
|
var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
|
|
4304
4704
|
|
|
4705
|
+
/// An icon says only what the screen labels it. iOS gives a symbol image
|
|
4706
|
+
/// a description of its own ("Circle", "Chevron"), which VoiceOver read
|
|
4707
|
+
/// as an extra stop and a row read as part of its text; an unlabelled
|
|
4708
|
+
/// icon is decoration, as UIKit's own glyph accessories are.
|
|
4709
|
+
private var authoredLabel: String?
|
|
4710
|
+
|
|
4711
|
+
override var accessibilityLabel: String? {
|
|
4712
|
+
get { authoredLabel }
|
|
4713
|
+
set { authoredLabel = newValue }
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4716
|
+
override var isAccessibilityElement: Bool {
|
|
4717
|
+
get { authoredLabel != nil && super.isAccessibilityElement }
|
|
4718
|
+
set { super.isAccessibilityElement = newValue }
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4305
4721
|
override var intrinsicContentSize: CGSize {
|
|
4306
4722
|
if let defaultBox { return CGSize(width: defaultBox, height: defaultBox) }
|
|
4307
4723
|
return super.intrinsicContentSize
|
|
@@ -4511,9 +4927,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4511
4927
|
}
|
|
4512
4928
|
}
|
|
4513
4929
|
|
|
4930
|
+
// A reparented popup must keep the live native view that owns its draft,
|
|
4931
|
+
// focus and selection. The mutation protocol identifies nodes globally;
|
|
4932
|
+
// retain the previous render by that ID when the new parent has no local
|
|
4933
|
+
// identity entry for it.
|
|
4934
|
+
private var previousNodesByProtocolID: [String: RenderedNode] = [:]
|
|
4935
|
+
private var adoptedPreviousProtocolIDs = Set<String>()
|
|
4936
|
+
|
|
4514
4937
|
private let config: CraftConfig
|
|
4515
4938
|
private let routeName: String?
|
|
4516
4939
|
private(set) var routeParams: [String: Any]
|
|
4940
|
+
private(set) var routeProps: [String: Any]
|
|
4517
4941
|
private let jsContext = JSContext(virtualMachine: CraftNativeBundle.virtualMachine)!
|
|
4518
4942
|
private let rootStack = CraftNativeFlowView()
|
|
4519
4943
|
private var handlers: [ObjectIdentifier: String] = [:]
|
|
@@ -4538,6 +4962,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4538
4962
|
private var sliderSteps: [ObjectIdentifier: Float] = [:]
|
|
4539
4963
|
private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
|
|
4540
4964
|
private var modalVisibility: [ObjectIdentifier: Bool] = [:]
|
|
4965
|
+
private var modalFocusRestore: [ObjectIdentifier: UIView] = [:]
|
|
4541
4966
|
private var layoutHandlers: [ObjectIdentifier: String] = [:]
|
|
4542
4967
|
private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
|
|
4543
4968
|
private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
|
|
@@ -4595,10 +5020,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4595
5020
|
/// A request a screen-owned module answers, by request token, and how to
|
|
4596
5021
|
/// call it off when it times out or is cancelled.
|
|
4597
5022
|
private var screenRequestCancellers: [String: () -> Void] = [:]
|
|
4598
|
-
///
|
|
4599
|
-
|
|
4600
|
-
///
|
|
4601
|
-
|
|
5023
|
+
/// Whether the script was last told the screen is on screen; nil until the
|
|
5024
|
+
/// first report. A screen first reported hidden - an adjacent tab the
|
|
5025
|
+
/// shell prepares ahead of a swipe - must hear that, or its script takes
|
|
5026
|
+
/// itself to be in front and acts on it: a sign-in guard on a prepared
|
|
5027
|
+
/// Record tab pushed Login over the Feed at launch.
|
|
5028
|
+
private var reportedVisible: Bool?
|
|
4602
5029
|
private let capabilityScope = UUID().uuidString
|
|
4603
5030
|
private var pendingCapabilityRequests = Set<String>()
|
|
4604
5031
|
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
@@ -4624,10 +5051,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4624
5051
|
/// `DEEP_LINK`; it can open paths but not push, pop or style a bar.
|
|
4625
5052
|
let isAppScript: Bool
|
|
4626
5053
|
|
|
4627
|
-
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:], isAppScript: Bool = false) {
|
|
5054
|
+
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:], routeProps: [String: Any] = [:], isAppScript: Bool = false) {
|
|
4628
5055
|
self.config = config
|
|
4629
5056
|
self.routeName = routeName
|
|
4630
5057
|
self.routeParams = routeParams
|
|
5058
|
+
self.routeProps = routeProps
|
|
4631
5059
|
self.isAppScript = isAppScript
|
|
4632
5060
|
super.init(nibName: nil, bundle: nil)
|
|
4633
5061
|
}
|
|
@@ -4718,6 +5146,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4718
5146
|
/// sees, not a tab prepared behind it.
|
|
4719
5147
|
private var isShownOnScreen: Bool {
|
|
4720
5148
|
guard let window = view.window else { return false }
|
|
5149
|
+
if let shell = navigationController?.parent as? CraftHybridController,
|
|
5150
|
+
!shell.isFocusedScreen(self) { return false }
|
|
4721
5151
|
var candidate: UIView? = view
|
|
4722
5152
|
while let current = candidate, current !== window {
|
|
4723
5153
|
if current.isHidden { return false }
|
|
@@ -4783,8 +5213,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4783
5213
|
/// The screen came on screen or went off it: a screen pushed over it was
|
|
4784
5214
|
/// popped, or the hybrid shell switched to or away from its tab (which
|
|
4785
5215
|
/// 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
|
|
5216
|
+
/// appearance of a screen shown as it mounts is the mount, not news; every
|
|
5217
|
+
/// later one is `SCREEN_FOCUS { focused: true }`, and going away is
|
|
5218
|
+
/// `focused: false`. A screen that mounts hidden is told `focused: false`
|
|
5219
|
+
/// first, so its first showing is news too.
|
|
4788
5220
|
func screenVisibilityChanged(_ visible: Bool) {
|
|
4789
5221
|
guard !runtimeDisposed else { return }
|
|
4790
5222
|
if !visible, isViewLoaded { stopCelebrations(in: view) }
|
|
@@ -4793,31 +5225,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4793
5225
|
// chrome even when no new SCREEN_FOCUS event is needed.
|
|
4794
5226
|
if visible { restoreShellChrome() }
|
|
4795
5227
|
guard visible != reportedVisible else { return }
|
|
5228
|
+
let first = reportedVisible == nil
|
|
4796
5229
|
reportedVisible = visible
|
|
4797
5230
|
if visible { DeepLinkManager.shared.replayNativeLinks() }
|
|
4798
|
-
if
|
|
4799
|
-
hasAppeared = true
|
|
4800
|
-
return
|
|
4801
|
-
}
|
|
5231
|
+
if first, visible { return }
|
|
4802
5232
|
send(type: "SCREEN_FOCUS", payload: ["focused": visible])
|
|
4803
5233
|
}
|
|
4804
5234
|
|
|
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]) {
|
|
5235
|
+
/// New params or props for the screen already showing a path: opened
|
|
5236
|
+
/// again with another query or a structured handoff. The two namespaces
|
|
5237
|
+
/// stay separate so props cannot collide with path/query params.
|
|
5238
|
+
func receiveRouteParams(_ params: [String: Any], props: [String: Any] = [:]) {
|
|
4811
5239
|
routeParams = params
|
|
5240
|
+
routeProps = props
|
|
4812
5241
|
guard isViewLoaded, !runtimeDisposed else { return }
|
|
4813
5242
|
let context = jsContext
|
|
4814
|
-
CraftNativeScriptThread.run {
|
|
4815
|
-
|
|
5243
|
+
CraftNativeScriptThread.run {
|
|
5244
|
+
context.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
5245
|
+
context.setObject(props as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
|
|
5246
|
+
}
|
|
5247
|
+
send(type: "ROUTE_PARAMS", payload: ["params": params, "props": props])
|
|
4816
5248
|
}
|
|
4817
5249
|
|
|
4818
5250
|
private func disposeRuntime() {
|
|
4819
5251
|
guard !runtimeDisposed else { return }
|
|
4820
5252
|
runtimeDisposed = true
|
|
5253
|
+
disposeRenderedTree()
|
|
4821
5254
|
let context = jsContext
|
|
4822
5255
|
CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
|
|
4823
5256
|
audio.close()
|
|
@@ -4827,6 +5260,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4827
5260
|
screenRequestCancellers.removeAll()
|
|
4828
5261
|
}
|
|
4829
5262
|
|
|
5263
|
+
/// Runtime disposal happens before UIKit necessarily deallocates the
|
|
5264
|
+
/// controller. Release the rendered tree now so recycled rows, native
|
|
5265
|
+
/// delegates, media, and map callbacks cannot outlive their screen.
|
|
5266
|
+
private func disposeRenderedTree() {
|
|
5267
|
+
guard let root = renderedRoot else { return }
|
|
5268
|
+
forgetHandlers(root)
|
|
5269
|
+
rootStack.removeArrangedSubview(root.view)
|
|
5270
|
+
root.view.removeFromSuperview()
|
|
5271
|
+
renderedRoot = nil
|
|
5272
|
+
flatListRows.removeAll()
|
|
5273
|
+
flatListRecycled.removeAll()
|
|
5274
|
+
flatListOwners.removeAll()
|
|
5275
|
+
svgOwners.removeAll()
|
|
5276
|
+
}
|
|
5277
|
+
|
|
4830
5278
|
private var appliedTraitDefaults: String?
|
|
4831
5279
|
|
|
4832
5280
|
private func refreshTraitDefaults() {
|
|
@@ -4847,6 +5295,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4847
5295
|
|
|
4848
5296
|
private func refreshTraitDefaults(in node: RenderedNode?) {
|
|
4849
5297
|
guard let node else { return }
|
|
5298
|
+
(node.view as? CraftNativeMapView)?.refreshThemeDefaults()
|
|
4850
5299
|
(node.view as? CraftNativeFlatList)?.refreshThemeDefaults()
|
|
4851
5300
|
node.children.forEach { refreshTraitDefaults(in: $0) }
|
|
4852
5301
|
}
|
|
@@ -4882,13 +5331,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4882
5331
|
}
|
|
4883
5332
|
|
|
4884
5333
|
/// What a screen's script starts with that only the main thread may read:
|
|
4885
|
-
/// the app's state, the appearance, what the device can do.
|
|
5334
|
+
/// the app's state, the appearance, initial visibility, what the device can do.
|
|
4886
5335
|
private struct ScriptInputs {
|
|
4887
5336
|
let capabilityJSON: String
|
|
4888
5337
|
let appState: String
|
|
4889
5338
|
let colorScheme: String
|
|
4890
5339
|
let appearanceJSON: String
|
|
4891
5340
|
let sessionTokenKey: String
|
|
5341
|
+
let focused: Bool?
|
|
4892
5342
|
}
|
|
4893
5343
|
|
|
4894
5344
|
private func scriptInputs() -> ScriptInputs {
|
|
@@ -4899,7 +5349,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4899
5349
|
appState: CraftNativeActions.currentAppState(),
|
|
4900
5350
|
colorScheme: colorScheme,
|
|
4901
5351
|
appearanceJSON: appearanceJSON(),
|
|
4902
|
-
sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config))
|
|
5352
|
+
sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config)),
|
|
5353
|
+
focused: reportedVisible
|
|
4903
5354
|
)
|
|
4904
5355
|
}
|
|
4905
5356
|
|
|
@@ -4908,6 +5359,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4908
5359
|
jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
|
|
4909
5360
|
}
|
|
4910
5361
|
jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
5362
|
+
jsContext.setObject(routeProps as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
|
|
5363
|
+
// Hidden before its script ran: the SCREEN_FOCUS saying so reached no
|
|
5364
|
+
// callback, so the script reads it as it starts instead.
|
|
5365
|
+
if inputs.focused == false {
|
|
5366
|
+
jsContext.setObject(false, forKeyedSubscript: "__stxNativeFocused" as NSString)
|
|
5367
|
+
}
|
|
4911
5368
|
jsContext.exceptionHandler = { [weak self] context, exception in
|
|
4912
5369
|
// Never stringify an arbitrary JS object here. JavaScriptCore can
|
|
4913
5370
|
// re-enter this handler while coercing an exception whose
|
|
@@ -4998,6 +5455,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4998
5455
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
4999
5456
|
};
|
|
5000
5457
|
""")
|
|
5458
|
+
jsContext.evaluateScript(Self.nativeWebAPICompatibilityPrelude)
|
|
5001
5459
|
installHostAPIs()
|
|
5002
5460
|
jsContext.evaluateScript("""
|
|
5003
5461
|
if (typeof globalThis.setTimeout !== 'function') {
|
|
@@ -5054,6 +5512,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5054
5512
|
""")
|
|
5055
5513
|
}
|
|
5056
5514
|
|
|
5515
|
+
/// JavaScriptCore has the language core but not browser URL/encoding
|
|
5516
|
+
/// globals. Keep their absence actionable for shared modules instead of
|
|
5517
|
+
/// surfacing an opaque ReferenceError from an unrelated load path. Full
|
|
5518
|
+
/// implementations belong to the shared STX runtime.
|
|
5519
|
+
private static let nativeWebAPICompatibilityPrelude = """
|
|
5520
|
+
(function() {
|
|
5521
|
+
function unavailable(name, hint) {
|
|
5522
|
+
var failure = function() {
|
|
5523
|
+
throw new ReferenceError(name + ' is not available on a native screen — ' + hint);
|
|
5524
|
+
};
|
|
5525
|
+
failure.prototype.constructor = failure;
|
|
5526
|
+
return failure;
|
|
5527
|
+
}
|
|
5528
|
+
if (typeof globalThis.URL === 'undefined') globalThis.URL = unavailable('URL', 'use an absolute URL or build the path with encodeURIComponent');
|
|
5529
|
+
if (typeof globalThis.URLSearchParams === 'undefined') globalThis.URLSearchParams = unavailable('URLSearchParams', 'build query strings with encodeURIComponent');
|
|
5530
|
+
if (typeof globalThis.TextEncoder === 'undefined') globalThis.TextEncoder = unavailable('TextEncoder', 'use a native-supported encoding helper');
|
|
5531
|
+
})();
|
|
5532
|
+
"""
|
|
5533
|
+
|
|
5057
5534
|
/// `value` as a JavaScript string literal (JSON), quotes and all.
|
|
5058
5535
|
static func jsonString(_ value: String) -> String {
|
|
5059
5536
|
guard let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
@@ -5222,14 +5699,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5222
5699
|
}));
|
|
5223
5700
|
});
|
|
5224
5701
|
}
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5702
|
+
// Native capability ownership wins over any browser-shaped
|
|
5703
|
+
// dialog object the bundle installed before the bridge ran.
|
|
5704
|
+
// Leaving the old guard here sends actionSheet through the
|
|
5705
|
+
// DOM fallback even though this screen can present UIKit.
|
|
5706
|
+
function installNativeDialog() {
|
|
5707
|
+
var craft = globalThis.craft = globalThis.craft || {};
|
|
5708
|
+
if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('dialog') >= 0) {
|
|
5709
|
+
craft.dialog = {
|
|
5710
|
+
alert: function(options) { return request('alert', [options || {}]).then(function() {}); },
|
|
5711
|
+
confirm: function(options) { return request('confirm', [options || {}]).then(Boolean); },
|
|
5712
|
+
actionSheet: function(options) { return request('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); }
|
|
5713
|
+
};
|
|
5714
|
+
}
|
|
5232
5715
|
}
|
|
5716
|
+
globalThis.__craftInstallNativeDialog = installNativeDialog;
|
|
5717
|
+
installNativeDialog();
|
|
5233
5718
|
})();
|
|
5234
5719
|
""")
|
|
5235
5720
|
// `AudioContext` and the rest of Web Audio, rendered natively.
|
|
@@ -5443,6 +5928,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5443
5928
|
jsContext.evaluateScript(runtime.source, withSourceURL: runtime.url)
|
|
5444
5929
|
}
|
|
5445
5930
|
jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
|
|
5931
|
+
// The STX runtime may install its browser-shaped craft.dialog after
|
|
5932
|
+
// the host prelude. Re-assert the native capability once the bundle
|
|
5933
|
+
// has finished so native screens cannot fall through to a DOM sheet.
|
|
5934
|
+
jsContext.evaluateScript("if (typeof globalThis.__craftInstallNativeDialog === 'function') globalThis.__craftInstallNativeDialog();")
|
|
5446
5935
|
// A bundle that names no route leaves `__stxNativeRoute` undefined;
|
|
5447
5936
|
// its title is the app's, not the word "undefined". A title the
|
|
5448
5937
|
// script set with `navigation.setOptions` wins over both.
|
|
@@ -5943,7 +6432,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5943
6432
|
guard !isAppScript else { return }
|
|
5944
6433
|
deepLinkListener = DeepLinkManager.shared.addNativeListener(canReceive: { [weak self] in
|
|
5945
6434
|
guard let self = self else { return false }
|
|
5946
|
-
return self.reportedVisible && !self.runtimeDisposed && self.navigationController?.topViewController === self
|
|
6435
|
+
return self.reportedVisible == true && !self.runtimeDisposed && self.navigationController?.topViewController === self
|
|
5947
6436
|
}) { [weak self] url, initial in
|
|
5948
6437
|
guard let self = self else { return }
|
|
5949
6438
|
var payload = CraftNativeActions.deepLinkData(url)
|
|
@@ -6155,7 +6644,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6155
6644
|
// A path rather than a screen: the hybrid shell opens it, natively
|
|
6156
6645
|
// or in the web view.
|
|
6157
6646
|
guard let path = payload["path"] as? String, !path.isEmpty else { return }
|
|
6158
|
-
hybridEvents?.nativeScreen(self, open: path)
|
|
6647
|
+
hybridEvents?.nativeScreen(self, open: path, props: payload["props"] as? [String: Any] ?? [:])
|
|
6159
6648
|
case "NAVIGATE_BACK":
|
|
6160
6649
|
if !isAppScript, navigationController?.topViewController === self {
|
|
6161
6650
|
navigationController?.popViewController(animated: true)
|
|
@@ -6255,6 +6744,124 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6255
6744
|
}
|
|
6256
6745
|
let id = args.first as? String ?? ""
|
|
6257
6746
|
switch (module, method) {
|
|
6747
|
+
case ("View", "focus"):
|
|
6748
|
+
let options = args.first as? [String: Any]
|
|
6749
|
+
let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
|
|
6750
|
+
let mode = options?["mode"] as? String ?? (args.count > 1 ? args[1] as? String : nil) ?? "keyboard"
|
|
6751
|
+
guard !nodeId.isEmpty else {
|
|
6752
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.focus needs a nodeId")))
|
|
6753
|
+
return true
|
|
6754
|
+
}
|
|
6755
|
+
guard let node = renderedNode(nodeId) else {
|
|
6756
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_FOUND", message: "View.focus could not find mounted node \(nodeId)")))
|
|
6757
|
+
return true
|
|
6758
|
+
}
|
|
6759
|
+
guard node.view.window != nil else {
|
|
6760
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_MOUNTED", message: "View.focus requires a mounted node")))
|
|
6761
|
+
return true
|
|
6762
|
+
}
|
|
6763
|
+
switch mode {
|
|
6764
|
+
case "keyboard":
|
|
6765
|
+
guard node.view is UITextField || node.view is UITextView else {
|
|
6766
|
+
reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "Keyboard focus is supported only for text inputs")))
|
|
6767
|
+
return true
|
|
6768
|
+
}
|
|
6769
|
+
guard node.view.becomeFirstResponder() else {
|
|
6770
|
+
reply(.failure(CraftNativeActionError(code: "FOCUS_FAILED", message: "The mounted view refused keyboard focus")))
|
|
6771
|
+
return true
|
|
6772
|
+
}
|
|
6773
|
+
lastFocusedInput = node.view
|
|
6774
|
+
reply(.success(true))
|
|
6775
|
+
case "accessibility":
|
|
6776
|
+
UIAccessibility.post(notification: .layoutChanged, argument: node.view)
|
|
6777
|
+
reply(.success(true))
|
|
6778
|
+
default:
|
|
6779
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.focus mode must be keyboard or accessibility")))
|
|
6780
|
+
}
|
|
6781
|
+
return true
|
|
6782
|
+
case ("View", "measure"):
|
|
6783
|
+
let options = args.first as? [String: Any]
|
|
6784
|
+
let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
|
|
6785
|
+
guard !nodeId.isEmpty else {
|
|
6786
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.measure needs a nodeId")))
|
|
6787
|
+
return true
|
|
6788
|
+
}
|
|
6789
|
+
guard let node = renderedNode(nodeId) else {
|
|
6790
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_FOUND", message: "View.measure could not find mounted node \(nodeId)")))
|
|
6791
|
+
return true
|
|
6792
|
+
}
|
|
6793
|
+
guard node.view.window != nil else {
|
|
6794
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_MOUNTED", message: "View.measure requires a mounted node")))
|
|
6795
|
+
return true
|
|
6796
|
+
}
|
|
6797
|
+
view.layoutIfNeeded()
|
|
6798
|
+
let bounds = node.view.convert(node.view.bounds, to: view)
|
|
6799
|
+
guard bounds.origin.x.isFinite, bounds.origin.y.isFinite, bounds.width.isFinite, bounds.height.isFinite else {
|
|
6800
|
+
reply(.failure(CraftNativeActionError(code: "MEASURE_FAILED", message: "The mounted view has no finite layout")))
|
|
6801
|
+
return true
|
|
6802
|
+
}
|
|
6803
|
+
reply(.success([
|
|
6804
|
+
"x": bounds.origin.x,
|
|
6805
|
+
"y": bounds.origin.y,
|
|
6806
|
+
"width": bounds.width,
|
|
6807
|
+
"height": bounds.height,
|
|
6808
|
+
"viewportWidth": view.bounds.width,
|
|
6809
|
+
"viewportHeight": view.bounds.height,
|
|
6810
|
+
"unit": "points",
|
|
6811
|
+
"coordinateSpace": "screenViewport",
|
|
6812
|
+
]))
|
|
6813
|
+
return true
|
|
6814
|
+
case ("View", "scrollTo"):
|
|
6815
|
+
let options = args.first as? [String: Any]
|
|
6816
|
+
let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
|
|
6817
|
+
guard !nodeId.isEmpty else {
|
|
6818
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo needs a nodeId")))
|
|
6819
|
+
return true
|
|
6820
|
+
}
|
|
6821
|
+
guard let node = renderedNode(nodeId) else {
|
|
6822
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_FOUND", message: "View.scrollTo could not find mounted node \(nodeId)")))
|
|
6823
|
+
return true
|
|
6824
|
+
}
|
|
6825
|
+
guard node.view.window != nil else {
|
|
6826
|
+
reply(.failure(CraftNativeActionError(code: "VIEW_NOT_MOUNTED", message: "View.scrollTo requires a mounted node")))
|
|
6827
|
+
return true
|
|
6828
|
+
}
|
|
6829
|
+
guard let scrollView = node.view as? UIScrollView else {
|
|
6830
|
+
reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "View.scrollTo is supported only for scroll views")))
|
|
6831
|
+
return true
|
|
6832
|
+
}
|
|
6833
|
+
let x: Double
|
|
6834
|
+
if let raw = options?["x"] {
|
|
6835
|
+
guard let number = craftNativeNumber(raw), number.doubleValue.isFinite else {
|
|
6836
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo coordinates must be finite numbers")))
|
|
6837
|
+
return true
|
|
6838
|
+
}
|
|
6839
|
+
x = number.doubleValue
|
|
6840
|
+
} else {
|
|
6841
|
+
x = scrollView.contentOffset.x
|
|
6842
|
+
}
|
|
6843
|
+
let y: Double
|
|
6844
|
+
if let raw = options?["y"] {
|
|
6845
|
+
guard let number = craftNativeNumber(raw), number.doubleValue.isFinite else {
|
|
6846
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo coordinates must be finite numbers")))
|
|
6847
|
+
return true
|
|
6848
|
+
}
|
|
6849
|
+
y = number.doubleValue
|
|
6850
|
+
} else {
|
|
6851
|
+
y = scrollView.contentOffset.y
|
|
6852
|
+
}
|
|
6853
|
+
guard x.isFinite, y.isFinite else {
|
|
6854
|
+
reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo coordinates must be finite numbers")))
|
|
6855
|
+
return true
|
|
6856
|
+
}
|
|
6857
|
+
let minX = -scrollView.adjustedContentInset.left
|
|
6858
|
+
let minY = -scrollView.adjustedContentInset.top
|
|
6859
|
+
let maxX = max(minX, scrollView.contentSize.width - scrollView.bounds.width + scrollView.adjustedContentInset.right)
|
|
6860
|
+
let maxY = max(minY, scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom)
|
|
6861
|
+
let target = CGPoint(x: min(max(x, minX), maxX), y: min(max(y, minY), maxY))
|
|
6862
|
+
scrollView.setContentOffset(target, animated: options?["animated"] as? Bool ?? false)
|
|
6863
|
+
reply(.success(["x": target.x, "y": target.y, "unit": "points"]))
|
|
6864
|
+
return true
|
|
6258
6865
|
case ("Network", "stream"):
|
|
6259
6866
|
let options = args.first as? [String: Any] ?? [:]
|
|
6260
6867
|
let streamId = options["id"] as? String ?? ""
|
|
@@ -6398,7 +7005,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6398
7005
|
statusBarStyleOverride = nil
|
|
6399
7006
|
statusBarHiddenOverride = nil
|
|
6400
7007
|
let previous = renderedRoot
|
|
7008
|
+
previousNodesByProtocolID.removeAll(keepingCapacity: true)
|
|
7009
|
+
adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
|
|
7010
|
+
indexPreviousNodes(previous)
|
|
6401
7011
|
let next = reconcile(document, identity: "root", path: "root", previous: previous)
|
|
7012
|
+
previousNodesByProtocolID.removeAll(keepingCapacity: true)
|
|
7013
|
+
adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
|
|
6402
7014
|
if previous?.view !== next.view {
|
|
6403
7015
|
if let old = previous { detach(old, from: rootStack) }
|
|
6404
7016
|
}
|
|
@@ -6460,6 +7072,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6460
7072
|
private func canReuseView(_ node: [String: Any], previous: RenderedNode) -> Bool {
|
|
6461
7073
|
let type = canonicalType(node["type"] as? String ?? "View")
|
|
6462
7074
|
guard previous.type == type else { return false }
|
|
7075
|
+
if type == "Text" {
|
|
7076
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
7077
|
+
return (previous.view is CraftNativeSelectableTextView) == (props["selectable"] as? Bool == true)
|
|
7078
|
+
}
|
|
6463
7079
|
guard type == "TextInput" else { return true }
|
|
6464
7080
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
6465
7081
|
return (previous.view is UITextView) == (props["multiline"] as? Bool == true)
|
|
@@ -6486,16 +7102,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6486
7102
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
6487
7103
|
let events = node["events"] as? [String: String] ?? [:]
|
|
6488
7104
|
let children = node["children"] as? [Any] ?? []
|
|
7105
|
+
let protocolId = node["id"] as? String
|
|
7106
|
+
let retained = previous ?? protocolId.flatMap { id in
|
|
7107
|
+
guard !adoptedPreviousProtocolIDs.contains(id) else { return nil }
|
|
7108
|
+
return previousNodesByProtocolID[id]
|
|
7109
|
+
}
|
|
7110
|
+
let adopted = previous == nil && retained != nil
|
|
6489
7111
|
let current: RenderedNode
|
|
6490
|
-
if let
|
|
6491
|
-
current =
|
|
7112
|
+
if let retained, (retained.identity == identity || adopting || adopted), canReuseView(node, previous: retained) {
|
|
7113
|
+
current = retained
|
|
7114
|
+
if let protocolId { adoptedPreviousProtocolIDs.insert(protocolId) }
|
|
6492
7115
|
current.identity = identity
|
|
6493
7116
|
} else {
|
|
6494
7117
|
current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
|
|
6495
7118
|
}
|
|
6496
7119
|
|
|
6497
7120
|
let result = current.view
|
|
6498
|
-
current.protocolId =
|
|
7121
|
+
current.protocolId = protocolId
|
|
6499
7122
|
current.style = style
|
|
6500
7123
|
let linkTarget = ["href", "to"].lazy.compactMap { props[$0] as? String }
|
|
6501
7124
|
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
|
|
@@ -6529,11 +7152,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6529
7152
|
result.isHidden = true
|
|
6530
7153
|
result.isUserInteractionEnabled = false
|
|
6531
7154
|
case "Text":
|
|
6532
|
-
let
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
7155
|
+
let text = children.compactMap { $0 as? String }.joined()
|
|
7156
|
+
if let selectable = result as? CraftNativeSelectableTextView {
|
|
7157
|
+
selectable.textContainer.maximumNumberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
|
|
7158
|
+
selectable.textContainer.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
7159
|
+
configureSelectableText(selectable, text: text, style: style)
|
|
7160
|
+
} else {
|
|
7161
|
+
let label = result as! UILabel
|
|
7162
|
+
label.numberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
|
|
7163
|
+
label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
7164
|
+
(label as? CraftNativeLabel)?.insets = paddingInsets(style)
|
|
7165
|
+
configureText(label, text: text, style: style)
|
|
7166
|
+
}
|
|
6537
7167
|
case "Button", "Link":
|
|
6538
7168
|
let button = result as! UIButton
|
|
6539
7169
|
let title = props["title"] as? String ?? children.compactMap { $0 as? String }.joined()
|
|
@@ -6704,6 +7334,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6704
7334
|
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
|
|
6705
7335
|
case "Modal":
|
|
6706
7336
|
let modal = result as! CraftNativeModalView
|
|
7337
|
+
let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
6707
7338
|
modal.transparent = props["transparent"] as? Bool == true
|
|
6708
7339
|
let visible: Bool
|
|
6709
7340
|
if let propVisible = props["visible"] as? Bool {
|
|
@@ -6718,10 +7349,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6718
7349
|
let modalID = ObjectIdentifier(modal)
|
|
6719
7350
|
let wasVisible = modalVisibility[modalID]
|
|
6720
7351
|
modalVisibility[modalID] = visible
|
|
7352
|
+
if visible, wasVisible != true, let focusBeforeContent,
|
|
7353
|
+
!focusBeforeContent.isDescendant(of: modal) {
|
|
7354
|
+
modalFocusRestore[modalID] = focusBeforeContent
|
|
7355
|
+
}
|
|
6721
7356
|
if visible, wasVisible != true { positionModals() }
|
|
6722
7357
|
modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
|
|
7358
|
+
if !visible, wasVisible == true {
|
|
7359
|
+
let restore = modalFocusRestore.removeValue(forKey: modalID)
|
|
7360
|
+
DispatchQueue.main.async { [weak self, weak restore] in
|
|
7361
|
+
guard let self, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
|
|
7362
|
+
self.lastFocusedInput = restore
|
|
7363
|
+
_ = restore.becomeFirstResponder()
|
|
7364
|
+
}
|
|
7365
|
+
}
|
|
6723
7366
|
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
6724
|
-
{ [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
7367
|
+
{ [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
|
|
6725
7368
|
}
|
|
6726
7369
|
if visible { modal.clearDismissSuppression() }
|
|
6727
7370
|
let suppressDismiss = !visible && modal.consumeDismissSuppression()
|
|
@@ -6744,10 +7387,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6744
7387
|
pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
|
|
6745
7388
|
case "Switch":
|
|
6746
7389
|
let toggle = result as! UISwitch
|
|
6747
|
-
|
|
6748
|
-
//
|
|
6749
|
-
//
|
|
6750
|
-
|
|
7390
|
+
// A supplied value controls the switch; without one, UIKit owns
|
|
7391
|
+
// the state and a reconciliation must not erase a user's tap.
|
|
7392
|
+
// `value: null` deliberately falls through to `checked`, matching
|
|
7393
|
+
// the host-neutral precedence used by the other renderer.
|
|
7394
|
+
if let value = props["value"] as? Bool ?? props["checked"] as? Bool {
|
|
7395
|
+
// UIKit has already changed the value before emitting the
|
|
7396
|
+
// event. Echoing it through the script must not restart its
|
|
7397
|
+
// native animation.
|
|
7398
|
+
if toggle.isOn != value { toggle.isOn = value }
|
|
7399
|
+
}
|
|
6751
7400
|
toggle.isEnabled = props["disabled"] as? Bool != true
|
|
6752
7401
|
if let track = props["trackColor"] as? [String: Any] {
|
|
6753
7402
|
toggle.onTintColor = color(track["true"])
|
|
@@ -6795,8 +7444,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6795
7444
|
indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
|
|
6796
7445
|
if props["animating"] as? Bool == false {
|
|
6797
7446
|
indicator.stopAnimating()
|
|
7447
|
+
// UIKit does not retroactively reveal an indicator when an
|
|
7448
|
+
// already-stopped view changes hidesWhenStopped to false.
|
|
7449
|
+
// Android applies the requested visibility on every render;
|
|
7450
|
+
// keep the two hosts in lockstep for dynamic prop changes.
|
|
7451
|
+
indicator.isHidden = indicator.hidesWhenStopped
|
|
6798
7452
|
} else {
|
|
6799
7453
|
indicator.startAnimating()
|
|
7454
|
+
indicator.isHidden = false
|
|
6800
7455
|
}
|
|
6801
7456
|
case "Svg":
|
|
6802
7457
|
let drawing = result as! CraftNativeSvgView
|
|
@@ -6822,7 +7477,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6822
7477
|
if let current = image.image { image.image = imageForDisplay(current, view: image) }
|
|
6823
7478
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6824
7479
|
image.contentMode = imageContentMode(requestedResizeMode)
|
|
6825
|
-
image
|
|
7480
|
+
applyImageClipping(image, contentMode: image.contentMode, style: style)
|
|
6826
7481
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
|
|
6827
7482
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
|
|
6828
7483
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
@@ -6841,7 +7496,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6841
7496
|
if let current = background.image { background.image = imageForDisplay(current, view: background) }
|
|
6842
7497
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6843
7498
|
background.contentMode = imageContentMode(requestedResizeMode)
|
|
6844
|
-
background
|
|
7499
|
+
applyImageClipping(background, contentMode: background.contentMode, style: style)
|
|
6845
7500
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
|
|
6846
7501
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
|
|
6847
7502
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
|
|
@@ -6985,6 +7640,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6985
7640
|
private func makeView(_ type: String, props: [String: Any]) -> UIView {
|
|
6986
7641
|
switch type {
|
|
6987
7642
|
case "Text":
|
|
7643
|
+
if props["selectable"] as? Bool == true {
|
|
7644
|
+
return CraftNativeSelectableTextView()
|
|
7645
|
+
}
|
|
6988
7646
|
let label = CraftNativeLabel()
|
|
6989
7647
|
label.numberOfLines = 0
|
|
6990
7648
|
return label
|
|
@@ -7169,7 +7827,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7169
7827
|
let key = explicitKey(node, props: node["props"] as? [String: Any] ?? [:])
|
|
7170
7828
|
// Keys are local to a parent. Ambiguous sibling keys fall back to
|
|
7171
7829
|
// position so a move never attaches the wrong live control.
|
|
7172
|
-
let
|
|
7830
|
+
let localIdentity = key.flatMap { counts[$0] == 1 ? "key:\($0)" : nil } ?? "index:\(index)"
|
|
7831
|
+
// A keyed row can live in either FlatList or an ordinary
|
|
7832
|
+
// ScrollView. Scope its descendants to the row so two rows cannot
|
|
7833
|
+
// share an uncontrolled input draft or retained native handler.
|
|
7834
|
+
let identity = parent.identity.hasPrefix("list:") || parent.identity.hasPrefix("key:")
|
|
7835
|
+
? "\(parent.identity)/\(localIdentity)"
|
|
7836
|
+
: localIdentity
|
|
7173
7837
|
let child = reconcile(node, identity: identity, path: "\(path).\(index)", previous: old[identity])
|
|
7174
7838
|
next.append(child)
|
|
7175
7839
|
}
|
|
@@ -7302,6 +7966,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7302
7966
|
recycled[ObjectIdentifier(row.view)] = row
|
|
7303
7967
|
self.flatListRecycled[listKey] = recycled
|
|
7304
7968
|
},
|
|
7969
|
+
removeItem: { [weak self] identity in
|
|
7970
|
+
guard let self else { return }
|
|
7971
|
+
let scopedIdentity = "list:\(identity)"
|
|
7972
|
+
if let row = self.flatListRows[listKey]?.removeValue(forKey: identity) {
|
|
7973
|
+
self.forgetHandlers(row)
|
|
7974
|
+
}
|
|
7975
|
+
guard var recycled = self.flatListRecycled[listKey] else { return }
|
|
7976
|
+
let staleViewIDs = recycled.compactMap { viewID, row in
|
|
7977
|
+
row.identity == scopedIdentity ? viewID : nil
|
|
7978
|
+
}
|
|
7979
|
+
for viewID in staleViewIDs {
|
|
7980
|
+
if let row = recycled.removeValue(forKey: viewID) { self.forgetHandlers(row) }
|
|
7981
|
+
}
|
|
7982
|
+
self.flatListRecycled[listKey] = recycled
|
|
7983
|
+
},
|
|
7305
7984
|
endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
|
|
7306
7985
|
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
7307
7986
|
}
|
|
@@ -7333,24 +8012,52 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7333
8012
|
}
|
|
7334
8013
|
|
|
7335
8014
|
private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
|
|
7336
|
-
|
|
8015
|
+
let retainedByReparent = node.protocolId.map { adoptedPreviousProtocolIDs.contains($0) } == true
|
|
8016
|
+
if !retainedByReparent { forgetHandlers(node) }
|
|
7337
8017
|
if node.type == "Modal" {
|
|
7338
|
-
node.view.removeFromSuperview()
|
|
8018
|
+
if node.view.superview === stack { node.view.removeFromSuperview() }
|
|
7339
8019
|
return
|
|
7340
8020
|
}
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
|
|
8021
|
+
if node.view.superview === stack {
|
|
8022
|
+
stack.removeLayoutStyle(for: node.view)
|
|
8023
|
+
stack.removeArrangedSubview(node.view)
|
|
8024
|
+
node.view.removeFromSuperview()
|
|
8025
|
+
}
|
|
8026
|
+
}
|
|
8027
|
+
|
|
8028
|
+
private func indexPreviousNodes(_ node: RenderedNode?) {
|
|
8029
|
+
guard let node else { return }
|
|
8030
|
+
if let id = node.protocolId, !id.isEmpty { previousNodesByProtocolID[id] = node }
|
|
8031
|
+
node.children.forEach { indexPreviousNodes($0) }
|
|
7344
8032
|
}
|
|
7345
8033
|
|
|
7346
8034
|
private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
|
|
7347
8035
|
(node.view as? CraftNativeScrollView)?.setRefreshHandler(nil, refreshing: false)
|
|
8036
|
+
// Recycled list rows keep their native delegates and targets until
|
|
8037
|
+
// they are rebound. A permanently detached node must release those
|
|
8038
|
+
// UIKit hooks as well as the handler maps below, otherwise a picker,
|
|
8039
|
+
// text view, or control can still call back into this screen after
|
|
8040
|
+
// its subtree is gone.
|
|
8041
|
+
if !preservingInputDrafts {
|
|
8042
|
+
if let textView = node.view as? UITextView { textView.delegate = nil }
|
|
8043
|
+
if let picker = node.view as? UIPickerView {
|
|
8044
|
+
picker.delegate = nil
|
|
8045
|
+
picker.dataSource = nil
|
|
8046
|
+
}
|
|
8047
|
+
if let scroll = node.view as? UIScrollView { scroll.delegate = nil }
|
|
8048
|
+
(node.view as? UIControl)?.removeTarget(self, action: nil, for: .allEvents)
|
|
8049
|
+
}
|
|
7348
8050
|
if let list = node.view as? CraftNativeFlatList {
|
|
7349
8051
|
let listKey = ObjectIdentifier(list)
|
|
7350
8052
|
list.setRefreshHandler(nil, refreshing: false)
|
|
7351
8053
|
list.discardAll()
|
|
8054
|
+
if !preservingInputDrafts { list.resetForTeardown() }
|
|
7352
8055
|
flatListRows.removeValue(forKey: listKey)
|
|
7353
|
-
|
|
8056
|
+
if !preservingInputDrafts {
|
|
8057
|
+
flatListRecycled.removeValue(forKey: listKey)?.values.forEach { forgetHandlers($0) }
|
|
8058
|
+
} else {
|
|
8059
|
+
flatListRecycled.removeValue(forKey: listKey)
|
|
8060
|
+
}
|
|
7354
8061
|
if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
|
|
7355
8062
|
}
|
|
7356
8063
|
if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
|
|
@@ -7408,6 +8115,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7408
8115
|
imageErrorHandlers.removeValue(forKey: id)
|
|
7409
8116
|
checkboxIndeterminate.removeValue(forKey: id)
|
|
7410
8117
|
if let video = node.view as? CraftNativeVideoView { video.stop() }
|
|
8118
|
+
if let map = node.view as? CraftNativeMapView { map.destroy() }
|
|
7411
8119
|
for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
|
|
7412
8120
|
}
|
|
7413
8121
|
|
|
@@ -7773,6 +8481,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7773
8481
|
stack.columnGap = max(0, number(style["columnGap"]) ?? gap)
|
|
7774
8482
|
stack.grid = style["display"] as? String == "grid"
|
|
7775
8483
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
8484
|
+
stack.gridRows = craftNativeGridRowCount(style["gridTemplateRows"])
|
|
7776
8485
|
stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
|
|
7777
8486
|
stack.padding = paddingInsets(style)
|
|
7778
8487
|
stack.invalidateMeasurements()
|
|
@@ -7939,6 +8648,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7939
8648
|
label.lineBreakMode = lineBreakMode
|
|
7940
8649
|
}
|
|
7941
8650
|
|
|
8651
|
+
private func configureSelectableText(_ textView: UITextView, text: String, style: [String: Any]) {
|
|
8652
|
+
let label = CraftNativeLabel()
|
|
8653
|
+
label.numberOfLines = 0
|
|
8654
|
+
configureText(label, text: text, style: style)
|
|
8655
|
+
textView.attributedText = label.attributedText
|
|
8656
|
+
if label.attributedText == nil { textView.text = label.text }
|
|
8657
|
+
textView.font = label.font
|
|
8658
|
+
textView.textColor = label.textColor
|
|
8659
|
+
textView.textAlignment = label.textAlignment
|
|
8660
|
+
textView.textContainerInset = paddingInsets(style)
|
|
8661
|
+
textView.textContainer.lineFragmentPadding = 0
|
|
8662
|
+
textView.isEditable = false
|
|
8663
|
+
textView.isSelectable = true
|
|
8664
|
+
textView.isScrollEnabled = false
|
|
8665
|
+
textView.backgroundColor = .clear
|
|
8666
|
+
}
|
|
8667
|
+
|
|
7942
8668
|
private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
|
|
7943
8669
|
switch value as? String {
|
|
7944
8670
|
case "head": return .byTruncatingHead
|
|
@@ -8186,6 +8912,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8186
8912
|
return ["localhost", "127.0.0.1", "::1"].contains(bare) || bare.hasSuffix(".localhost")
|
|
8187
8913
|
}
|
|
8188
8914
|
|
|
8915
|
+
/// Native screens have no document URL to resolve a path against. A
|
|
8916
|
+
/// leading slash is an app URL (for example `/api/avatar`), while a bare
|
|
8917
|
+
/// name remains a bundled asset for backwards compatibility.
|
|
8918
|
+
private func imageURL(for uri: String) -> URL? {
|
|
8919
|
+
let candidate: URL?
|
|
8920
|
+
if uri.hasPrefix("/") {
|
|
8921
|
+
guard let base = config.devServerURL.flatMap(URL.init(string:)) else { return nil }
|
|
8922
|
+
candidate = URL(string: uri, relativeTo: base)?.absoluteURL
|
|
8923
|
+
} else {
|
|
8924
|
+
candidate = URL(string: uri)
|
|
8925
|
+
}
|
|
8926
|
+
guard let candidate, Self.loadsImage(from: candidate) else { return nil }
|
|
8927
|
+
return candidate
|
|
8928
|
+
}
|
|
8929
|
+
|
|
8930
|
+
private func applyImageClipping(_ view: UIImageView, contentMode: UIView.ContentMode, style: [String: Any]) {
|
|
8931
|
+
let authored = (style["overflow"] as? String) == "hidden"
|
|
8932
|
+
|| (view.craftRequestedCornerRadius ?? 0) > 0
|
|
8933
|
+
|| view.craftRequestedCornerRadii != nil
|
|
8934
|
+
let clips = authored || contentMode == .scaleAspectFill
|
|
8935
|
+
view.clipsToBounds = clips
|
|
8936
|
+
view.layer.masksToBounds = clips
|
|
8937
|
+
}
|
|
8938
|
+
|
|
8189
8939
|
private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
|
|
8190
8940
|
let sourceObject = source as? [String: Any]
|
|
8191
8941
|
let uri = (source as? String) ?? sourceObject?["uri"] as? String
|
|
@@ -8225,7 +8975,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8225
8975
|
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
8226
8976
|
return
|
|
8227
8977
|
}
|
|
8228
|
-
if let url =
|
|
8978
|
+
if let url = imageURL(for: uri) {
|
|
8229
8979
|
// Decoded at the size it is drawn, when the style fixes that size.
|
|
8230
8980
|
let pixels = Self.displayPixels(of: view)
|
|
8231
8981
|
let fill = view.contentMode == .scaleAspectFill
|
|
@@ -8267,6 +9017,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8267
9017
|
task.resume()
|
|
8268
9018
|
return
|
|
8269
9019
|
}
|
|
9020
|
+
if uri.hasPrefix("/") {
|
|
9021
|
+
imageFailure(view, uri: uri, message: "Relative image source requires devServerURL")
|
|
9022
|
+
return
|
|
9023
|
+
}
|
|
8270
9024
|
if !uri.contains(":") {
|
|
8271
9025
|
guard let image = UIImage(named: uri) else {
|
|
8272
9026
|
imageFailure(view, uri: uri, message: "Bundled image was not found")
|
|
@@ -8341,7 +9095,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8341
9095
|
}
|
|
8342
9096
|
let hasLinkTarget = ["href", "to"].contains { !(props[$0] as? String ?? "").isEmpty }
|
|
8343
9097
|
let linkedControl = ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type) && hasLinkTarget
|
|
8344
|
-
let
|
|
9098
|
+
let touchable = ["TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type)
|
|
9099
|
+
let role = (props["accessibilityRole"] as? String) ?? (linkedControl ? "link" : touchable ? "button" : nil)
|
|
8345
9100
|
// A scroll view, a list or a group of controls holds what VoiceOver
|
|
8346
9101
|
// reads: its label names the group, as a tab strip or a pager's
|
|
8347
9102
|
// name, and never stands in for its content. As an element of its
|
|
@@ -8349,7 +9104,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8349
9104
|
// A video with its system controls is a group too: VoiceOver reaches
|
|
8350
9105
|
// play, the scrubber, skipping and full screen inside it.
|
|
8351
9106
|
let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
|
|
8352
|
-
|
|
9107
|
+
// UIPickerView exposes adjustable component wheels. Making the
|
|
9108
|
+
// picker itself a leaf hides those wheels and their selected values.
|
|
9109
|
+
let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
|
|
8353
9110
|
|| ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
|
|
8354
9111
|
|| controlledVideo
|
|
8355
9112
|
view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
|
|
@@ -8360,7 +9117,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8360
9117
|
// screen omits redundant accessibility metadata.
|
|
8361
9118
|
var traits: UIAccessibilityTraits = []
|
|
8362
9119
|
switch type {
|
|
8363
|
-
case "Slider", "
|
|
9120
|
+
case "Slider", "DatePicker": traits.insert(.adjustable)
|
|
8364
9121
|
default: break
|
|
8365
9122
|
}
|
|
8366
9123
|
switch role ?? type.lowercased() {
|
|
@@ -8428,7 +9185,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8428
9185
|
@objc private func buttonPressed(_ sender: UIButton) {
|
|
8429
9186
|
let focused = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
8430
9187
|
if let target = linkTargets[ObjectIdentifier(sender)] {
|
|
8431
|
-
|
|
9188
|
+
hybridEvents?.nativeScreen(self, open: target, props: [:])
|
|
8432
9189
|
return
|
|
8433
9190
|
}
|
|
8434
9191
|
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
@@ -8719,7 +9476,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8719
9476
|
guard let view = sender.view else { return }
|
|
8720
9477
|
let id = ObjectIdentifier(view)
|
|
8721
9478
|
if let target = linkTargets[id] {
|
|
8722
|
-
|
|
9479
|
+
hybridEvents?.nativeScreen(self, open: target, props: [:])
|
|
8723
9480
|
return
|
|
8724
9481
|
}
|
|
8725
9482
|
guard let handler = handlers[id] else { return }
|