@craft-native/ios 0.0.241 → 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.
@@ -1755,6 +1755,25 @@ private var craftNativeCornerRadiiKey: UInt8 = 0
1755
1755
  private var craftNativeBorderLayerKey: UInt8 = 0
1756
1756
  private var craftNativeSideBorderLayersKey: UInt8 = 0
1757
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
+
1758
1777
  private func craftRoundedPath(_ rect: CGRect, _ rawRadii: [CGFloat]) -> CGPath {
1759
1778
  let limit = min(rect.width, rect.height) / 2
1760
1779
  let radii = (0..<4).map { min(max(0, rawRadii.count > $0 ? rawRadii[$0] : 0), limit) }
@@ -1805,25 +1824,62 @@ extension UIView {
1805
1824
  let requested = craftRequestedCornerRadius ?? 0
1806
1825
  let limit = min(bounds.width, bounds.height) / 2
1807
1826
  let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
1808
- 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.
1809
1840
  let mask = (layer.mask as? CAShapeLayer) ?? CAShapeLayer()
1810
- mask.frame = bounds
1841
+ if mask.frame != bounds { mask.frame = bounds }
1811
1842
  mask.path = craftRoundedPath(bounds, radii)
1812
1843
  layer.mask = mask
1813
1844
  layer.cornerRadius = 0
1814
1845
  } else {
1815
- layer.mask = nil
1846
+ if layer.mask != nil { layer.mask = nil }
1847
+ if layer.maskedCorners != craftAllCorners { layer.maskedCorners = craftAllCorners }
1816
1848
  if layer.cornerRadius != radius { layer.cornerRadius = radius }
1817
1849
  }
1818
- if let border = objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer {
1819
- 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 }
1820
1875
  let inset = border.lineWidth / 2
1821
- border.path = UIBezierPath(
1876
+ let path = UIBezierPath(
1822
1877
  roundedRect: bounds.insetBy(dx: inset, dy: inset),
1823
1878
  cornerRadius: max(0, radius - inset)
1824
1879
  ).cgPath
1880
+ if border.path != path { border.path = path }
1825
1881
  }
1826
- if let sides = objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer], sides.count == 4 {
1882
+ if let sides {
1827
1883
  let top = sides[0].frame.height
1828
1884
  let right = sides[1].frame.width
1829
1885
  let bottom = sides[2].frame.height
@@ -1835,9 +1891,10 @@ extension UIView {
1835
1891
  CGRect(x: 0, y: 0, width: left, height: bounds.height),
1836
1892
  ]
1837
1893
  for (index, side) in sides.enumerated() {
1838
- side.frame = frames[index]
1894
+ if side.frame != frames[index] { side.frame = frames[index] }
1839
1895
  if let shape = side as? CAShapeLayer {
1840
- shape.path = craftSideBorderPath(shape.bounds, index: index)
1896
+ let path = craftSideBorderPath(shape.bounds, index: index)
1897
+ if shape.path != path { shape.path = path }
1841
1898
  }
1842
1899
  }
1843
1900
  }
@@ -4292,6 +4349,52 @@ private final class CraftNativeTextView: UITextView {
4292
4349
  }
4293
4350
  }
4294
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
+
4295
4398
  final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
4296
4399
  private final class RenderedNode {
4297
4400
  let identity: String
@@ -4313,7 +4416,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4313
4416
  private let config: CraftConfig
4314
4417
  private let routeName: String?
4315
4418
  private(set) var routeParams: [String: Any]
4316
- private let jsContext = JSContext()!
4419
+ private let jsContext = JSContext(virtualMachine: CraftNativeBundle.virtualMachine)!
4317
4420
  private let rootStack = CraftNativeFlowView()
4318
4421
  private var handlers: [ObjectIdentifier: String] = [:]
4319
4422
  private var pressInHandlers: [ObjectIdentifier: String] = [:]
@@ -4461,8 +4564,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4461
4564
  rootLeadingToSafeArea!,
4462
4565
  rootTrailingToSafeArea!
4463
4566
  ])
4464
- setupJavaScript()
4465
- loadBundle()
4567
+ let pacing = CraftFramePacing.shared
4568
+ pacing.measure("mount \(routeName ?? "screen") setup") { setupJavaScript() }
4569
+ pacing.measure("mount \(routeName ?? "screen") bundle") { loadBundle() }
4466
4570
  observeNativeEvents()
4467
4571
  if isAppScript {
4468
4572
  // Its bundle has run, so its listeners are in place: the app's
@@ -4501,6 +4605,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4501
4605
 
4502
4606
  override func viewWillDisappear(_ animated: Bool) {
4503
4607
  super.viewWillDisappear(animated)
4608
+ screenVisibilityChanged(false)
4504
4609
  stopCelebrations(in: view)
4505
4610
  // The accessory belongs to the visible native screen. Clear the
4506
4611
  // shared shell state before a hybrid/web route appears, otherwise a
@@ -4563,6 +4668,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4563
4668
  if visible { restoreShellChrome() }
4564
4669
  guard visible != reportedVisible else { return }
4565
4670
  reportedVisible = visible
4671
+ if visible { DeepLinkManager.shared.replayNativeLinks() }
4566
4672
  if visible, !hasAppeared {
4567
4673
  hasAppeared = true
4568
4674
  return
@@ -5133,13 +5239,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5133
5239
  }
5134
5240
 
5135
5241
  private func loadBundle() {
5136
- guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
5137
- ?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
5138
- let script = try? String(contentsOf: url, encoding: .utf8) else {
5242
+ guard let bundle = CraftNativeBundle.source(for: routeName) else {
5139
5243
  showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
5140
5244
  return
5141
5245
  }
5142
- jsContext.evaluateScript(script)
5246
+ jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
5143
5247
  // A bundle that names no route leaves `__stxNativeRoute` undefined;
5144
5248
  // its title is the app's, not the word "undefined". A title the
5145
5249
  // script set with `navigation.setOptions` wins over both.
@@ -5485,7 +5589,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5485
5589
  private func loadBarImage(_ url: URL, _ done: @escaping (UIImage?) -> Void) {
5486
5590
  if let cached = Self.barImages.object(forKey: url as NSURL) { done(cached); return }
5487
5591
  URLSession.shared.dataTask(with: url) { data, _, _ in
5488
- let image = data.flatMap(UIImage.init(data:))
5592
+ let image = data.flatMap(UIImage.init(data:)).map { $0.preparingForDisplay() ?? $0 }
5489
5593
  if let image { Self.barImages.setObject(image, forKey: url as NSURL) }
5490
5594
  DispatchQueue.main.async { done(image) }
5491
5595
  }.resume()
@@ -5619,8 +5723,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5619
5723
  sendAppState(CraftNativeActions.currentAppState())
5620
5724
  // Links are the screens' to show; the app script hears none.
5621
5725
  guard !isAppScript else { return }
5622
- deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
5623
- 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 }
5624
5731
  var payload = CraftNativeActions.deepLinkData(url)
5625
5732
  payload["initial"] = initial
5626
5733
  self.send(type: "DEEP_LINK", payload: payload)
@@ -7425,6 +7532,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7425
7532
  || (view is UIScrollView && overflow != "visible")
7426
7533
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
7427
7534
  || (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
7535
+ // Per-corner rounding clips its content, as its mask did.
7536
+ || hasCornerRadii
7428
7537
  view.layer.masksToBounds = view.clipsToBounds
7429
7538
  view.craftLayoutStyle = CraftNativeLayoutStyle(style)
7430
7539
  (view as? CraftNativeFlowView)?.invalidateMeasurements()
@@ -7687,6 +7796,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7687
7796
  }
7688
7797
  }
7689
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
+
7690
7808
  /// An image the screen downloads: https anywhere, and http only from this
7691
7809
  /// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
7692
7810
  /// server the app's ATS exception already allows. Internal for XCTest.
@@ -7738,14 +7856,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7738
7856
  return
7739
7857
  }
7740
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
+ }
7741
7867
  var request = URLRequest(url: url)
7742
7868
  for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
7743
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
+ }
7744
7878
  guard let self, let view else { return }
7745
7879
  DispatchQueue.main.async {
7746
7880
  guard self.imageSources[ObjectIdentifier(view)] == requestKey else { return }
7747
7881
  self.imageTasks.removeValue(forKey: ObjectIdentifier(view))
7748
- guard let data, let image = UIImage(data: data) else {
7882
+ guard let image = decoded else {
7749
7883
  self.imageFailure(view, uri: uri, message: "Image download failed")
7750
7884
  return
7751
7885
  }
@@ -8259,12 +8393,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8259
8393
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
8260
8394
  }
8261
8395
 
8396
+ private var pacingName: String { "scroll \(routeName ?? "screen")" }
8397
+
8262
8398
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
8399
+ CraftFramePacing.shared.begin(pacingName)
8263
8400
  guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
8264
8401
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
8265
8402
  }
8266
8403
 
8267
8404
  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
8405
+ if !decelerate { CraftFramePacing.shared.end(pacingName) }
8268
8406
  guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
8269
8407
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
8270
8408
  }
@@ -8275,6 +8413,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8275
8413
  }
8276
8414
 
8277
8415
  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
8416
+ CraftFramePacing.shared.end(pacingName)
8278
8417
  guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
8279
8418
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
8280
8419
  }