@craft-native/ios 0.0.238 → 0.0.242
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/cli.js +22 -7
- package/dist/index.d.ts +12 -1
- package/dist/index.js +32 -7
- package/dist/testing.d.ts +14 -2
- package/package.json +1 -1
- package/templates/CraftApp.swift +117 -29
- package/templates/CraftHybrid.swift +193 -29
- package/templates/CraftNativeActions.swift +26 -27
- package/templates/CraftNativeFiles.swift +205 -1
- package/templates/CraftNativeScreen.swift +230 -33
- package/templates/testing/DriverTests.swift.template +37 -0
|
@@ -139,7 +139,7 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
139
139
|
var alignItems: UInt32 = 3
|
|
140
140
|
var alignSelf: UInt32 = 0
|
|
141
141
|
var flexWrap: UInt32 = 0
|
|
142
|
-
var alignContent: UInt32 =
|
|
142
|
+
var alignContent: UInt32 = 5
|
|
143
143
|
var marginTop: Float = 0
|
|
144
144
|
var marginRight: Float = 0
|
|
145
145
|
var marginBottom: Float = 0
|
|
@@ -180,7 +180,7 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
180
180
|
var alignItems: UInt32 = 3
|
|
181
181
|
var alignSelf: UInt32 = 0
|
|
182
182
|
var flexWrap: UInt32 = 0
|
|
183
|
-
var alignContent: UInt32 =
|
|
183
|
+
var alignContent: UInt32 = 5
|
|
184
184
|
var marginTop: Float = 0
|
|
185
185
|
var marginRight: Float = 0
|
|
186
186
|
var marginBottom: Float = 0
|
|
@@ -222,7 +222,7 @@ private enum CraftNativeTreeLayoutBridge {
|
|
|
222
222
|
var alignItems: UInt32 = 3
|
|
223
223
|
var alignSelf: UInt32 = 0
|
|
224
224
|
var flexWrap: UInt32 = 0
|
|
225
|
-
var alignContent: UInt32 =
|
|
225
|
+
var alignContent: UInt32 = 5
|
|
226
226
|
var marginTop: Float = 0
|
|
227
227
|
var marginRight: Float = 0
|
|
228
228
|
var marginBottom: Float = 0
|
|
@@ -1171,8 +1171,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1171
1171
|
private var authoredHeading: Double?
|
|
1172
1172
|
private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
|
|
1173
1173
|
private var markerBearings: [String: Double] = [:]
|
|
1174
|
+
private var markerPinColors: [String: UIColor] = [:]
|
|
1174
1175
|
private var tileOverlay: MKTileOverlay?
|
|
1175
1176
|
private var tileTemplate: String?
|
|
1177
|
+
private var tileAttributionBottom: NSLayoutConstraint?
|
|
1176
1178
|
private lazy var tileAttribution: UILabel = {
|
|
1177
1179
|
let label = UILabel()
|
|
1178
1180
|
label.font = .systemFont(ofSize: 9)
|
|
@@ -1181,7 +1183,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1181
1183
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
1182
1184
|
label.numberOfLines = 2
|
|
1183
1185
|
addSubview(label)
|
|
1184
|
-
|
|
1186
|
+
let bottom = label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8)
|
|
1187
|
+
tileAttributionBottom = bottom
|
|
1188
|
+
NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), bottom, label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
|
|
1185
1189
|
return label
|
|
1186
1190
|
}()
|
|
1187
1191
|
|
|
@@ -1206,6 +1210,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1206
1210
|
isScrollEnabled = props["scrollEnabled"] as? Bool ?? true
|
|
1207
1211
|
isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
|
|
1208
1212
|
isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
|
|
1213
|
+
isPitchEnabled = props["pitchEnabled"] as? Bool ?? true
|
|
1209
1214
|
mapType = Self.mapType(props["mapType"] as? String)
|
|
1210
1215
|
configureTiles(props["tileOverlay"] as? [String: Any])
|
|
1211
1216
|
showsCompass = props["showsCompass"] as? Bool ?? false
|
|
@@ -1219,6 +1224,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1219
1224
|
}
|
|
1220
1225
|
edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
|
|
1221
1226
|
var nextBearings: [String: Double] = [:]
|
|
1227
|
+
var nextPinColors: [String: UIColor] = [:]
|
|
1222
1228
|
// Showing the user dot must not trigger a permission prompt from a
|
|
1223
1229
|
// renderer pass. The app's existing location capability owns that
|
|
1224
1230
|
// prompt; a map only opts in after authorization is already granted.
|
|
@@ -1227,6 +1233,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1227
1233
|
var nextMarkers: [String: (coordinate: CLLocationCoordinate2D, title: String?)] = [:]
|
|
1228
1234
|
var nextHandlers: [String: String] = [:]
|
|
1229
1235
|
var nextPolylines: [String: (coordinates: [CLLocationCoordinate2D], color: UIColor, width: CGFloat)] = [:]
|
|
1236
|
+
var nextPolylineIDs: [String] = []
|
|
1230
1237
|
for (index, child) in children.enumerated() {
|
|
1231
1238
|
guard let node = child as? [String: Any], let rawType = node["type"] as? String else { continue }
|
|
1232
1239
|
let type = rawType.lowercased()
|
|
@@ -1239,6 +1246,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1239
1246
|
if type == "mapmarker" || type == "marker" {
|
|
1240
1247
|
guard let point = points.first else { continue }
|
|
1241
1248
|
nextMarkers[id] = (point, childProps["title"] as? String)
|
|
1249
|
+
if let color = childProps["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
|
|
1242
1250
|
if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
|
|
1243
1251
|
nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
|
|
1244
1252
|
}
|
|
@@ -1251,11 +1259,13 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1251
1259
|
let authoredWidth = Self.numeric(childProps["strokeWidth"]) ?? 3
|
|
1252
1260
|
let width = CGFloat(authoredWidth)
|
|
1253
1261
|
guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
|
|
1262
|
+
if nextPolylines[id] == nil { nextPolylineIDs.append(id) }
|
|
1254
1263
|
nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
|
|
1255
1264
|
}
|
|
1256
1265
|
}
|
|
1257
1266
|
|
|
1258
1267
|
markerBearings = nextBearings
|
|
1268
|
+
markerPinColors = nextPinColors
|
|
1259
1269
|
let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
|
|
1260
1270
|
removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
|
|
1261
1271
|
for (id, value) in nextMarkers {
|
|
@@ -1282,7 +1292,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1282
1292
|
}
|
|
1283
1293
|
overlayCoordinatesByID.removeValue(forKey: id)
|
|
1284
1294
|
}
|
|
1285
|
-
for
|
|
1295
|
+
for id in nextPolylineIDs {
|
|
1296
|
+
guard let value = nextPolylines[id] else { continue }
|
|
1286
1297
|
if let overlay = overlaysByID[id],
|
|
1287
1298
|
let coordinates = overlayCoordinatesByID[id],
|
|
1288
1299
|
Self.coordinatesEqual(coordinates, value.coordinates),
|
|
@@ -1302,6 +1313,19 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1302
1313
|
overlayWidths[ObjectIdentifier(overlay)] = value.width
|
|
1303
1314
|
addOverlay(overlay)
|
|
1304
1315
|
}
|
|
1316
|
+
// Keep authored casing/foreground order when an earlier line changes
|
|
1317
|
+
// or retained children move. Exchanging native overlays preserves the
|
|
1318
|
+
// unchanged objects, tile levels and current camera.
|
|
1319
|
+
let ownedOverlayIDs = Set(overlaysByID.values.map { ObjectIdentifier($0) })
|
|
1320
|
+
var orderedOverlays = overlays.compactMap { $0 as? MKPolyline }
|
|
1321
|
+
.filter { ownedOverlayIDs.contains(ObjectIdentifier($0)) }
|
|
1322
|
+
for (index, id) in nextPolylineIDs.enumerated() {
|
|
1323
|
+
guard let overlay = overlaysByID[id], index < orderedOverlays.count,
|
|
1324
|
+
orderedOverlays[index] !== overlay,
|
|
1325
|
+
let previousIndex = orderedOverlays.firstIndex(where: { $0 === overlay }) else { continue }
|
|
1326
|
+
exchangeOverlay(orderedOverlays[index], with: orderedOverlays[previousIndex])
|
|
1327
|
+
orderedOverlays.swapAt(index, previousIndex)
|
|
1328
|
+
}
|
|
1305
1329
|
|
|
1306
1330
|
if let region = Self.region(props["region"]) {
|
|
1307
1331
|
// `region` is controlled: an authored change is allowed to move
|
|
@@ -1411,20 +1435,27 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1411
1435
|
|
|
1412
1436
|
private func configureMarkerView(_ view: MKAnnotationView, id: String) {
|
|
1413
1437
|
if let bearing = markerBearings[id] {
|
|
1414
|
-
let configuration = UIImage.SymbolConfiguration(pointSize:
|
|
1438
|
+
let configuration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
|
|
1415
1439
|
.applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
|
|
1416
|
-
|
|
1440
|
+
// MapKit otherwise tints this as a monochrome template and loses
|
|
1441
|
+
// the contrasting route arrow when displayed over map content.
|
|
1442
|
+
view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)?
|
|
1443
|
+
.withRenderingMode(.alwaysOriginal)
|
|
1417
1444
|
view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
|
|
1418
1445
|
} else {
|
|
1446
|
+
view.tintColor = markerPinColors[id] ?? .systemRed
|
|
1419
1447
|
view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
|
|
1420
|
-
.withTintColor(.
|
|
1448
|
+
.withTintColor(view.tintColor, renderingMode: .alwaysOriginal)
|
|
1421
1449
|
view.transform = .identity
|
|
1422
1450
|
}
|
|
1423
1451
|
}
|
|
1424
1452
|
|
|
1425
1453
|
override func layoutSubviews() {
|
|
1426
1454
|
super.layoutSubviews()
|
|
1427
|
-
if tileTemplate != nil {
|
|
1455
|
+
if tileTemplate != nil {
|
|
1456
|
+
tileAttributionBottom?.constant = -min(edgePadding.bottom, max(0, bounds.height - 40)) - 8
|
|
1457
|
+
bringSubviewToFront(tileAttribution)
|
|
1458
|
+
}
|
|
1428
1459
|
if let region = pendingRegion, bounds.width > 0, bounds.height > 0 {
|
|
1429
1460
|
pendingRegion = nil
|
|
1430
1461
|
setRegion(region, animated: false)
|
|
@@ -1724,6 +1755,25 @@ private var craftNativeCornerRadiiKey: UInt8 = 0
|
|
|
1724
1755
|
private var craftNativeBorderLayerKey: UInt8 = 0
|
|
1725
1756
|
private var craftNativeSideBorderLayersKey: UInt8 = 0
|
|
1726
1757
|
|
|
1758
|
+
private let craftAllCorners: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner]
|
|
1759
|
+
|
|
1760
|
+
/// One radius for every rounded corner of `radii` (top-left, top-right,
|
|
1761
|
+
/// bottom-right, bottom-left), and which corners those are; nil when the
|
|
1762
|
+
/// rounded corners differ.
|
|
1763
|
+
func craftSharedCornerRadius(_ radii: [CGFloat]) -> (radius: CGFloat, corners: CACornerMask)? {
|
|
1764
|
+
let order: [CACornerMask] = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner]
|
|
1765
|
+
var radius: CGFloat?
|
|
1766
|
+
var corners: CACornerMask = []
|
|
1767
|
+
for (index, corner) in order.enumerated() {
|
|
1768
|
+
let value = max(0, radii.count > index ? radii[index] : 0)
|
|
1769
|
+
guard value > 0 else { continue }
|
|
1770
|
+
if let radius, abs(radius - value) > 0.01 { return nil }
|
|
1771
|
+
radius = value
|
|
1772
|
+
corners.insert(corner)
|
|
1773
|
+
}
|
|
1774
|
+
return radius.map { ($0, corners) } ?? (0, craftAllCorners)
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1727
1777
|
private func craftRoundedPath(_ rect: CGRect, _ rawRadii: [CGFloat]) -> CGPath {
|
|
1728
1778
|
let limit = min(rect.width, rect.height) / 2
|
|
1729
1779
|
let radii = (0..<4).map { min(max(0, rawRadii.count > $0 ? rawRadii[$0] : 0), limit) }
|
|
@@ -1774,25 +1824,62 @@ extension UIView {
|
|
|
1774
1824
|
let requested = craftRequestedCornerRadius ?? 0
|
|
1775
1825
|
let limit = min(bounds.width, bounds.height) / 2
|
|
1776
1826
|
let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
|
|
1777
|
-
if let radii = craftRequestedCornerRadii {
|
|
1827
|
+
if let radii = craftRequestedCornerRadii, let shared = craftSharedCornerRadius(radii) {
|
|
1828
|
+
// The rounded corners share one radius (`rounded-t-md`): the
|
|
1829
|
+
// layer rounds just those corners itself. A mask layer is an
|
|
1830
|
+
// offscreen render pass for every frame the view is on screen,
|
|
1831
|
+
// which Apple names first among render-phase hitches. Clipped
|
|
1832
|
+
// like the mask was.
|
|
1833
|
+
let clamped = limit > 0 ? min(shared.radius, limit) : min(shared.radius, 9_998)
|
|
1834
|
+
layer.mask = nil
|
|
1835
|
+
if layer.cornerRadius != clamped { layer.cornerRadius = clamped }
|
|
1836
|
+
if layer.maskedCorners != shared.corners { layer.maskedCorners = shared.corners }
|
|
1837
|
+
if !layer.masksToBounds { layer.masksToBounds = true }
|
|
1838
|
+
} else if let radii = craftRequestedCornerRadii {
|
|
1839
|
+
// Corners of different radii: only a path can draw them.
|
|
1778
1840
|
let mask = (layer.mask as? CAShapeLayer) ?? CAShapeLayer()
|
|
1779
|
-
mask.frame = bounds
|
|
1841
|
+
if mask.frame != bounds { mask.frame = bounds }
|
|
1780
1842
|
mask.path = craftRoundedPath(bounds, radii)
|
|
1781
1843
|
layer.mask = mask
|
|
1782
1844
|
layer.cornerRadius = 0
|
|
1783
1845
|
} else {
|
|
1784
|
-
layer.mask = nil
|
|
1846
|
+
if layer.mask != nil { layer.mask = nil }
|
|
1847
|
+
if layer.maskedCorners != craftAllCorners { layer.maskedCorners = craftAllCorners }
|
|
1785
1848
|
if layer.cornerRadius != radius { layer.cornerRadius = radius }
|
|
1786
1849
|
}
|
|
1787
|
-
|
|
1788
|
-
|
|
1850
|
+
// A box shadow, as CSS draws it: the shape of the rounded box. Given
|
|
1851
|
+
// no path, Core Animation renders the layer offscreen every frame to
|
|
1852
|
+
// find the shadow's shape from its pixels.
|
|
1853
|
+
if layer.shadowOpacity > 0, bounds.width > 0, bounds.height > 0 {
|
|
1854
|
+
let cornerRadius = layer.mask == nil ? layer.cornerRadius : 0
|
|
1855
|
+
let shape: CGPath = layer.mask == nil
|
|
1856
|
+
? (layer.maskedCorners == craftAllCorners || cornerRadius == 0
|
|
1857
|
+
? CGPath(roundedRect: bounds, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
|
|
1858
|
+
: craftRoundedPath(bounds, [CACornerMask.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner].map { layer.maskedCorners.contains($0) ? cornerRadius : 0 }))
|
|
1859
|
+
: craftRoundedPath(bounds, craftRequestedCornerRadii ?? [])
|
|
1860
|
+
if layer.shadowPath != shape { layer.shadowPath = shape }
|
|
1861
|
+
} else if layer.shadowPath != nil {
|
|
1862
|
+
layer.shadowPath = nil
|
|
1863
|
+
}
|
|
1864
|
+
let border = objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer
|
|
1865
|
+
let sides = (objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer]).flatMap { $0.count == 4 ? $0 : nil }
|
|
1866
|
+
guard border != nil || sides != nil else { return }
|
|
1867
|
+
// Border layers are the view's own sublayers, so a new frame or path
|
|
1868
|
+
// would animate implicitly; and a path set again, even an equal one,
|
|
1869
|
+
// draws the shape again. Layout runs for every child on every pass.
|
|
1870
|
+
CATransaction.begin()
|
|
1871
|
+
CATransaction.setDisableActions(true)
|
|
1872
|
+
defer { CATransaction.commit() }
|
|
1873
|
+
if let border {
|
|
1874
|
+
if border.frame != bounds { border.frame = bounds }
|
|
1789
1875
|
let inset = border.lineWidth / 2
|
|
1790
|
-
|
|
1876
|
+
let path = UIBezierPath(
|
|
1791
1877
|
roundedRect: bounds.insetBy(dx: inset, dy: inset),
|
|
1792
1878
|
cornerRadius: max(0, radius - inset)
|
|
1793
1879
|
).cgPath
|
|
1880
|
+
if border.path != path { border.path = path }
|
|
1794
1881
|
}
|
|
1795
|
-
if let sides
|
|
1882
|
+
if let sides {
|
|
1796
1883
|
let top = sides[0].frame.height
|
|
1797
1884
|
let right = sides[1].frame.width
|
|
1798
1885
|
let bottom = sides[2].frame.height
|
|
@@ -1804,9 +1891,10 @@ extension UIView {
|
|
|
1804
1891
|
CGRect(x: 0, y: 0, width: left, height: bounds.height),
|
|
1805
1892
|
]
|
|
1806
1893
|
for (index, side) in sides.enumerated() {
|
|
1807
|
-
side.frame = frames[index]
|
|
1894
|
+
if side.frame != frames[index] { side.frame = frames[index] }
|
|
1808
1895
|
if let shape = side as? CAShapeLayer {
|
|
1809
|
-
|
|
1896
|
+
let path = craftSideBorderPath(shape.bounds, index: index)
|
|
1897
|
+
if shape.path != path { shape.path = path }
|
|
1810
1898
|
}
|
|
1811
1899
|
}
|
|
1812
1900
|
}
|
|
@@ -2681,11 +2769,24 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2681
2769
|
var fixedHeight = style.top != nil && style.bottom != nil
|
|
2682
2770
|
? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
|
|
2683
2771
|
: style.height?.resolve(content.height)
|
|
2772
|
+
let authoredWidth = fixedWidth != nil
|
|
2773
|
+
let authoredHeight = fixedHeight != nil
|
|
2684
2774
|
(fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
let
|
|
2688
|
-
|
|
2775
|
+
var measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
|
|
2776
|
+
var boundedWidth = clamp(measured.width, style.minWidth?.resolve(content.width), style.maxWidth?.resolve(content.width))
|
|
2777
|
+
let proposedHeight = !authoredHeight && style.aspectRatio != nil ? boundedWidth / style.aspectRatio! : fixedHeight
|
|
2778
|
+
if abs(boundedWidth - measured.width) > 0.0001 || proposedHeight != fixedHeight {
|
|
2779
|
+
// A narrower label or container must wrap before its
|
|
2780
|
+
// height and bottom anchor are resolved.
|
|
2781
|
+
measured = CraftNativeFlexLayout.measure(child, width: boundedWidth, height: proposedHeight, maxWidth: content.width, maxHeight: content.height)
|
|
2782
|
+
}
|
|
2783
|
+
let boundedHeight = clamp(measured.height, style.minHeight?.resolve(content.height), style.maxHeight?.resolve(content.height))
|
|
2784
|
+
if !authoredWidth, authoredHeight, let ratio = style.aspectRatio {
|
|
2785
|
+
boundedWidth = clamp(boundedHeight * ratio, style.minWidth?.resolve(content.width), style.maxWidth?.resolve(content.width))
|
|
2786
|
+
}
|
|
2787
|
+
let x = style.left ?? (style.right.map { content.width - $0 - boundedWidth } ?? 0)
|
|
2788
|
+
let y = style.top ?? (style.bottom.map { content.height - $0 - boundedHeight } ?? 0)
|
|
2789
|
+
frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: boundedWidth, height: boundedHeight)))
|
|
2689
2790
|
}
|
|
2690
2791
|
}
|
|
2691
2792
|
return (size, frames)
|
|
@@ -3370,6 +3471,8 @@ private final class CraftNativeModalView: UIView {
|
|
|
3370
3471
|
let generation = animationGeneration
|
|
3371
3472
|
layer.removeAllAnimations()
|
|
3372
3473
|
guard visible else {
|
|
3474
|
+
craftScreenController?.forgetFocusedInput(in: self)
|
|
3475
|
+
endEditing(true)
|
|
3373
3476
|
if animationType == "slide" && wasVisible {
|
|
3374
3477
|
let distance = max(bounds.height, superview?.bounds.height ?? UIScreen.main.bounds.height)
|
|
3375
3478
|
UIView.animate(withDuration: 0.24, animations: {
|
|
@@ -3409,6 +3512,8 @@ private final class CraftNativeModalView: UIView {
|
|
|
3409
3512
|
}
|
|
3410
3513
|
|
|
3411
3514
|
func resetForTeardown() {
|
|
3515
|
+
craftScreenController?.forgetFocusedInput(in: self)
|
|
3516
|
+
endEditing(true)
|
|
3412
3517
|
animationGeneration += 1
|
|
3413
3518
|
layer.removeAllAnimations()
|
|
3414
3519
|
isHidden = true
|
|
@@ -4244,6 +4349,52 @@ private final class CraftNativeTextView: UITextView {
|
|
|
4244
4349
|
}
|
|
4245
4350
|
}
|
|
4246
4351
|
|
|
4352
|
+
/// The native screens' JavaScript. A screen is a JavaScriptCore context of
|
|
4353
|
+
/// its own and evaluates the whole file it is given, so each route has a file
|
|
4354
|
+
/// of its own when stx wrote one (`dist/native-screens/<route>.js`, stx's
|
|
4355
|
+
/// `routesDir`): a screen then parses its own code rather than every
|
|
4356
|
+
/// screen's. The one-file `dist/native-screen.js` remains the fallback.
|
|
4357
|
+
enum CraftNativeBundle {
|
|
4358
|
+
/// Every screen's context lives in this one virtual machine. They all run
|
|
4359
|
+
/// on the main thread, the machine's only one, and sharing it shares its
|
|
4360
|
+
/// code cache: a screen opened again (a second workout, a tab rebuilt)
|
|
4361
|
+
/// is not parsed again. Each screen keeps a global object of its own.
|
|
4362
|
+
static let virtualMachine = JSVirtualMachine()!
|
|
4363
|
+
|
|
4364
|
+
private static let sources: NSCache<NSString, NSString> = {
|
|
4365
|
+
let cache = NSCache<NSString, NSString>()
|
|
4366
|
+
// Room for the one-file bundle, or a dozen route files.
|
|
4367
|
+
cache.totalCostLimit = 24 << 20
|
|
4368
|
+
return cache
|
|
4369
|
+
}()
|
|
4370
|
+
|
|
4371
|
+
/// The file a route runs from: its own, else the one-file bundle.
|
|
4372
|
+
static func url(for route: String?) -> URL? {
|
|
4373
|
+
if let route, !route.isEmpty, !route.contains("/"), !route.hasPrefix("."),
|
|
4374
|
+
let own = Bundle.main.url(forResource: route, withExtension: "js", subdirectory: "dist/native-screens") {
|
|
4375
|
+
return own
|
|
4376
|
+
}
|
|
4377
|
+
return Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
|
|
4378
|
+
?? Bundle.main.url(forResource: "native-screen", withExtension: "js")
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
/// Whether the app has native screens to run at all.
|
|
4382
|
+
static var isAvailable: Bool {
|
|
4383
|
+
url(for: nil) != nil || Bundle.main.url(forResource: "native-screens", withExtension: nil, subdirectory: "dist") != nil
|
|
4384
|
+
}
|
|
4385
|
+
|
|
4386
|
+
/// The route's source, read from disk once and kept while memory allows:
|
|
4387
|
+
/// a push read and decoded the file on the main thread every time.
|
|
4388
|
+
static func source(for route: String?) -> (source: String, url: URL)? {
|
|
4389
|
+
guard let url = url(for: route) else { return nil }
|
|
4390
|
+
let key = url.path as NSString
|
|
4391
|
+
if let cached = sources.object(forKey: key) { return (cached as String, url) }
|
|
4392
|
+
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
|
|
4393
|
+
sources.setObject(text as NSString, forKey: key, cost: text.utf8.count)
|
|
4394
|
+
return (text, url)
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
|
|
4247
4398
|
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
|
|
4248
4399
|
private final class RenderedNode {
|
|
4249
4400
|
let identity: String
|
|
@@ -4265,7 +4416,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4265
4416
|
private let config: CraftConfig
|
|
4266
4417
|
private let routeName: String?
|
|
4267
4418
|
private(set) var routeParams: [String: Any]
|
|
4268
|
-
private let jsContext = JSContext()!
|
|
4419
|
+
private let jsContext = JSContext(virtualMachine: CraftNativeBundle.virtualMachine)!
|
|
4269
4420
|
private let rootStack = CraftNativeFlowView()
|
|
4270
4421
|
private var handlers: [ObjectIdentifier: String] = [:]
|
|
4271
4422
|
private var pressInHandlers: [ObjectIdentifier: String] = [:]
|
|
@@ -4326,6 +4477,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4326
4477
|
func forgetFocusedInput(_ input: UIView? = nil) {
|
|
4327
4478
|
if input == nil || lastFocusedInput === input { lastFocusedInput = nil }
|
|
4328
4479
|
}
|
|
4480
|
+
func forgetFocusedInput(in container: UIView) {
|
|
4481
|
+
if let input = lastFocusedInput, input === container || input.isDescendant(of: container) {
|
|
4482
|
+
lastFocusedInput = nil
|
|
4483
|
+
}
|
|
4484
|
+
}
|
|
4329
4485
|
private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
|
|
4330
4486
|
private var flatListOwners: [String: String] = [:]
|
|
4331
4487
|
/// A drawing's shapes are not views: a change to one redraws its `Svg`.
|
|
@@ -4408,8 +4564,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4408
4564
|
rootLeadingToSafeArea!,
|
|
4409
4565
|
rootTrailingToSafeArea!
|
|
4410
4566
|
])
|
|
4411
|
-
|
|
4412
|
-
|
|
4567
|
+
let pacing = CraftFramePacing.shared
|
|
4568
|
+
pacing.measure("mount \(routeName ?? "screen") setup") { setupJavaScript() }
|
|
4569
|
+
pacing.measure("mount \(routeName ?? "screen") bundle") { loadBundle() }
|
|
4413
4570
|
observeNativeEvents()
|
|
4414
4571
|
if isAppScript {
|
|
4415
4572
|
// Its bundle has run, so its listeners are in place: the app's
|
|
@@ -4448,6 +4605,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4448
4605
|
|
|
4449
4606
|
override func viewWillDisappear(_ animated: Bool) {
|
|
4450
4607
|
super.viewWillDisappear(animated)
|
|
4608
|
+
screenVisibilityChanged(false)
|
|
4451
4609
|
stopCelebrations(in: view)
|
|
4452
4610
|
// The accessory belongs to the visible native screen. Clear the
|
|
4453
4611
|
// shared shell state before a hybrid/web route appears, otherwise a
|
|
@@ -4510,6 +4668,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4510
4668
|
if visible { restoreShellChrome() }
|
|
4511
4669
|
guard visible != reportedVisible else { return }
|
|
4512
4670
|
reportedVisible = visible
|
|
4671
|
+
if visible { DeepLinkManager.shared.replayNativeLinks() }
|
|
4513
4672
|
if visible, !hasAppeared {
|
|
4514
4673
|
hasAppeared = true
|
|
4515
4674
|
return
|
|
@@ -5080,13 +5239,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5080
5239
|
}
|
|
5081
5240
|
|
|
5082
5241
|
private func loadBundle() {
|
|
5083
|
-
guard let
|
|
5084
|
-
?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
|
|
5085
|
-
let script = try? String(contentsOf: url, encoding: .utf8) else {
|
|
5242
|
+
guard let bundle = CraftNativeBundle.source(for: routeName) else {
|
|
5086
5243
|
showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
|
|
5087
5244
|
return
|
|
5088
5245
|
}
|
|
5089
|
-
jsContext.evaluateScript(
|
|
5246
|
+
jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
|
|
5090
5247
|
// A bundle that names no route leaves `__stxNativeRoute` undefined;
|
|
5091
5248
|
// its title is the app's, not the word "undefined". A title the
|
|
5092
5249
|
// script set with `navigation.setOptions` wins over both.
|
|
@@ -5432,7 +5589,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5432
5589
|
private func loadBarImage(_ url: URL, _ done: @escaping (UIImage?) -> Void) {
|
|
5433
5590
|
if let cached = Self.barImages.object(forKey: url as NSURL) { done(cached); return }
|
|
5434
5591
|
URLSession.shared.dataTask(with: url) { data, _, _ in
|
|
5435
|
-
let image = data.flatMap(UIImage.init(data:))
|
|
5592
|
+
let image = data.flatMap(UIImage.init(data:)).map { $0.preparingForDisplay() ?? $0 }
|
|
5436
5593
|
if let image { Self.barImages.setObject(image, forKey: url as NSURL) }
|
|
5437
5594
|
DispatchQueue.main.async { done(image) }
|
|
5438
5595
|
}.resume()
|
|
@@ -5566,8 +5723,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5566
5723
|
sendAppState(CraftNativeActions.currentAppState())
|
|
5567
5724
|
// Links are the screens' to show; the app script hears none.
|
|
5568
5725
|
guard !isAppScript else { return }
|
|
5569
|
-
deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self]
|
|
5570
|
-
guard let self = self
|
|
5726
|
+
deepLinkListener = DeepLinkManager.shared.addNativeListener(canReceive: { [weak self] in
|
|
5727
|
+
guard let self = self else { return false }
|
|
5728
|
+
return self.reportedVisible && !self.runtimeDisposed && self.navigationController?.topViewController === self
|
|
5729
|
+
}) { [weak self] url, initial in
|
|
5730
|
+
guard let self = self else { return }
|
|
5571
5731
|
var payload = CraftNativeActions.deepLinkData(url)
|
|
5572
5732
|
payload["initial"] = initial
|
|
5573
5733
|
self.send(type: "DEEP_LINK", payload: payload)
|
|
@@ -6054,6 +6214,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6054
6214
|
private func restoreFocus(_ focused: UIView?) {
|
|
6055
6215
|
guard let focused, focused.window != nil, !focused.isFirstResponder,
|
|
6056
6216
|
lastFocusedInput === focused, firstResponder(in: rootStack) == nil else { return }
|
|
6217
|
+
var ancestor: UIView? = focused
|
|
6218
|
+
while let view = ancestor {
|
|
6219
|
+
if view.isHidden { return }
|
|
6220
|
+
ancestor = view.superview
|
|
6221
|
+
}
|
|
6057
6222
|
// A delayed acknowledgement belongs to the input that scheduled it.
|
|
6058
6223
|
// A newer field or an explicit dismissal owns focus now.
|
|
6059
6224
|
_ = focused.becomeFirstResponder()
|
|
@@ -7367,6 +7532,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7367
7532
|
|| (view is UIScrollView && overflow != "visible")
|
|
7368
7533
|
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
7369
7534
|
|| (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
|
|
7535
|
+
// Per-corner rounding clips its content, as its mask did.
|
|
7536
|
+
|| hasCornerRadii
|
|
7370
7537
|
view.layer.masksToBounds = view.clipsToBounds
|
|
7371
7538
|
view.craftLayoutStyle = CraftNativeLayoutStyle(style)
|
|
7372
7539
|
(view as? CraftNativeFlowView)?.invalidateMeasurements()
|
|
@@ -7629,6 +7796,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7629
7796
|
}
|
|
7630
7797
|
}
|
|
7631
7798
|
|
|
7799
|
+
/// Downloaded images, decoded, for every screen: a screen opened again or
|
|
7800
|
+
/// a row scrolled back draws its picture in its first frame rather than
|
|
7801
|
+
/// fetching and decoding it again. Bounded by decoded bytes.
|
|
7802
|
+
private static let decodedImages: NSCache<NSString, UIImage> = {
|
|
7803
|
+
let cache = NSCache<NSString, UIImage>()
|
|
7804
|
+
cache.totalCostLimit = 48 << 20
|
|
7805
|
+
return cache
|
|
7806
|
+
}()
|
|
7807
|
+
|
|
7632
7808
|
/// An image the screen downloads: https anywhere, and http only from this
|
|
7633
7809
|
/// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
|
|
7634
7810
|
/// server the app's ATS exception already allows. Internal for XCTest.
|
|
@@ -7680,14 +7856,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7680
7856
|
return
|
|
7681
7857
|
}
|
|
7682
7858
|
if let url = URL(string: uri), Self.loadsImage(from: url) {
|
|
7859
|
+
// Shown before: on screen in this same frame, already decoded.
|
|
7860
|
+
if let decoded = Self.decodedImages.object(forKey: requestKey as NSString) {
|
|
7861
|
+
view.image = imageForDisplay(decoded, view: view)
|
|
7862
|
+
imageDidChange(view)
|
|
7863
|
+
emitImageEvent(view, handler: imageLoadHandlers[id])
|
|
7864
|
+
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
7865
|
+
return
|
|
7866
|
+
}
|
|
7683
7867
|
var request = URLRequest(url: url)
|
|
7684
7868
|
for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
|
|
7685
7869
|
let task = URLSession.shared.dataTask(with: request) { [weak self, weak view] data, _, _ in
|
|
7870
|
+
// Decoded here, off the main thread. A UIImage made from data
|
|
7871
|
+
// is only decoded when it first draws, on the main thread, in
|
|
7872
|
+
// the frame it scrolls into view: Apple's "prepare" hitch.
|
|
7873
|
+
let decoded = data.flatMap(UIImage.init(data:)).map { $0.preparingForDisplay() ?? $0 }
|
|
7874
|
+
if let decoded {
|
|
7875
|
+
let pixels = decoded.size.width * decoded.scale * decoded.size.height * decoded.scale
|
|
7876
|
+
Self.decodedImages.setObject(decoded, forKey: requestKey as NSString, cost: Int(pixels * 4))
|
|
7877
|
+
}
|
|
7686
7878
|
guard let self, let view else { return }
|
|
7687
7879
|
DispatchQueue.main.async {
|
|
7688
7880
|
guard self.imageSources[ObjectIdentifier(view)] == requestKey else { return }
|
|
7689
7881
|
self.imageTasks.removeValue(forKey: ObjectIdentifier(view))
|
|
7690
|
-
guard let
|
|
7882
|
+
guard let image = decoded else {
|
|
7691
7883
|
self.imageFailure(view, uri: uri, message: "Image download failed")
|
|
7692
7884
|
return
|
|
7693
7885
|
}
|
|
@@ -8201,12 +8393,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8201
8393
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
8202
8394
|
}
|
|
8203
8395
|
|
|
8396
|
+
private var pacingName: String { "scroll \(routeName ?? "screen")" }
|
|
8397
|
+
|
|
8204
8398
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
8399
|
+
CraftFramePacing.shared.begin(pacingName)
|
|
8205
8400
|
guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
8206
8401
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
8207
8402
|
}
|
|
8208
8403
|
|
|
8209
8404
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
8405
|
+
if !decelerate { CraftFramePacing.shared.end(pacingName) }
|
|
8210
8406
|
guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
8211
8407
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
8212
8408
|
}
|
|
@@ -8217,6 +8413,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8217
8413
|
}
|
|
8218
8414
|
|
|
8219
8415
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
8416
|
+
CraftFramePacing.shared.end(pacingName)
|
|
8220
8417
|
guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
8221
8418
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
8222
8419
|
}
|
|
@@ -21,6 +21,7 @@ import Darwin
|
|
|
21
21
|
/// tap <text> first hittable element whose label contains text
|
|
22
22
|
/// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
|
|
23
23
|
/// tapid <identifier>
|
|
24
|
+
/// longpressid <identifier> holds a hittable native element to open its edit menu
|
|
24
25
|
/// touches <JSON> timed normalized touch paths in one native dispatch
|
|
25
26
|
/// tapxy <x> <y> normalised 0…1 of the screen
|
|
26
27
|
/// tapexact <label> a button whose label is exactly this
|
|
@@ -29,11 +30,14 @@ import Darwin
|
|
|
29
30
|
/// return the keyboard's return key (Go on a sign-in form)
|
|
30
31
|
/// field <n> <text> tap the n-th text field (0-based, secure fields after plain) and type
|
|
31
32
|
/// typeid <identifier> <text> tap the element with this identifier and type
|
|
33
|
+
/// sliderid <identifier> <0…1> adjusts a hittable native slider
|
|
34
|
+
/// expectapp <bundle-id> [| secs] asserts a native app handoff reached the foreground
|
|
32
35
|
/// expect <text> [| secs] waits for an element containing text; logs PASS/FAIL
|
|
33
36
|
/// expectexact <label> [| secs] a button or text whose label is exactly this
|
|
34
37
|
/// absent <text> logs PASS when no element contains text
|
|
35
38
|
/// expectid <id> [| secs] / absentid <id> the same by accessibility identifier (testID)
|
|
36
39
|
/// labelid <id> <text> that element's label is exactly text (a count, a status)
|
|
40
|
+
/// valueid <id> <text> that element's actual string value is exactly text
|
|
37
41
|
/// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
|
|
38
42
|
/// selected <tab> the tab bar's selected button is this one
|
|
39
43
|
/// tab <name> tap a tab bar button
|
|
@@ -297,6 +301,14 @@ final class DriverTests: XCTestCase {
|
|
|
297
301
|
let target = identified(arg)
|
|
298
302
|
if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
|
|
299
303
|
else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
|
|
304
|
+
case "longpressid":
|
|
305
|
+
let target = identified(arg)
|
|
306
|
+
guard target.waitForExistence(timeout: 10), target.isHittable else {
|
|
307
|
+
check(false, "longpressid \(arg) is hittable")
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
target.press(forDuration: 1.2)
|
|
311
|
+
note("longpressid \(arg): ok")
|
|
300
312
|
case "touches":
|
|
301
313
|
do {
|
|
302
314
|
guard let data = arg.data(using: .utf8),
|
|
@@ -367,6 +379,23 @@ final class DriverTests: XCTestCase {
|
|
|
367
379
|
app.typeText(idParts.count > 1 ? idParts[1] : "")
|
|
368
380
|
note("typeid \(idParts[0]): ok")
|
|
369
381
|
} else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
|
|
382
|
+
case "sliderid":
|
|
383
|
+
let parts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
384
|
+
guard parts.count == 2, let position = Double(parts[1]), position.isFinite, (0...1).contains(position) else {
|
|
385
|
+
check(false, "sliderid requires an identifier and normalized position")
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
let slider = app.sliders.matching(NSPredicate(format: "identifier == %@", parts[0])).firstMatch
|
|
389
|
+
guard slider.waitForExistence(timeout: 10), slider.isHittable else {
|
|
390
|
+
check(false, "sliderid \(parts[0]) is hittable")
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
slider.adjust(toNormalizedSliderPosition: CGFloat(position))
|
|
394
|
+
note("sliderid \(parts[0]): \(position)")
|
|
395
|
+
case "expectapp":
|
|
396
|
+
let (bundle, seconds) = split(arg)
|
|
397
|
+
let target = XCUIApplication(bundleIdentifier: bundle)
|
|
398
|
+
check(target.wait(for: .runningForeground, timeout: seconds), "expectapp \(bundle)")
|
|
370
399
|
case "field":
|
|
371
400
|
let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
372
401
|
let index = Int(fieldParts[0]) ?? 0
|
|
@@ -392,6 +421,14 @@ final class DriverTests: XCTestCase {
|
|
|
392
421
|
let want = idParts.count > 1 ? idParts[1] : ""
|
|
393
422
|
let seen = target.waitForExistence(timeout: 5) ? target.label : "(missing)"
|
|
394
423
|
check(seen == want, "labelid \(idParts[0]) \(want) (saw \(seen))")
|
|
424
|
+
case "valueid":
|
|
425
|
+
let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
426
|
+
let target = identified(idParts[0])
|
|
427
|
+
let want = idParts.count > 1 ? idParts[1] : ""
|
|
428
|
+
let matched = poll(5) {
|
|
429
|
+
target.exists && (target.value as? String) == want ? target : nil
|
|
430
|
+
}
|
|
431
|
+
check(matched != nil, "valueid \(idParts[0]) \(want)")
|
|
395
432
|
case "absentid":
|
|
396
433
|
check(!identified(arg).exists, "absentid \(arg)")
|
|
397
434
|
case "absent":
|