@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.
@@ -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 = 6
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 = 6
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 = 6
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
- NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
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 (id, value) in nextPolylines {
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: 24, weight: .semibold)
1438
+ let configuration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
1415
1439
  .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
1416
- view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)
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(.systemRed, renderingMode: .alwaysOriginal)
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 { bringSubviewToFront(tileAttribution) }
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
- if let border = objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer {
1788
- border.frame = bounds
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
- border.path = UIBezierPath(
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 = objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer], sides.count == 4 {
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
- shape.path = craftSideBorderPath(shape.bounds, index: index)
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
- let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
2686
- let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
2687
- let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
2688
- frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height)))
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
- setupJavaScript()
4412
- loadBundle()
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 url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
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(script)
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] url, initial in
5570
- guard let self = self, self.navigationController?.topViewController === self else { return }
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 data, let image = UIImage(data: data) else {
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":