craft-native 0.0.173 → 0.0.175
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.d.ts +2 -0
- package/dist/android/src/index.js +4 -1
- package/dist/android/templates/AndroidManifest.xml.template +2 -0
- package/dist/android/templates/CraftNativeShell.kt.template +50 -0
- package/dist/android/templates/MainActivityNative.kt.template +454 -27
- package/dist/android/templates/build.gradle.kts.app.template +3 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +1 -0
- package/dist/ios/templates/CraftApp.swift +5 -0
- package/dist/ios/templates/CraftNativeActions.swift +174 -0
- package/dist/ios/templates/CraftNativeFlatList.swift +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +454 -38
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import AVKit
|
|
|
4
4
|
import CryptoKit
|
|
5
5
|
import Darwin
|
|
6
6
|
import JavaScriptCore
|
|
7
|
+
import MapKit
|
|
7
8
|
import OSLog
|
|
8
9
|
import Security
|
|
9
10
|
import SwiftUI
|
|
@@ -426,6 +427,183 @@ private final class CraftNativeVideoView: UIView {
|
|
|
426
427
|
deinit { stop() }
|
|
427
428
|
}
|
|
428
429
|
|
|
430
|
+
private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
431
|
+
private var annotationsByID: [String: MKPointAnnotation] = [:]
|
|
432
|
+
private var overlaysByID: [String: MKPolyline] = [:]
|
|
433
|
+
private var overlayColors: [ObjectIdentifier: UIColor] = [:]
|
|
434
|
+
private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
|
|
435
|
+
private(set) var markerHandlers: [String: String] = [:]
|
|
436
|
+
var onMarkerTap: ((String) -> Void)?
|
|
437
|
+
|
|
438
|
+
override init(frame: CGRect) {
|
|
439
|
+
super.init(frame: frame)
|
|
440
|
+
delegate = self
|
|
441
|
+
showsCompass = false
|
|
442
|
+
showsScale = false
|
|
443
|
+
isRotateEnabled = false
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
required init?(coder: NSCoder) {
|
|
447
|
+
super.init(coder: coder)
|
|
448
|
+
delegate = self
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
func configure(props: [String: Any], children: [Any], markerHandler: @escaping (String) -> Void) {
|
|
452
|
+
onMarkerTap = markerHandler
|
|
453
|
+
isScrollEnabled = props["scrollEnabled"] as? Bool ?? true
|
|
454
|
+
isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
|
|
455
|
+
isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
|
|
456
|
+
mapType = Self.mapType(props["mapType"] as? String)
|
|
457
|
+
// Showing the user dot must not trigger a permission prompt from a
|
|
458
|
+
// renderer pass. The app's existing location capability owns that
|
|
459
|
+
// prompt; a map only opts in after authorization is already granted.
|
|
460
|
+
showsUserLocation = props["showsUserLocation"] as? Bool == true && Self.locationPermissionGranted()
|
|
461
|
+
|
|
462
|
+
var nextMarkers: [String: (coordinate: CLLocationCoordinate2D, title: String?)] = [:]
|
|
463
|
+
var nextHandlers: [String: String] = [:]
|
|
464
|
+
var nextPolylines: [String: (coordinates: [CLLocationCoordinate2D], color: UIColor, width: CGFloat)] = [:]
|
|
465
|
+
for (index, child) in children.enumerated() {
|
|
466
|
+
guard let node = child as? [String: Any], let rawType = node["type"] as? String else { continue }
|
|
467
|
+
let type = rawType.lowercased()
|
|
468
|
+
let childProps = node["props"] as? [String: Any] ?? [:]
|
|
469
|
+
let childEvents = node["events"] as? [String: String] ?? [:]
|
|
470
|
+
let id = (childProps["id"] as? CustomStringConvertible)?.description
|
|
471
|
+
?? (node["id"] as? String)
|
|
472
|
+
?? "overlay-\(index)"
|
|
473
|
+
let points = Self.coordinates(childProps["geometry"])
|
|
474
|
+
if type == "mapmarker" || type == "marker" {
|
|
475
|
+
guard let point = points.first else { continue }
|
|
476
|
+
nextMarkers[id] = (point, childProps["title"] as? String)
|
|
477
|
+
if let handler = childEvents["onPress"] ?? childEvents["onMarkerPress"], !handler.isEmpty {
|
|
478
|
+
nextHandlers[id] = handler
|
|
479
|
+
}
|
|
480
|
+
} else if type == "mappolyline" || type == "polyline" {
|
|
481
|
+
guard points.count > 1 else { continue }
|
|
482
|
+
nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), CGFloat((childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3))
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
|
|
487
|
+
removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
|
|
488
|
+
for (id, value) in nextMarkers {
|
|
489
|
+
if let annotation = annotationsByID[id] {
|
|
490
|
+
annotation.coordinate = value.coordinate
|
|
491
|
+
annotation.title = value.title
|
|
492
|
+
} else {
|
|
493
|
+
let annotation = MKPointAnnotation()
|
|
494
|
+
annotation.coordinate = value.coordinate
|
|
495
|
+
annotation.title = value.title
|
|
496
|
+
annotationsByID[id] = annotation
|
|
497
|
+
addAnnotation(annotation)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
markerHandlers = nextHandlers
|
|
501
|
+
|
|
502
|
+
removeOverlays(Array(overlaysByID.values))
|
|
503
|
+
overlaysByID.removeAll(keepingCapacity: true)
|
|
504
|
+
overlayColors.removeAll(keepingCapacity: true)
|
|
505
|
+
overlayWidths.removeAll(keepingCapacity: true)
|
|
506
|
+
for (id, value) in nextPolylines {
|
|
507
|
+
let overlay = MKPolyline(coordinates: value.coordinates, count: value.coordinates.count)
|
|
508
|
+
overlaysByID[id] = overlay
|
|
509
|
+
overlayColors[ObjectIdentifier(overlay)] = value.color
|
|
510
|
+
overlayWidths[ObjectIdentifier(overlay)] = value.width
|
|
511
|
+
addOverlay(overlay)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if let region = Self.region(props["region"] ?? props["initialRegion"]) {
|
|
515
|
+
setRegion(region, animated: false)
|
|
516
|
+
} else if props["fitToBounds"] as? Bool == true {
|
|
517
|
+
fitToOverlays()
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private func fitToOverlays() {
|
|
522
|
+
var rect = MKMapRect.null
|
|
523
|
+
for overlay in overlaysByID.values { rect = rect.union(overlay.boundingMapRect) }
|
|
524
|
+
for annotation in annotationsByID.values {
|
|
525
|
+
let point = MKMapPoint(annotation.coordinate)
|
|
526
|
+
rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
|
|
527
|
+
}
|
|
528
|
+
guard !rect.isNull else { return }
|
|
529
|
+
let inset = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
|
|
530
|
+
setVisibleMapRect(rect, edgePadding: inset, animated: false)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
|
|
534
|
+
guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
|
|
535
|
+
let view = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? MKMarkerAnnotationView)
|
|
536
|
+
?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
|
|
537
|
+
view.annotation = annotation
|
|
538
|
+
view.markerTintColor = .systemRed
|
|
539
|
+
view.accessibilityIdentifier = id
|
|
540
|
+
return view
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
|
|
544
|
+
guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return }
|
|
545
|
+
onMarkerTap?(id)
|
|
546
|
+
deselectAnnotation(annotation, animated: false)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
|
|
550
|
+
let renderer = MKPolylineRenderer(overlay: overlay)
|
|
551
|
+
renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
|
|
552
|
+
renderer.lineWidth = overlayWidths[ObjectIdentifier(overlay)] ?? 3
|
|
553
|
+
return renderer
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private static func coordinates(_ value: Any?) -> [CLLocationCoordinate2D] {
|
|
557
|
+
let numbers = (value as? [Any])?.compactMap { ($0 as? NSNumber)?.doubleValue }
|
|
558
|
+
?? (value as? [NSNumber])?.map(\.doubleValue)
|
|
559
|
+
?? []
|
|
560
|
+
return stride(from: 0, to: numbers.count - 1, by: 2).map {
|
|
561
|
+
CLLocationCoordinate2D(latitude: numbers[$0], longitude: numbers[$0 + 1])
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private static func region(_ value: Any?) -> MKCoordinateRegion? {
|
|
566
|
+
guard let value = value as? [String: Any],
|
|
567
|
+
let center = value["center"] as? [String: Any],
|
|
568
|
+
let span = value["span"] as? [String: Any],
|
|
569
|
+
let latitude = (center["latitude"] as? NSNumber)?.doubleValue,
|
|
570
|
+
let longitude = (center["longitude"] as? NSNumber)?.doubleValue,
|
|
571
|
+
let latitudeDelta = (span["latitudeDelta"] as? NSNumber)?.doubleValue ?? (span["latitude_delta"] as? NSNumber)?.doubleValue,
|
|
572
|
+
let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue else { return nil }
|
|
573
|
+
return MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private static func color(_ value: String?) -> UIColor {
|
|
577
|
+
switch value?.lowercased() {
|
|
578
|
+
case "red": return .systemRed
|
|
579
|
+
case "green": return .systemGreen
|
|
580
|
+
case "orange": return .systemOrange
|
|
581
|
+
case "purple": return .systemPurple
|
|
582
|
+
case "yellow": return .systemYellow
|
|
583
|
+
default: return .systemBlue
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private static func mapType(_ value: String?) -> MKMapType {
|
|
588
|
+
switch value?.lowercased() {
|
|
589
|
+
case "satellite": return .satellite
|
|
590
|
+
case "hybrid": return .hybrid
|
|
591
|
+
case "muted_standard": return .mutedStandard
|
|
592
|
+
default: return .standard
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private static func locationPermissionGranted() -> Bool {
|
|
597
|
+
let status: CLAuthorizationStatus
|
|
598
|
+
if #available(iOS 14.0, *) {
|
|
599
|
+
status = CLLocationManager().authorizationStatus
|
|
600
|
+
} else {
|
|
601
|
+
status = CLLocationManager.authorizationStatus()
|
|
602
|
+
}
|
|
603
|
+
return status == .authorizedAlways || status == .authorizedWhenInUse
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
429
607
|
/// A WebView-free host for the first stx native vertical slice. The bundled
|
|
430
608
|
/// JavaScript sends whole, compiled view trees to UIKit and receives control
|
|
431
609
|
/// events and Craft API replies through JavaScriptCore.
|
|
@@ -781,11 +959,28 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
781
959
|
var wrapReverse = false { didSet { if wrapReverse != oldValue { invalidateMeasurements() } } }
|
|
782
960
|
var padding = UIEdgeInsets.zero {
|
|
783
961
|
didSet {
|
|
784
|
-
layoutMargins =
|
|
962
|
+
layoutMargins = effectivePadding
|
|
785
963
|
isLayoutMarginsRelativeArrangement = true
|
|
786
964
|
if padding != oldValue { invalidateMeasurements() }
|
|
787
965
|
}
|
|
788
966
|
}
|
|
967
|
+
/// Insets contributed by a native SafeAreaView root. Authored padding
|
|
968
|
+
/// remains separate so a reconciliation can remove either source without
|
|
969
|
+
/// leaving stale safe-area space behind.
|
|
970
|
+
var safeAreaPadding = UIEdgeInsets.zero {
|
|
971
|
+
didSet {
|
|
972
|
+
layoutMargins = effectivePadding
|
|
973
|
+
if safeAreaPadding != oldValue { invalidateMeasurements() }
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
var effectivePadding: UIEdgeInsets {
|
|
977
|
+
UIEdgeInsets(
|
|
978
|
+
top: padding.top + safeAreaPadding.top,
|
|
979
|
+
left: padding.left + safeAreaPadding.left,
|
|
980
|
+
bottom: padding.bottom + safeAreaPadding.bottom,
|
|
981
|
+
right: padding.right + safeAreaPadding.right
|
|
982
|
+
)
|
|
983
|
+
}
|
|
789
984
|
override var axis: NSLayoutConstraint.Axis {
|
|
790
985
|
didSet { if axis != oldValue { invalidateMeasurements() } }
|
|
791
986
|
}
|
|
@@ -1020,8 +1215,9 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1020
1215
|
let children = visibleChildren
|
|
1021
1216
|
let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
|
|
1022
1217
|
let horizontal = axis == .horizontal
|
|
1023
|
-
let
|
|
1024
|
-
let
|
|
1218
|
+
let contentPadding = effectivePadding
|
|
1219
|
+
let paddingWidth = contentPadding.left + contentPadding.right
|
|
1220
|
+
let paddingHeight = contentPadding.top + contentPadding.bottom
|
|
1025
1221
|
let innerWidth = width.map { max(0, $0 - paddingWidth) }
|
|
1026
1222
|
let innerHeight = height.map { max(0, $0 - paddingHeight) }
|
|
1027
1223
|
let roomWidth = max(0, (width ?? maxWidth) - paddingWidth)
|
|
@@ -1046,7 +1242,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1046
1242
|
let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
|
|
1047
1243
|
|
|
1048
1244
|
if place {
|
|
1049
|
-
let content = CGRect(x:
|
|
1245
|
+
let content = CGRect(x: contentPadding.left, y: contentPadding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
|
|
1050
1246
|
for child in children where child.craftLayoutStyle.position == "absolute" {
|
|
1051
1247
|
let style = child.craftLayoutStyle
|
|
1052
1248
|
var fixedWidth = style.left != nil && style.right != nil
|
|
@@ -1432,8 +1628,8 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1432
1628
|
? (sharedLine?[offset].position ?? (mainOffset + item.mainLeading))
|
|
1433
1629
|
: mainOffset + item.mainLeading
|
|
1434
1630
|
let frame = horizontal
|
|
1435
|
-
? CGRect(x:
|
|
1436
|
-
: CGRect(x:
|
|
1631
|
+
? CGRect(x: contentPadding.left + mainPosition, y: contentPadding.top + physicalLineOffset + physicalCrossPosition, width: item.main, height: item.cross)
|
|
1632
|
+
: CGRect(x: contentPadding.left + physicalLineOffset + physicalCrossPosition, y: contentPadding.top + mainPosition, width: item.cross, height: item.main)
|
|
1437
1633
|
frames.append((item.view, frame))
|
|
1438
1634
|
if !useSharedPositions { mainOffset += item.mainMargins + item.main + mainGap + between }
|
|
1439
1635
|
}
|
|
@@ -1475,7 +1671,10 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1475
1671
|
let align = style.alignSelf ?? alignItems
|
|
1476
1672
|
var fixedWidth = style.width?.resolve(cellWidth)
|
|
1477
1673
|
var fixedHeight = style.height?.resolve(nil)
|
|
1478
|
-
|
|
1674
|
+
// A cell's item is as wide as its column unless it sets a width
|
|
1675
|
+
// (or an aspect ratio derives one from its height), whatever its
|
|
1676
|
+
// height: a fixed-height swatch with no content was drawn 0 wide.
|
|
1677
|
+
if fixedWidth == nil && align == "stretch" && (fixedHeight == nil || style.aspectRatio == nil) { fixedWidth = available }
|
|
1479
1678
|
(fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
|
|
1480
1679
|
let size = CraftNativeFlexLayout.measure(
|
|
1481
1680
|
child,
|
|
@@ -1506,7 +1705,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1506
1705
|
let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
|
|
1507
1706
|
var explicitWidth = style.width?.resolve(cellWidth)
|
|
1508
1707
|
var explicitHeight = style.height?.resolve(nil)
|
|
1509
|
-
if explicitWidth == nil && explicitHeight == nil
|
|
1708
|
+
if explicitWidth == nil && align == "stretch" && (explicitHeight == nil || style.aspectRatio == nil) {
|
|
1510
1709
|
explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1511
1710
|
}
|
|
1512
1711
|
(explicitWidth, explicitHeight) = aspectDimensions(style, width: explicitWidth, height: explicitHeight)
|
|
@@ -1526,7 +1725,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1526
1725
|
if let sharedFrames {
|
|
1527
1726
|
for (index, child) in children.enumerated() {
|
|
1528
1727
|
let frame = sharedFrames[index]
|
|
1529
|
-
frames.append((child, CGRect(x:
|
|
1728
|
+
frames.append((child, CGRect(x: contentPadding.left + frame.x, y: contentPadding.top + frame.y, width: frame.width, height: frame.height)))
|
|
1530
1729
|
}
|
|
1531
1730
|
return CGSize(width: width, height: innerHeight ?? height)
|
|
1532
1731
|
}
|
|
@@ -1550,7 +1749,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1550
1749
|
case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
|
|
1551
1750
|
default: break
|
|
1552
1751
|
}
|
|
1553
|
-
var rowTop =
|
|
1752
|
+
var rowTop = contentPadding.top + rowLeading
|
|
1554
1753
|
for (index, child) in children.enumerated() {
|
|
1555
1754
|
let style = child.craftLayoutStyle
|
|
1556
1755
|
let row = index / columns
|
|
@@ -1562,7 +1761,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1562
1761
|
let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
|
|
1563
1762
|
let childWidth = min(availableWidth, size.width)
|
|
1564
1763
|
let childHeight = align == "stretch" && style.height == nil && style.aspectRatio == nil ? availableHeight : min(availableHeight, size.height)
|
|
1565
|
-
let x =
|
|
1764
|
+
let x = contentPadding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
|
|
1566
1765
|
+ (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
|
|
1567
1766
|
let y = rowTop + style.marginTop
|
|
1568
1767
|
+ (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
|
|
@@ -1574,6 +1773,41 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1574
1773
|
}
|
|
1575
1774
|
}
|
|
1576
1775
|
|
|
1776
|
+
/// A container that applies only the requested system safe-area edges. The
|
|
1777
|
+
/// host keeps the root edge-to-edge when this node is the screen root so the
|
|
1778
|
+
/// node, rather than the controller's outer stack, owns the insets.
|
|
1779
|
+
private final class CraftNativeSafeAreaView: CraftNativeFlowView {
|
|
1780
|
+
private var edges: Set<String> = ["top", "right", "bottom", "left"]
|
|
1781
|
+
|
|
1782
|
+
func setSafeAreaEdges(_ raw: Any?) {
|
|
1783
|
+
guard let values = raw as? [Any] else {
|
|
1784
|
+
edges = ["top", "right", "bottom", "left"]
|
|
1785
|
+
updateSafeAreaPadding()
|
|
1786
|
+
return
|
|
1787
|
+
}
|
|
1788
|
+
edges = Set(values.compactMap { value in
|
|
1789
|
+
guard let edge = value as? String, ["top", "right", "bottom", "left"].contains(edge) else { return nil }
|
|
1790
|
+
return edge
|
|
1791
|
+
})
|
|
1792
|
+
updateSafeAreaPadding()
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
override func safeAreaInsetsDidChange() {
|
|
1796
|
+
super.safeAreaInsetsDidChange()
|
|
1797
|
+
updateSafeAreaPadding()
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
private func updateSafeAreaPadding() {
|
|
1801
|
+
let insets = safeAreaInsets
|
|
1802
|
+
safeAreaPadding = UIEdgeInsets(
|
|
1803
|
+
top: edges.contains("top") ? insets.top : 0,
|
|
1804
|
+
left: edges.contains("left") ? insets.left : 0,
|
|
1805
|
+
bottom: edges.contains("bottom") ? insets.bottom : 0,
|
|
1806
|
+
right: edges.contains("right") ? insets.right : 0
|
|
1807
|
+
)
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1577
1811
|
/// A flex container that responds to the controller's keyboard overlap.
|
|
1578
1812
|
/// `padding` keeps the content visible without moving the field; `position`
|
|
1579
1813
|
/// lifts the whole container; `height` reduces its viewport.
|
|
@@ -1773,6 +2007,18 @@ private final class CraftNativeModalView: UIView {
|
|
|
1773
2007
|
}
|
|
1774
2008
|
}
|
|
1775
2009
|
|
|
2010
|
+
/// Whether a scrolling view's gesture runs together with `other`. A scroll
|
|
2011
|
+
/// view is the delegate of its own pan as well as of its keyboard tap, so this
|
|
2012
|
+
/// answers for both. Everything runs together (the keyboard tap watches
|
|
2013
|
+
/// alongside a control's own gestures; the back-swipe and the tab swipe decide
|
|
2014
|
+
/// for themselves) except two scroll views' pans: run together, a drag on a
|
|
2015
|
+
/// nested scroll view scrolled its parent at the same time.
|
|
2016
|
+
func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestureRecognizer) -> Bool {
|
|
2017
|
+
guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer,
|
|
2018
|
+
let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer else { return true }
|
|
2019
|
+
return false
|
|
2020
|
+
}
|
|
2021
|
+
|
|
1776
2022
|
private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
|
|
1777
2023
|
let contentStack = CraftNativeFlowView()
|
|
1778
2024
|
private let keyboardTapGesture = UITapGestureRecognizer()
|
|
@@ -1994,6 +2240,22 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
1994
2240
|
}
|
|
1995
2241
|
}
|
|
1996
2242
|
|
|
2243
|
+
/// Applies the shared RefreshControl appearance props. UIKit's refresh
|
|
2244
|
+
/// control owns the spinner and optional title; keep the authored values
|
|
2245
|
+
/// on the control so a later reconciliation can clear them as well.
|
|
2246
|
+
func setRefreshAppearance(_ props: [String: Any]) {
|
|
2247
|
+
pullToRefresh.tintColor = (props["tintColor"] as? String).flatMap { UIColor(hex: $0) }
|
|
2248
|
+
guard let title = props["title"] as? String, !title.isEmpty else {
|
|
2249
|
+
pullToRefresh.attributedTitle = nil
|
|
2250
|
+
return
|
|
2251
|
+
}
|
|
2252
|
+
var attributes: [NSAttributedString.Key: Any] = [:]
|
|
2253
|
+
if let titleColor = (props["titleColor"] as? String).flatMap({ UIColor(hex: $0) }) {
|
|
2254
|
+
attributes[.foregroundColor] = titleColor
|
|
2255
|
+
}
|
|
2256
|
+
pullToRefresh.attributedTitle = NSAttributedString(string: title, attributes: attributes)
|
|
2257
|
+
}
|
|
2258
|
+
|
|
1997
2259
|
var isRefreshingNow: Bool { pullToRefresh.isRefreshing }
|
|
1998
2260
|
|
|
1999
2261
|
@objc private func refreshPulled() {
|
|
@@ -2025,7 +2287,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
2025
2287
|
}
|
|
2026
2288
|
|
|
2027
2289
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
2028
|
-
|
|
2290
|
+
craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
|
|
2029
2291
|
}
|
|
2030
2292
|
|
|
2031
2293
|
func setAxis(_ axis: NSLayoutConstraint.Axis) {
|
|
@@ -2510,6 +2772,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2510
2772
|
private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
|
|
2511
2773
|
private var imageLoadEndHandlers: [ObjectIdentifier: String] = [:]
|
|
2512
2774
|
private var imageErrorHandlers: [ObjectIdentifier: String] = [:]
|
|
2775
|
+
/// Native links keep their target separate from optional press handlers.
|
|
2776
|
+
/// The compiler can emit an anchor as `TouchableOpacity`, so this map is
|
|
2777
|
+
/// intentionally shared by Link and the generic pressable path.
|
|
2778
|
+
private var linkTargets: [ObjectIdentifier: String] = [:]
|
|
2513
2779
|
private var renderedRoot: RenderedNode?
|
|
2514
2780
|
private weak var lastFocusedInput: UIView?
|
|
2515
2781
|
private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
|
|
@@ -2582,11 +2848,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2582
2848
|
view.addSubview(rootStack)
|
|
2583
2849
|
rootTopToSafeArea = rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
|
|
2584
2850
|
rootTopToView = rootStack.topAnchor.constraint(equalTo: view.topAnchor)
|
|
2851
|
+
rootBottomToSafeArea = rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
|
|
2852
|
+
rootBottomToView = rootStack.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
|
2853
|
+
rootLeadingToSafeArea = rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor)
|
|
2854
|
+
rootLeadingToView = rootStack.leadingAnchor.constraint(equalTo: view.leadingAnchor)
|
|
2855
|
+
rootTrailingToSafeArea = rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
|
|
2856
|
+
rootTrailingToView = rootStack.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
|
2585
2857
|
NSLayoutConstraint.activate([
|
|
2586
2858
|
rootTopToSafeArea!,
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2859
|
+
rootBottomToSafeArea!,
|
|
2860
|
+
rootLeadingToSafeArea!,
|
|
2861
|
+
rootTrailingToSafeArea!
|
|
2590
2862
|
])
|
|
2591
2863
|
setupJavaScript()
|
|
2592
2864
|
loadBundle()
|
|
@@ -3137,7 +3409,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3137
3409
|
private var titleFromOptions = false
|
|
3138
3410
|
private var rootTopToSafeArea: NSLayoutConstraint?
|
|
3139
3411
|
private var rootTopToView: NSLayoutConstraint?
|
|
3412
|
+
private var rootBottomToSafeArea: NSLayoutConstraint?
|
|
3413
|
+
private var rootBottomToView: NSLayoutConstraint?
|
|
3414
|
+
private var rootLeadingToSafeArea: NSLayoutConstraint?
|
|
3415
|
+
private var rootLeadingToView: NSLayoutConstraint?
|
|
3416
|
+
private var rootTrailingToSafeArea: NSLayoutConstraint?
|
|
3417
|
+
private var rootTrailingToView: NSLayoutConstraint?
|
|
3418
|
+
private var rootIsSafeAreaView = false
|
|
3140
3419
|
private var navigationBarHidden: Bool?
|
|
3420
|
+
/// A native `<StatusBar>` node overrides the app default for this screen.
|
|
3421
|
+
private var statusBarStyleOverride: UIStatusBarStyle?
|
|
3422
|
+
private var statusBarHiddenOverride: Bool?
|
|
3141
3423
|
/// The app's tab bar away while this screen shows (`tabBarHidden`).
|
|
3142
3424
|
private var tabBarHidden = false
|
|
3143
3425
|
/// Whether a back-swipe may leave this screen at all, the edge's
|
|
@@ -3158,6 +3440,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3158
3440
|
/// full-screen timer is black edge to edge. nil is the app's.
|
|
3159
3441
|
private var screenBackgroundColor: UIColor?
|
|
3160
3442
|
|
|
3443
|
+
override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
3444
|
+
statusBarStyleOverride ?? .default
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
override var prefersStatusBarHidden: Bool {
|
|
3448
|
+
statusBarHiddenOverride ?? false
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3161
3451
|
/// Whether this screen asked for a bar of its own through `setOptions`: a
|
|
3162
3452
|
/// title, a large title or buttons, and not `hidden`. A tab's root screen
|
|
3163
3453
|
/// in a hybrid app is shown bare unless it did.
|
|
@@ -3221,6 +3511,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3221
3511
|
}
|
|
3222
3512
|
}
|
|
3223
3513
|
|
|
3514
|
+
private func applyStatusBar(_ props: [String: Any]) {
|
|
3515
|
+
let style: UIStatusBarStyle?
|
|
3516
|
+
switch (props["barStyle"] as? String)?.lowercased() {
|
|
3517
|
+
case "light-content", "light": style = .lightContent
|
|
3518
|
+
case "dark-content", "dark": style = .darkContent
|
|
3519
|
+
case "default": style = .default
|
|
3520
|
+
default: style = nil
|
|
3521
|
+
}
|
|
3522
|
+
let hidden = props["hidden"] as? Bool
|
|
3523
|
+
if statusBarStyleOverride != style || statusBarHiddenOverride != hidden {
|
|
3524
|
+
statusBarStyleOverride = style
|
|
3525
|
+
statusBarHiddenOverride = hidden
|
|
3526
|
+
setNeedsStatusBarAppearanceUpdate()
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3224
3530
|
/// A bar button from `{ id, symbol | title }`. Its tap is the event
|
|
3225
3531
|
/// `navButton` with `{ id }`.
|
|
3226
3532
|
private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
|
|
@@ -3417,10 +3723,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3417
3723
|
// effect, rather than over the bar's back button. Other screens stay
|
|
3418
3724
|
// inside the safe area.
|
|
3419
3725
|
let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leading(renderedRoot))
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3726
|
+
let fullEdgeToEdge = rootIsSafeAreaView
|
|
3727
|
+
let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
|
|
3728
|
+
rootTopToSafeArea?.isActive = !topEdgeToEdge
|
|
3729
|
+
rootTopToView?.isActive = topEdgeToEdge
|
|
3730
|
+
rootBottomToSafeArea?.isActive = !fullEdgeToEdge
|
|
3731
|
+
rootBottomToView?.isActive = fullEdgeToEdge
|
|
3732
|
+
rootLeadingToSafeArea?.isActive = !fullEdgeToEdge
|
|
3733
|
+
rootLeadingToView?.isActive = fullEdgeToEdge
|
|
3734
|
+
rootTrailingToSafeArea?.isActive = !fullEdgeToEdge
|
|
3735
|
+
rootTrailingToView?.isActive = fullEdgeToEdge
|
|
3424
3736
|
scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never
|
|
3425
3737
|
guard scroll !== trackedScrollView else { return }
|
|
3426
3738
|
trackedScrollView = scroll
|
|
@@ -3898,6 +4210,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3898
4210
|
|
|
3899
4211
|
private func renderCommitted(_ document: [String: Any]) {
|
|
3900
4212
|
let focused = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
4213
|
+
statusBarStyleOverride = nil
|
|
4214
|
+
statusBarHiddenOverride = nil
|
|
3901
4215
|
let previous = renderedRoot
|
|
3902
4216
|
let next = reconcile(document, identity: "root", path: "root", previous: previous)
|
|
3903
4217
|
if previous?.view !== next.view {
|
|
@@ -3920,15 +4234,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3920
4234
|
rootStack.setLayoutStyle(rootStyle, for: next.view)
|
|
3921
4235
|
rootStack.invalidateMeasurements()
|
|
3922
4236
|
renderedRoot = next
|
|
4237
|
+
rootIsSafeAreaView = next.type == "SafeAreaView"
|
|
3923
4238
|
updateKeyboardAvoidingViews()
|
|
3924
4239
|
syncGlobalKeyboardSafeArea()
|
|
3925
4240
|
trackContentScrollView()
|
|
4241
|
+
setNeedsStatusBarAppearanceUpdate()
|
|
3926
4242
|
restoreFocus(focused)
|
|
3927
4243
|
}
|
|
3928
4244
|
|
|
3929
4245
|
private func clearRenderedTree() {
|
|
3930
4246
|
if let root = renderedRoot { detach(root, from: rootStack) }
|
|
3931
4247
|
renderedRoot = nil
|
|
4248
|
+
rootIsSafeAreaView = false
|
|
4249
|
+
rootTopToSafeArea?.isActive = true
|
|
4250
|
+
rootTopToView?.isActive = false
|
|
4251
|
+
rootBottomToSafeArea?.isActive = true
|
|
4252
|
+
rootBottomToView?.isActive = false
|
|
4253
|
+
rootLeadingToSafeArea?.isActive = true
|
|
4254
|
+
rootLeadingToView?.isActive = false
|
|
4255
|
+
rootTrailingToSafeArea?.isActive = true
|
|
4256
|
+
rootTrailingToView?.isActive = false
|
|
4257
|
+
statusBarStyleOverride = nil
|
|
4258
|
+
statusBarHiddenOverride = nil
|
|
4259
|
+
setNeedsStatusBarAppearanceUpdate()
|
|
3932
4260
|
syncGlobalKeyboardSafeArea()
|
|
3933
4261
|
}
|
|
3934
4262
|
|
|
@@ -3963,14 +4291,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3963
4291
|
let result = current.view
|
|
3964
4292
|
current.protocolId = node["id"] as? String
|
|
3965
4293
|
current.style = style
|
|
4294
|
+
let linkTarget = ["href", "to"].lazy.compactMap { props[$0] as? String }
|
|
4295
|
+
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
|
|
4296
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
4297
|
+
if ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type),
|
|
4298
|
+
props["disabled"] as? Bool != true, let linkTarget {
|
|
4299
|
+
linkTargets[ObjectIdentifier(result)] = linkTarget
|
|
4300
|
+
} else {
|
|
4301
|
+
linkTargets.removeValue(forKey: ObjectIdentifier(result))
|
|
4302
|
+
}
|
|
3966
4303
|
result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
|
|
3967
4304
|
if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "SectionList" && type != "FlatList" && type != "Modal" {
|
|
3968
4305
|
result.setContentHuggingPriority(.required, for: .vertical)
|
|
3969
4306
|
}
|
|
3970
4307
|
applyViewStyle(style, to: result, node: current)
|
|
4308
|
+
if type == "SafeAreaView", let safeArea = result as? CraftNativeSafeAreaView {
|
|
4309
|
+
safeArea.setSafeAreaEdges(props["edges"])
|
|
4310
|
+
}
|
|
3971
4311
|
updateLayoutHandler(events["onLayout"], for: result)
|
|
4312
|
+
if type == "StatusBar" { applyStatusBar(props) }
|
|
3972
4313
|
|
|
3973
4314
|
switch type {
|
|
4315
|
+
case "StatusBar", "RefreshControl":
|
|
4316
|
+
// StatusBar is a screen trait, and RefreshControl is consumed by
|
|
4317
|
+
// its nearest ScrollView. Neither contributes a visible child.
|
|
4318
|
+
result.isHidden = true
|
|
4319
|
+
result.isUserInteractionEnabled = false
|
|
3974
4320
|
case "Text":
|
|
3975
4321
|
let label = result as! UILabel
|
|
3976
4322
|
label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
|
|
@@ -4108,11 +4454,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4108
4454
|
case "DatePicker":
|
|
4109
4455
|
let picker = result as! UIDatePicker
|
|
4110
4456
|
let mode = Self.datePickerMode(props["mode"])
|
|
4111
|
-
|
|
4112
|
-
|
|
4457
|
+
// A countdown is wheels only: UIKit asserts (and the app aborts)
|
|
4458
|
+
// on a countdown in the compact or inline style, so the style is
|
|
4459
|
+
// settled before the mode becomes a countdown, and after it
|
|
4460
|
+
// stops being one.
|
|
4461
|
+
let display: UIDatePickerStyle = mode == .countDownTimer ? .wheels
|
|
4462
|
+
: props["display"] as? String == "inline" ? .inline
|
|
4113
4463
|
: props["display"] as? String == "wheels" || props["display"] as? String == "spinner" ? .wheels
|
|
4114
4464
|
: .compact
|
|
4115
|
-
if
|
|
4465
|
+
if mode == .countDownTimer {
|
|
4466
|
+
if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
|
|
4467
|
+
if picker.datePickerMode != mode { picker.datePickerMode = mode }
|
|
4468
|
+
} else {
|
|
4469
|
+
if picker.datePickerMode != mode { picker.datePickerMode = mode }
|
|
4470
|
+
if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
|
|
4471
|
+
}
|
|
4116
4472
|
// UIKit takes an interval that divides an hour, and ignores others.
|
|
4117
4473
|
let interval = (props["minuteInterval"] as? NSNumber)?.intValue ?? 1
|
|
4118
4474
|
picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
|
|
@@ -4242,7 +4598,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4242
4598
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
|
|
4243
4599
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
4244
4600
|
updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
|
|
4245
|
-
updateImage(image, source: props["source"])
|
|
4601
|
+
updateImage(image, source: props["source"], defaultSource: props["defaultSource"])
|
|
4246
4602
|
case "ImageBackground":
|
|
4247
4603
|
let background = result as! CraftNativeImageBackgroundView
|
|
4248
4604
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
@@ -4252,7 +4608,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4252
4608
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
|
|
4253
4609
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
|
|
4254
4610
|
updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: background)
|
|
4255
|
-
updateImage(background, source: props["source"])
|
|
4611
|
+
updateImage(background, source: props["source"], defaultSource: props["defaultSource"])
|
|
4256
4612
|
configureStack(background.contentStack, style: style)
|
|
4257
4613
|
reconcileChildren(children, in: background.contentStack, parent: current, path: path, style: style)
|
|
4258
4614
|
case "KeyboardAvoidingView":
|
|
@@ -4276,6 +4632,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4276
4632
|
controls: (props["controls"] as? Bool) == true,
|
|
4277
4633
|
resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
4278
4634
|
)
|
|
4635
|
+
case "Map":
|
|
4636
|
+
let map = result as! CraftNativeMapView
|
|
4637
|
+
map.configure(props: props, children: children) { [weak self, weak map] markerID in
|
|
4638
|
+
guard let self, let map, let handler = map.markerHandlers[markerID], !handler.isEmpty else { return }
|
|
4639
|
+
self.send(type: "EVENT", payload: [
|
|
4640
|
+
"handlerName": handler,
|
|
4641
|
+
"nativeEvent": ["id": markerID],
|
|
4642
|
+
])
|
|
4643
|
+
}
|
|
4279
4644
|
case "ScrollView", "SectionList":
|
|
4280
4645
|
let scroll = result as! CraftNativeScrollView
|
|
4281
4646
|
let flexDirection = style["flexDirection"] as? String
|
|
@@ -4301,15 +4666,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4301
4666
|
updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
|
|
4302
4667
|
updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
|
|
4303
4668
|
updateAuxiliaryHandler(events["onMomentumScrollEnd"], in: &scrollMomentumEndHandlers, for: scroll)
|
|
4304
|
-
|
|
4669
|
+
let refreshNode = children.compactMap { $0 as? [String: Any] }.first {
|
|
4670
|
+
canonicalType($0["type"] as? String ?? "") == "RefreshControl"
|
|
4671
|
+
}
|
|
4672
|
+
let refreshProps = refreshNode?["props"] as? [String: Any] ?? [:]
|
|
4673
|
+
let refreshEvents = refreshNode?["events"] as? [String: String] ?? [:]
|
|
4674
|
+
let refreshHandlerName = nonEmptyHandler(refreshEvents["onRefresh"])
|
|
4675
|
+
?? nonEmptyHandler(events["onRefresh"])
|
|
4676
|
+
scroll.setRefreshHandler(refreshHandlerName.map { [weak self] handler in
|
|
4305
4677
|
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
4306
|
-
}, refreshing: props["refreshing"] as? Bool == true)
|
|
4678
|
+
}, refreshing: (refreshNode == nil ? props["refreshing"] : refreshProps["refreshing"]) as? Bool == true)
|
|
4679
|
+
scroll.setRefreshAppearance(refreshProps)
|
|
4307
4680
|
var contentStyle = style
|
|
4308
4681
|
if let override = props["contentContainerStyle"] as? [String: Any] {
|
|
4309
4682
|
contentStyle.merge(override) { _, next in next }
|
|
4310
4683
|
}
|
|
4311
4684
|
configureStack(scroll.contentStack, style: contentStyle)
|
|
4312
|
-
reconcileChildren(children
|
|
4685
|
+
reconcileChildren(children.filter {
|
|
4686
|
+
guard let child = $0 as? [String: Any] else { return true }
|
|
4687
|
+
return canonicalType(child["type"] as? String ?? "") != "RefreshControl"
|
|
4688
|
+
}, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
|
|
4313
4689
|
case "FlatList":
|
|
4314
4690
|
let list = result as! CraftNativeFlatList
|
|
4315
4691
|
reconcileFlatList(
|
|
@@ -4326,7 +4702,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4326
4702
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
4327
4703
|
}
|
|
4328
4704
|
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
4329
|
-
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
4705
|
+
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
|
|
4330
4706
|
let disabled = props["disabled"] as? Bool == true
|
|
4331
4707
|
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
4332
4708
|
updatePressFeedback(
|
|
@@ -4391,6 +4767,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4391
4767
|
return slider
|
|
4392
4768
|
case "ActivityIndicator":
|
|
4393
4769
|
return UIActivityIndicatorView(style: .medium)
|
|
4770
|
+
case "StatusBar", "RefreshControl":
|
|
4771
|
+
return CraftNativeFlowView()
|
|
4394
4772
|
case "Image":
|
|
4395
4773
|
return UIImageView()
|
|
4396
4774
|
case "ImageBackground":
|
|
@@ -4399,6 +4777,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4399
4777
|
return CraftNativeKeyboardAvoidingView()
|
|
4400
4778
|
case "Video":
|
|
4401
4779
|
return CraftNativeVideoView(host: self)
|
|
4780
|
+
case "Map":
|
|
4781
|
+
return CraftNativeMapView()
|
|
4402
4782
|
case "Icon":
|
|
4403
4783
|
let icon = CraftNativeIconView()
|
|
4404
4784
|
icon.contentMode = .scaleAspectFit
|
|
@@ -4409,8 +4789,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4409
4789
|
return CraftNativeFlatList()
|
|
4410
4790
|
case "Svg":
|
|
4411
4791
|
return CraftNativeSvgView()
|
|
4412
|
-
case "View"
|
|
4792
|
+
case "View":
|
|
4413
4793
|
return CraftNativeFlowView()
|
|
4794
|
+
case "SafeAreaView":
|
|
4795
|
+
return CraftNativeSafeAreaView()
|
|
4414
4796
|
case "TouchableOpacity", "TouchableHighlight", "Pressable":
|
|
4415
4797
|
return CraftNativeFlowView()
|
|
4416
4798
|
default:
|
|
@@ -4433,9 +4815,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4433
4815
|
case "switch": return "Switch"
|
|
4434
4816
|
case "slider": return "Slider"
|
|
4435
4817
|
case "activityindicator": return "ActivityIndicator"
|
|
4818
|
+
case "statusbar": return "StatusBar"
|
|
4819
|
+
case "refreshcontrol": return "RefreshControl"
|
|
4436
4820
|
case "image": return "Image"
|
|
4437
4821
|
case "imagebackground": return "ImageBackground"
|
|
4438
4822
|
case "video": return "Video"
|
|
4823
|
+
case "map": return "Map"
|
|
4439
4824
|
case "icon": return "Icon"
|
|
4440
4825
|
case "scrollview": return "ScrollView"
|
|
4441
4826
|
case "sectionlist": return "SectionList"
|
|
@@ -4640,6 +5025,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4640
5025
|
let id = ObjectIdentifier(node.view)
|
|
4641
5026
|
if let modal = node.view as? CraftNativeModalView { modal.resetForTeardown() }
|
|
4642
5027
|
handlers.removeValue(forKey: id)
|
|
5028
|
+
linkTargets.removeValue(forKey: id)
|
|
4643
5029
|
pressInHandlers.removeValue(forKey: id)
|
|
4644
5030
|
pressOutHandlers.removeValue(forKey: id)
|
|
4645
5031
|
layoutHandlers.removeValue(forKey: id)
|
|
@@ -4754,7 +5140,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4754
5140
|
let id = ObjectIdentifier(view)
|
|
4755
5141
|
let handler = handler?.isEmpty == false ? handler : nil
|
|
4756
5142
|
updateHandler(handler, for: view)
|
|
4757
|
-
|
|
5143
|
+
let actionable = handler != nil || linkTargets[id] != nil
|
|
5144
|
+
if actionable, tapRecognizers[id] == nil {
|
|
4758
5145
|
let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
|
|
4759
5146
|
tapRecognizers[id] = recognizer
|
|
4760
5147
|
view.addGestureRecognizer(recognizer)
|
|
@@ -4763,7 +5150,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4763
5150
|
}
|
|
4764
5151
|
// A container keeps receiving touches without a handler of its own,
|
|
4765
5152
|
// or every control inside it (and the screen root) would be dead.
|
|
4766
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled =
|
|
5153
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
4767
5154
|
}
|
|
4768
5155
|
|
|
4769
5156
|
private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
|
|
@@ -4772,7 +5159,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4772
5159
|
if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
|
|
4773
5160
|
guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
|
|
4774
5161
|
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
|
|
4775
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
5162
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
4776
5163
|
return
|
|
4777
5164
|
}
|
|
4778
5165
|
if pressFeedbackRecognizers[id] == nil {
|
|
@@ -4801,7 +5188,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4801
5188
|
if let recognizer = longPressRecognizers.removeValue(forKey: id) {
|
|
4802
5189
|
view.removeGestureRecognizer(recognizer)
|
|
4803
5190
|
}
|
|
4804
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || view is CraftNativeFlowView }
|
|
5191
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
|
|
4805
5192
|
}
|
|
4806
5193
|
}
|
|
4807
5194
|
|
|
@@ -5270,7 +5657,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5270
5657
|
return ["localhost", "127.0.0.1", "::1"].contains(bare) || bare.hasSuffix(".localhost")
|
|
5271
5658
|
}
|
|
5272
5659
|
|
|
5273
|
-
private func updateImage(_ view: UIImageView, source: Any?) {
|
|
5660
|
+
private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
|
|
5274
5661
|
let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
|
|
5275
5662
|
let id = ObjectIdentifier(view)
|
|
5276
5663
|
guard let uri, !uri.isEmpty else {
|
|
@@ -5284,7 +5671,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5284
5671
|
imageTasks.removeValue(forKey: id)?.cancel()
|
|
5285
5672
|
imageSources[id] = uri
|
|
5286
5673
|
imageErrors.removeValue(forKey: id)
|
|
5287
|
-
view.image =
|
|
5674
|
+
view.image = immediateImage(defaultSource).map { imageForDisplay($0, view: view) }
|
|
5288
5675
|
view.accessibilityValue = nil
|
|
5289
5676
|
emitImageEvent(view, handler: imageLoadStartHandlers[id])
|
|
5290
5677
|
|
|
@@ -5332,6 +5719,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5332
5719
|
imageFailure(view, uri: uri, message: "Unsupported image source")
|
|
5333
5720
|
}
|
|
5334
5721
|
|
|
5722
|
+
/// `defaultSource` is intentionally local-only: data and bundled assets
|
|
5723
|
+
/// can be shown before a network image arrives without starting another
|
|
5724
|
+
/// request or bypassing the source's URL policy.
|
|
5725
|
+
private func immediateImage(_ source: Any?) -> UIImage? {
|
|
5726
|
+
let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
|
|
5727
|
+
guard let uri, !uri.isEmpty else { return nil }
|
|
5728
|
+
if uri.hasPrefix("data:image/"),
|
|
5729
|
+
let comma = uri.firstIndex(of: ","),
|
|
5730
|
+
let data = Data(base64Encoded: String(uri[uri.index(after: comma)...])) {
|
|
5731
|
+
return UIImage(data: data)
|
|
5732
|
+
}
|
|
5733
|
+
return !uri.contains(":") ? UIImage(named: uri) : nil
|
|
5734
|
+
}
|
|
5735
|
+
|
|
5335
5736
|
private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
|
|
5336
5737
|
imageErrors[ObjectIdentifier(view)] = message
|
|
5337
5738
|
view.image = nil
|
|
@@ -5425,6 +5826,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5425
5826
|
|
|
5426
5827
|
@objc private func buttonPressed(_ sender: UIButton) {
|
|
5427
5828
|
let focused = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
5829
|
+
if let target = linkTargets[ObjectIdentifier(sender)] {
|
|
5830
|
+
hybridEvents?.nativeScreen(self, open: target)
|
|
5831
|
+
return
|
|
5832
|
+
}
|
|
5428
5833
|
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
5429
5834
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
|
|
5430
5835
|
restoreFocus(focused)
|
|
@@ -5534,7 +5939,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5534
5939
|
let value = snappedSliderValue(sender.value, for: sender)
|
|
5535
5940
|
if sender.value != value { sender.value = value }
|
|
5536
5941
|
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
5537
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value]])
|
|
5942
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value)]])
|
|
5943
|
+
}
|
|
5944
|
+
|
|
5945
|
+
/// A slider's value as the script reads it: the shortest decimal that is
|
|
5946
|
+
/// the same Float, so a step of 0.1 gives 0.8 and not 0.800000011920929.
|
|
5947
|
+
static func sliderEventValue(_ value: Float) -> Double {
|
|
5948
|
+
Double(value.description) ?? Double(value)
|
|
5538
5949
|
}
|
|
5539
5950
|
|
|
5540
5951
|
/// `@change` on a DatePicker: the value as `<input type="date|time|
|
|
@@ -5631,7 +6042,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5631
6042
|
|
|
5632
6043
|
@objc private func sliderFinished(_ sender: UISlider) {
|
|
5633
6044
|
guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
|
|
5634
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
|
|
6045
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender))]])
|
|
5635
6046
|
}
|
|
5636
6047
|
|
|
5637
6048
|
func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
|
|
@@ -5681,8 +6092,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5681
6092
|
}
|
|
5682
6093
|
|
|
5683
6094
|
@objc private func viewPressed(_ sender: UITapGestureRecognizer) {
|
|
5684
|
-
guard let view = sender.view
|
|
6095
|
+
guard let view = sender.view else { return }
|
|
5685
6096
|
let id = ObjectIdentifier(view)
|
|
6097
|
+
if let target = linkTargets[id] {
|
|
6098
|
+
hybridEvents?.nativeScreen(self, open: target)
|
|
6099
|
+
return
|
|
6100
|
+
}
|
|
6101
|
+
guard let handler = handlers[id] else { return }
|
|
5686
6102
|
if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
|
|
5687
6103
|
let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
|
|
5688
6104
|
UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
|