@craft-native/ios 0.0.254 → 0.0.272

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.
@@ -1090,6 +1090,8 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1090
1090
  private var playingObservation: NSKeyValueObservation?
1091
1091
  private var timeObserver: Any?
1092
1092
  private var loops = false
1093
+ private var requestedTime: Double?
1094
+ private var pendingSeek: Double?
1093
1095
  private var wasPlaying: Bool?
1094
1096
  private var playIntent: Bool?
1095
1097
  private var userPaused = false
@@ -1097,7 +1099,8 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1097
1099
  /// This video holds a claim on the audio session (`CraftNativeSounds`).
1098
1100
  private var claimedSession = false
1099
1101
  /// The screen's handlers by event name (`onEnd`, `onPlay`, `onPause`,
1100
- /// `onProgress`, `onLoad`, `onError`), and how an event reaches them.
1102
+ /// `onProgress`, `onLoadStart`, `onLoad`, `onLoadEnd`, `onError`), and how
1103
+ /// an event reaches them.
1101
1104
  var handlers: [String: String] = [:]
1102
1105
  var emit: ((_ handler: String, _ nativeEvent: [String: Any]) -> Void)?
1103
1106
 
@@ -1170,8 +1173,21 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1170
1173
  CraftNativeSounds.releaseSession()
1171
1174
  }
1172
1175
 
1173
- func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?, pictureInPicture: Bool = false) {
1176
+ private func applyPendingSeek() {
1177
+ guard let target = pendingSeek, let player, let item = player.currentItem, item.status == .readyToPlay else { return }
1178
+ let current = seconds(player.currentTime())
1179
+ pendingSeek = nil
1180
+ guard abs(current - target) > 0.25 else { return }
1181
+ player.seek(to: CMTime(seconds: target, preferredTimescale: 600))
1182
+ }
1183
+
1184
+ func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, currentTime: Any?, controls: Bool, resizeMode: String?, pictureInPicture: Bool = false) {
1174
1185
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String ?? ""
1186
+ let nextTime = number(currentTime).map(Double.init).map { max(0, $0) }
1187
+ if nextTime != requestedTime {
1188
+ requestedTime = nextTime
1189
+ pendingSeek = nextTime
1190
+ }
1175
1191
  let nextShouldPlay = autoplay && !paused
1176
1192
  if playIntent != nextShouldPlay {
1177
1193
  userPaused = false
@@ -1184,6 +1200,7 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1184
1200
  userPaused = false
1185
1201
  ended = false
1186
1202
  loops = false
1203
+ pendingSeek = requestedTime
1187
1204
  // The copy `craft.media.keep` put on the phone plays in place of
1188
1205
  // the address, so a kept video plays with no signal.
1189
1206
  let kept = CraftNativeMedia.local(uri)
@@ -1200,11 +1217,13 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1200
1217
  return
1201
1218
  }
1202
1219
  let next = AVPlayer(url: url)
1220
+ fire("onLoadStart")
1203
1221
  player = next
1204
1222
  playerController.player = next
1205
1223
  observe(next)
1206
1224
  }
1207
1225
  guard let player else { return }
1226
+ applyPendingSeek()
1208
1227
  player.isMuted = muted
1209
1228
  // A muted video makes no sound, so it leaves the session as it is.
1210
1229
  if muted { releaseSession() } else { claimSession() }
@@ -1258,7 +1277,10 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1258
1277
  DispatchQueue.main.async {
1259
1278
  guard let self, self.player?.currentItem === item else { return }
1260
1279
  switch item.status {
1261
- case .readyToPlay: self.fire("onLoad", ["duration": self.seconds(item.duration)])
1280
+ case .readyToPlay:
1281
+ self.applyPendingSeek()
1282
+ self.fire("onLoad", ["duration": self.seconds(item.duration)])
1283
+ self.fire("onLoadEnd")
1262
1284
  case .failed: self.fire("onError", ["message": item.error?.localizedDescription ?? "The video could not be played"])
1263
1285
  default: break
1264
1286
  }
@@ -1306,6 +1328,8 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1306
1328
  playerController.player = nil
1307
1329
  playerController.delegate = nil
1308
1330
  sourceURI = ""
1331
+ requestedTime = nil
1332
+ pendingSeek = nil
1309
1333
  playIntent = nil
1310
1334
  userPaused = false
1311
1335
  ended = false
@@ -2022,6 +2046,8 @@ private struct CraftNativeLayoutStyle {
2022
2046
  let bottom: CGFloat?
2023
2047
  let left: CGFloat?
2024
2048
  let alignSelf: String?
2049
+ /// CSS/Yoga order; equal values retain source order.
2050
+ let order: Int
2025
2051
  let flexGrow: CGFloat
2026
2052
  /// Nil when the style does not say: the parent picks the default for the
2027
2053
  /// kind of view (text shrinks along a row, a box does not).
@@ -2059,6 +2085,7 @@ private struct CraftNativeLayoutStyle {
2059
2085
  left = number(raw["left"])
2060
2086
  let align = raw["alignSelf"] as? String
2061
2087
  alignSelf = align == "auto" ? nil : align
2088
+ order = number(raw["order"]).map { Int($0.rounded(.towardZero)) } ?? 0
2062
2089
  // `flex: n` is the web's and React Native's shorthand: a positive
2063
2090
  // number grows by n, shrinks, and starts from nothing, so `flex-1`
2064
2091
  // siblings share a row equally whatever their content.
@@ -2137,6 +2164,13 @@ extension UIView {
2137
2164
  set { objc_setAssociatedObject(self, &craftNativeLayoutStyleKey, CraftNativeLayoutStyleBox(newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
2138
2165
  }
2139
2166
 
2167
+ /// The margins this view's style asks for, for a container that places
2168
+ /// it without a flex parent (a FlatList cell).
2169
+ var craftLayoutMargins: UIEdgeInsets {
2170
+ let style = craftLayoutStyle
2171
+ return UIEdgeInsets(top: max(0, style.marginTop), left: max(0, style.marginLeft), bottom: max(0, style.marginBottom), right: max(0, style.marginRight))
2172
+ }
2173
+
2140
2174
  /// The corner radius the style asked for. The layer gets at most half the
2141
2175
  /// shortest side: Core Animation draws nothing at all for a radius larger
2142
2176
  /// than the view (`rounded-full` is 9999).
@@ -2516,6 +2550,7 @@ final class CraftNativeCelebration {
2516
2550
  group.duration = 2.2 + Double(spread) * 0.45
2517
2551
  group.beginTime = now + Double(index % 7) * 0.018
2518
2552
  group.fillMode = .backwards
2553
+ group.atDisplayRate()
2519
2554
  piece.add(group, forKey: "craft.celebration.particle")
2520
2555
  }
2521
2556
 
@@ -2529,6 +2564,7 @@ final class CraftNativeCelebration {
2529
2564
  entrance.stiffness = 160
2530
2565
  entrance.mass = 1
2531
2566
  entrance.duration = 0.95
2567
+ entrance.atDisplayRate()
2532
2568
  view.layer.add(entrance, forKey: "craft.celebration.entrance")
2533
2569
 
2534
2570
  let sheen = CAGradientLayer()
@@ -2554,6 +2590,7 @@ final class CraftNativeCelebration {
2554
2590
  shine.animations = [sweep, light]
2555
2591
  shine.duration = 1.15
2556
2592
  shine.beginTime = now + 0.55
2593
+ shine.atDisplayRate()
2557
2594
  sheen.add(shine, forKey: "craft.celebration.sweep")
2558
2595
  let cleanup = DispatchWorkItem { [weak self] in self?.stop() }
2559
2596
  self.cleanup = cleanup
@@ -2886,7 +2923,15 @@ private class CraftNativeFlowView: UIStackView {
2886
2923
 
2887
2924
  /// Children that take part in the flow: shown, not a modal overlay.
2888
2925
  private var visibleChildren: [UIView] {
2889
- flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
2926
+ flowChildren.enumerated()
2927
+ .filter { $0.element.superview === self && !$0.element.isHidden && !($0.element is CraftNativeModalView) }
2928
+ .sorted {
2929
+ if $0.element.craftLayoutStyle.order != $1.element.craftLayoutStyle.order {
2930
+ return $0.element.craftLayoutStyle.order < $1.element.craftLayoutStyle.order
2931
+ }
2932
+ return $0.offset < $1.offset
2933
+ }
2934
+ .map(\.element)
2890
2935
  }
2891
2936
 
2892
2937
  /// Builds the eligible portion of this container for the host-neutral
@@ -3088,7 +3133,8 @@ private class CraftNativeFlowView: UIStackView {
3088
3133
  frames: &frames
3089
3134
  )
3090
3135
  }
3091
- if place, flow.count == children.count, let sharedTree = sharedTreeFrames(flow, width: width, height: height), (sharedTree.nodes.count > flow.count + 1 || grid) {
3136
+ if place, flow.count == children.count, let sharedTree = sharedTreeFrames(flow, width: width, height: height),
3137
+ (sharedTree.nodes.count > flow.count + 1 || grid || reverse || wrapReverse || gridAutoRows != nil || gridRows != nil) {
3092
3138
  frames = []
3093
3139
  for index in 1..<sharedTree.nodes.count {
3094
3140
  guard let view = sharedTree.nodes[index].view else { continue }
@@ -3795,6 +3841,17 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3795
3841
  didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
3796
3842
  }
3797
3843
 
3844
+ /// Existing Modal nodes block by default. A non-modal popup keeps the
3845
+ /// screen-level overlay but lets hit testing fall through outside its
3846
+ /// interactive content.
3847
+ var blocksBackground = true {
3848
+ didSet { blocker.isUserInteractionEnabled = blocksBackground }
3849
+ }
3850
+ /// An opt-in non-modal dismissal policy. The default popup remains
3851
+ /// pass-through; when enabled, an outside tap is consumed and routed to
3852
+ /// onRequestClose instead of reaching the view below.
3853
+ var dismissOnOutsideTap = false
3854
+
3798
3855
  override init(frame: CGRect) {
3799
3856
  super.init(frame: frame)
3800
3857
  isUserInteractionEnabled = true
@@ -3876,10 +3933,13 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3876
3933
  transform = .identity
3877
3934
  alpha = 0
3878
3935
  isUserInteractionEnabled = true
3936
+ blocksBackground = true
3937
+ dismissOnOutsideTap = false
3879
3938
  onRequestClose = nil
3880
3939
  }
3881
3940
 
3882
3941
  func requestClose(reason: String = "backdrop") {
3942
+ guard blocksBackground || dismissOnOutsideTap else { return }
3883
3943
  guard let onRequestClose else { return }
3884
3944
  isUserInteractionEnabled = false
3885
3945
  onRequestClose(reason)
@@ -3912,6 +3972,14 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3912
3972
  candidate = view.superview
3913
3973
  }
3914
3974
  }
3975
+ if !blocksBackground {
3976
+ // A dismissible popup consumes its outside hit so the close event
3977
+ // cannot also activate the screen below. Otherwise it owns the
3978
+ // hit only when a blocking modal is underneath it; with no modal
3979
+ // below, the screen remains pass-through.
3980
+ if dismissOnOutsideTap { return self }
3981
+ return craftScreenController?.hasBlockingModalBelow(self) == true ? self : nil
3982
+ }
3915
3983
  return blocker.hitTest(blocker.convert(point, from: self), with: event)
3916
3984
  }
3917
3985
 
@@ -4267,9 +4335,13 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4267
4335
  /// 3 quadratic, 4 close, in viewBox units - and scaled to the view. Paint is
4268
4336
  /// inherited down the tree as SVG does; `dark:` classes arrive already
4269
4337
  /// resolved for the appearance, so a redraw is all a theme change needs.
4270
- private final class CraftNativeSvgView: UIView {
4338
+ final class CraftNativeSvgView: UIView {
4271
4339
  var drawing: [String: Any] = [:] {
4272
- didSet { setNeedsDisplay() }
4340
+ didSet {
4341
+ animates = Self.hasAnimation(drawing["children"] as? [Any] ?? [])
4342
+ setNeedsDisplay()
4343
+ updateFrames()
4344
+ }
4273
4345
  }
4274
4346
 
4275
4347
  override init(frame: CGRect) {
@@ -4277,10 +4349,123 @@ private final class CraftNativeSvgView: UIView {
4277
4349
  isOpaque = false
4278
4350
  backgroundColor = .clear
4279
4351
  contentMode = .redraw
4352
+ NotificationCenter.default.addObserver(self, selector: #selector(reduceMotionChanged), name: UIAccessibility.reduceMotionStatusDidChangeNotification, object: nil)
4280
4353
  }
4281
4354
 
4282
4355
  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
4283
4356
 
4357
+ deinit { CraftNativeFrameClock.shared.cancel(self) }
4358
+
4359
+ // MARK: Animation
4360
+
4361
+ /// Whether a shape carries keyframes (SMIL `<animate attributeName="d">`,
4362
+ /// stx's `Animate`: an exercise's moving figure).
4363
+ private var animates = false
4364
+ /// When this drawing's repetition began: each one starts from its first
4365
+ /// pose when it comes on screen, as a page's SMIL starts when it loads.
4366
+ private var animationStart: CFTimeInterval?
4367
+
4368
+ /// Moving now: on display, and the person has not asked for less motion,
4369
+ /// when each shape keeps its own outline (the still pose).
4370
+ var isAnimating: Bool { animates && window != nil && !isHidden && !UIAccessibility.isReduceMotionEnabled }
4371
+
4372
+ override func didMoveToWindow() {
4373
+ super.didMoveToWindow()
4374
+ updateFrames()
4375
+ }
4376
+
4377
+ override var isHidden: Bool {
4378
+ didSet { if isHidden != oldValue { updateFrames() } }
4379
+ }
4380
+
4381
+ @objc private func reduceMotionChanged() {
4382
+ updateFrames()
4383
+ setNeedsDisplay()
4384
+ }
4385
+
4386
+ /// Frames from the screens' display link (CraftNativeFrameClock, up to
4387
+ /// 120Hz) while moving; none at all while still or out of sight.
4388
+ private func updateFrames() {
4389
+ if isAnimating {
4390
+ if animationStart == nil { animationStart = CACurrentMediaTime() }
4391
+ CraftNativeFrameClock.shared.request(self) { [weak self] in self?.nextFrame() }
4392
+ } else {
4393
+ animationStart = nil
4394
+ CraftNativeFrameClock.shared.cancel(self)
4395
+ }
4396
+ }
4397
+
4398
+ private func nextFrame() {
4399
+ guard isAnimating else { return updateFrames() }
4400
+ // Drawn only while some of it is on screen; a row scrolled away keeps
4401
+ // its time and is right when it comes back.
4402
+ if let window, window.bounds.intersects(convert(bounds, to: window)) { setNeedsDisplay() }
4403
+ CraftNativeFrameClock.shared.request(self) { [weak self] in self?.nextFrame() }
4404
+ }
4405
+
4406
+ private static func hasAnimation(_ children: [Any]) -> Bool {
4407
+ children.contains { child in
4408
+ guard let node = child as? [String: Any] else { return false }
4409
+ if node["type"] as? String == "Animate" { return (node["props"] as? [String: Any])?["frames"] != nil }
4410
+ return hasAnimation(node["children"] as? [Any] ?? [])
4411
+ }
4412
+ }
4413
+
4414
+ /// A shape's outline now: between the keyframes either side of `elapsed`
4415
+ /// seconds into its animation (SMIL's linear `calcMode`), or its own
4416
+ /// outline when it has none, the animation has ended, or motion is reduced.
4417
+ private func outline(of shape: [String: Any]) -> Any? {
4418
+ let geometry = (shape["props"] as? [String: Any])?["geometry"]
4419
+ guard isAnimating, let start = animationStart,
4420
+ let animate = (shape["children"] as? [Any])?.lazy.compactMap({ $0 as? [String: Any] }).first(where: { $0["type"] as? String == "Animate" }),
4421
+ let current = Self.keyframe(animate["props"] as? [String: Any] ?? [:], at: CACurrentMediaTime() - start) else { return geometry }
4422
+ return current
4423
+ }
4424
+
4425
+ /// The outline `elapsed` seconds into an `Animate`'s repetitions; nil
4426
+ /// when it does not animate path data or its repetitions are over.
4427
+ static func keyframe(_ props: [String: Any], at elapsed: CFTimeInterval) -> [Any]? {
4428
+ guard props["attributeName"] as? String == "d",
4429
+ let duration = number(props["duration"]), duration > 0, duration.isFinite,
4430
+ let frames = (props["frames"] as? [Any])?.compactMap({ ($0 as? [Any])?.compactMap(number) }),
4431
+ frames.count >= 2, elapsed >= 0 else { return nil }
4432
+ if let count = number(props["repeatCount"]), elapsed >= Double(count * duration) { return nil }
4433
+ let given = (props["frameTimes"] as? [Any])?.compactMap(number) ?? []
4434
+ let times = given.count == frames.count ? given : frames.indices.map { CGFloat($0) / CGFloat(frames.count - 1) }
4435
+ let phase = CGFloat(elapsed.truncatingRemainder(dividingBy: Double(duration))) / duration
4436
+ var index = 0
4437
+ while index < frames.count - 2, times[index + 1] <= phase { index += 1 }
4438
+ let from = frames[index]
4439
+ let to = frames[index + 1]
4440
+ let span = times[index + 1] - times[index]
4441
+ let share = span > 0 ? min(1, max(0, (phase - times[index]) / span)) : 0
4442
+ return interpolate(from, to, share).map { NSNumber(value: Double($0)) }
4443
+ }
4444
+
4445
+ /// Two outlines with the same commands, a share of the way from one to
4446
+ /// the other; the first as it is when their commands differ.
4447
+ static func interpolate(_ from: [CGFloat], _ to: [CGFloat], _ share: CGFloat) -> [CGFloat] {
4448
+ guard from.count == to.count, share > 0 else { return from }
4449
+ var out = from
4450
+ var i = 0
4451
+ while i < from.count {
4452
+ let command = Int(from[i])
4453
+ guard to[i] == from[i] else { return from }
4454
+ let count: Int
4455
+ switch command {
4456
+ case 0, 1: count = 2
4457
+ case 2: count = 6
4458
+ case 3: count = 4
4459
+ case 4: count = 0
4460
+ default: return from
4461
+ }
4462
+ guard i + count < from.count else { return from }
4463
+ if count > 0 { for k in 1...count { out[i + k] = from[i + k] + (to[i + k] - from[i + k]) * share } }
4464
+ i += count + 1
4465
+ }
4466
+ return out
4467
+ }
4468
+
4284
4469
  var viewBox: CGRect? {
4285
4470
  let props = drawing["props"] as? [String: Any] ?? [:]
4286
4471
  guard let box = props["viewBox"] as? [Any], box.count == 4 else { return nil }
@@ -4400,8 +4585,10 @@ private final class CraftNativeSvgView: UIView {
4400
4585
  draw(children: shape["children"] as? [Any] ?? [], paint: own, in: context)
4401
4586
  case "SvgText":
4402
4587
  drawText(shape, paint: own)
4588
+ case "Animate":
4589
+ continue
4403
4590
  default:
4404
- guard let path = CraftNativeSvgView.path((shape["props"] as? [String: Any])?["geometry"]) else { continue }
4591
+ guard let path = CraftNativeSvgView.path(outline(of: shape)) else { continue }
4405
4592
  context.saveGState()
4406
4593
  path.usesEvenOddFillRule = own.fillRule == "evenodd"
4407
4594
  if let fill = own.resolve(own.fill) {
@@ -4798,9 +4985,24 @@ enum CraftNativeScriptThread {
4798
4985
  static let queue = DispatchQueue(label: "craft.native.script", qos: .userInteractive)
4799
4986
 
4800
4987
  /// `work` where the screens' JavaScript runs: now on the main thread, or
4801
- /// queued in order on the script thread.
4802
- static func run(_ work: @escaping () -> Void) {
4803
- if isBackground { queue.async(execute: work) } else { work() }
4988
+ /// queued in order on the script thread. With frame pacing on
4989
+ /// (`CRAFT_FRAME_PACING=1`), work that waited or ran for 4ms or more is
4990
+ /// logged under `label`, so a slow screen can be told from a queued one.
4991
+ static func run(label: @autoclosure @escaping () -> String = "script", _ work: @escaping () -> Void) {
4992
+ let pacing = CraftFramePacing.shared
4993
+ guard pacing.enabled else {
4994
+ if isBackground { queue.async(execute: work) } else { work() }
4995
+ return
4996
+ }
4997
+ let queued = CACurrentMediaTime()
4998
+ let timed = {
4999
+ let started = CACurrentMediaTime()
5000
+ work()
5001
+ let ran = CACurrentMediaTime() - started
5002
+ let waited = started - queued
5003
+ if waited + ran >= 0.004 { pacing.scriptWork(label(), waited: waited, ran: ran) }
5004
+ }
5005
+ if isBackground { queue.async(execute: timed) } else { timed() }
4804
5006
  }
4805
5007
 
4806
5008
  /// From the script thread, `work` on the main thread, not waited for.
@@ -4824,6 +5026,62 @@ enum CraftNativeScriptThread {
4824
5026
  }
4825
5027
  }
4826
5028
 
5029
+ extension CAAnimation {
5030
+ /// Asks for the display's full rate, up to 120Hz on ProMotion. Core
5031
+ /// Animation otherwise picks a rate for the motion and often stays at 60;
5032
+ /// a transition or a spring the eye follows reads smoother at 120.
5033
+ func atDisplayRate() {
5034
+ if #available(iOS 15.0, *) { preferredFrameRateRange = CAFrameRateRange(minimum: 80, maximum: 120, preferred: 120) }
5035
+ }
5036
+ }
5037
+
5038
+ /// `requestAnimationFrame` for native screens: one display link, at the
5039
+ /// display's own rate (up to 120Hz on a ProMotion iPhone, which the app
5040
+ /// allows with `CADisableMinimumFrameDurationOnPhone`), running only while a
5041
+ /// screen on display is waiting for a frame. Each frame hands every waiting
5042
+ /// screen one `__craftNativeFireFrame`, where its script runs; a screen asks
5043
+ /// again for the frame after, as a browser's page does. A screen out of sight
5044
+ /// waits until it is shown, as a browser pauses a hidden tab's frames, and as
5045
+ /// Craft's Android host does (Choreographer). Main thread only.
5046
+ final class CraftNativeFrameClock: NSObject {
5047
+ static let shared = CraftNativeFrameClock()
5048
+
5049
+ private var waiting: [ObjectIdentifier: () -> Void] = [:]
5050
+ private var link: CADisplayLink?
5051
+
5052
+ /// How many screens wait for the next frame.
5053
+ var waitingCount: Int { waiting.count }
5054
+ /// Whether the display link is counting frames now.
5055
+ var isRunning: Bool { link.map { !$0.isPaused } ?? false }
5056
+
5057
+ /// `deliver` on the next frame, once. Asking again before it comes keeps one.
5058
+ func request(_ owner: AnyObject, _ deliver: @escaping () -> Void) {
5059
+ waiting[ObjectIdentifier(owner)] = deliver
5060
+ if link == nil {
5061
+ let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
5062
+ if #available(iOS 15.0, *) {
5063
+ link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 120, preferred: 120)
5064
+ }
5065
+ link.add(to: .main, forMode: .common)
5066
+ self.link = link
5067
+ }
5068
+ link?.isPaused = false
5069
+ }
5070
+
5071
+ /// `owner` no longer waits: it went out of sight or away.
5072
+ func cancel(_ owner: AnyObject) {
5073
+ waiting.removeValue(forKey: ObjectIdentifier(owner))
5074
+ if waiting.isEmpty { link?.isPaused = true }
5075
+ }
5076
+
5077
+ @objc private func frame(_ link: CADisplayLink) {
5078
+ let due = waiting
5079
+ waiting.removeAll()
5080
+ if waiting.isEmpty { link.isPaused = true }
5081
+ due.values.forEach { $0() }
5082
+ }
5083
+ }
5084
+
4827
5085
  /// The native screens' JavaScript. A screen is a JavaScriptCore context of
4828
5086
  /// its own and evaluates the whole file it is given, so each route has a file
4829
5087
  /// of its own when stx wrote one (`dist/native-screens/<route>.js`, stx's
@@ -4900,6 +5158,25 @@ enum CraftNativeBundle {
4900
5158
  }
4901
5159
  }
4902
5160
 
5161
+ private final class CraftNativeScreenRootView: UIView {
5162
+ weak var controller: CraftNativeScreenController?
5163
+
5164
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5165
+ // A popup's host frame is only its authored card, so UIKit would not
5166
+ // ask it about an outside point. Shield that point at the screen root
5167
+ // only when a blocking modal is underneath; ordinary non-modal
5168
+ // overlays remain pass-through.
5169
+ if let controller {
5170
+ for modal in controller.view.subviews.compactMap({ $0 as? CraftNativeModalView }).reversed()
5171
+ where !modal.blocksBackground && !modal.isHidden && modal.alpha > 0.01 {
5172
+ let local = modal.convert(point, from: self)
5173
+ if !modal.bounds.contains(local) && (modal.dismissOnOutsideTap || controller.hasBlockingModalBelow(modal)) { return modal }
5174
+ }
5175
+ }
5176
+ return super.hitTest(point, with: event)
5177
+ }
5178
+ }
5179
+
4903
5180
  final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
4904
5181
  private final class RenderedNode {
4905
5182
  /// A recycled list row's root takes the identity of the row it shows next.
@@ -4952,6 +5229,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4952
5229
  private var scrollMomentumEndHandlers: [ObjectIdentifier: String] = [:]
4953
5230
  private var sliderCompleteHandlers: [ObjectIdentifier: String] = [:]
4954
5231
  private var sliderSteps: [ObjectIdentifier: Float] = [:]
5232
+ /// A controlled switch can be reconciled while UIKit is still animating
5233
+ /// the user's tap. Remember that native value briefly so the echo does not
5234
+ /// interrupt the animation and leave the thumb painted at its old edge.
5235
+ private var switchUserValues: [ObjectIdentifier: Bool] = [:]
4955
5236
  private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
4956
5237
  private var modalVisibility: [ObjectIdentifier: Bool] = [:]
4957
5238
  private var modalFocusRestore: [ObjectIdentifier: UIView] = [:]
@@ -5026,6 +5307,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5026
5307
  var pending: [Int: DispatchWorkItem] = [:]
5027
5308
  }
5028
5309
  private let timers = Timers()
5310
+ /// The script asked for an animation frame it has not had yet
5311
+ /// (`requestAnimationFrame`, CraftNativeFrameClock). Main thread only.
5312
+ private var frameWanted = false
5029
5313
  private var lifecycleObservers: [NSObjectProtocol] = []
5030
5314
  private var deepLinkListener: UUID?
5031
5315
  private var keyboardSafeAreaInset: CGFloat = 0
@@ -5056,6 +5340,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5056
5340
  fatalError("init(coder:) has not been implemented")
5057
5341
  }
5058
5342
 
5343
+ override func loadView() {
5344
+ let root = CraftNativeScreenRootView()
5345
+ root.controller = self
5346
+ view = root
5347
+ }
5348
+
5059
5349
  deinit {
5060
5350
  disposeRuntime()
5061
5351
  imageTasks.values.forEach { $0.cancel() }
@@ -5092,7 +5382,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5092
5382
  let pacing = CraftFramePacing.shared
5093
5383
  let inputs = scriptInputs()
5094
5384
  let name = routeName ?? "screen"
5095
- CraftNativeScriptThread.run { [self] in
5385
+ CraftNativeScriptThread.run(label: "mount \(name)") { [self] in
5096
5386
  pacing.measure("mount \(name) setup") { setupJavaScript(inputs) }
5097
5387
  pacing.measure("mount \(name) bundle") { loadBundle() }
5098
5388
  }
@@ -5153,8 +5443,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5153
5443
  screenVisibilityChanged(false)
5154
5444
  stopCelebrations(in: view)
5155
5445
  // Release only this screen's accessory. A retained or prepared screen
5156
- // can disappear after the destination has already published its own.
5157
- CraftChrome.shared.clearTabAccessory(ownedBy: self)
5446
+ // can disappear after the destination has already published its own;
5447
+ // released, it stays drawn until the destination says what it wants.
5448
+ CraftChrome.shared.releaseTabAccessory(ownedBy: self)
5158
5449
  // Popped (or its stack dismissed) while the bar was away: it comes
5159
5450
  // back for whatever shows next, a web page included.
5160
5451
  if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
@@ -5199,7 +5490,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5199
5490
  private func restoreShellChrome() {
5200
5491
  guard !isAppScript else { return }
5201
5492
  CraftChrome.shared.setHiddenByScreen(tabBarHidden)
5202
- CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
5493
+ // A screen not yet asked about the accessory (one shown for the first
5494
+ // time, before its script has run) leaves the bar as it is; one that
5495
+ // has drawn without asking for it takes off what nobody claims.
5496
+ if tabAccessoryDeclared { CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self) }
5497
+ else if hybridRendered { CraftChrome.shared.clearUnclaimedTabAccessory() }
5498
+ }
5499
+
5500
+ /// `requestAnimationFrame`: the next display frame, for a screen on display
5501
+ /// (or the app script, which is never shown). One out of sight keeps the
5502
+ /// request until it is shown again (screenVisibilityChanged).
5503
+ private func requestFrame() {
5504
+ guard !runtimeDisposed, !frameWanted else { return }
5505
+ frameWanted = true
5506
+ guard isAppScript || reportedVisible != false else { return }
5507
+ CraftNativeFrameClock.shared.request(self) { [weak self] in self?.fireFrame() }
5508
+ }
5509
+
5510
+ private func fireFrame() {
5511
+ guard frameWanted, !runtimeDisposed else { return }
5512
+ frameWanted = false
5513
+ let context = jsContext
5514
+ let route = routeName ?? "screen"
5515
+ CraftNativeScriptThread.run(label: "frame \(route)") { context.objectForKeyedSubscript("__craftNativeFireFrame")?.call(withArguments: []) }
5203
5516
  }
5204
5517
 
5205
5518
  /// The screen came on screen or went off it: a screen pushed over it was
@@ -5216,6 +5529,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5216
5529
  // preview also restores a screen that never lost focus, so restore its
5217
5530
  // chrome even when no new SCREEN_FOCUS event is needed.
5218
5531
  if visible { restoreShellChrome() }
5532
+ if visible, frameWanted { CraftNativeFrameClock.shared.request(self) { [weak self] in self?.fireFrame() } }
5533
+ if !visible { CraftNativeFrameClock.shared.cancel(self) }
5219
5534
  guard visible != reportedVisible else { return }
5220
5535
  let first = reportedVisible == nil
5221
5536
  reportedVisible = visible
@@ -5232,7 +5547,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5232
5547
  routeProps = props
5233
5548
  guard isViewLoaded, !runtimeDisposed else { return }
5234
5549
  let context = jsContext
5235
- CraftNativeScriptThread.run {
5550
+ let route = routeName ?? "screen"
5551
+ CraftNativeScriptThread.run(label: "params \(route)") {
5236
5552
  context.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
5237
5553
  context.setObject(props as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
5238
5554
  }
@@ -5242,9 +5558,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5242
5558
  private func disposeRuntime() {
5243
5559
  guard !runtimeDisposed else { return }
5244
5560
  runtimeDisposed = true
5561
+ frameWanted = false
5562
+ CraftNativeFrameClock.shared.cancel(self)
5245
5563
  disposeRenderedTree()
5246
5564
  let context = jsContext
5247
- CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
5565
+ let route = routeName ?? "screen"
5566
+ CraftNativeScriptThread.run(label: "unmount \(route)") { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
5248
5567
  audio.close()
5249
5568
  sounds.close()
5250
5569
  streams.close()
@@ -5312,9 +5631,58 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5312
5631
  if bar.intersects(view.bounds) { insets.top = max(insets.top, bar.maxY) }
5313
5632
  }
5314
5633
  for modal in modals {
5315
- if modal.frame != view.bounds { modal.frame = view.bounds }
5316
- modal.contentInsets = insets
5634
+ guard !modal.blocksBackground else {
5635
+ if modal.frame != view.bounds { modal.frame = view.bounds }
5636
+ modal.contentInsets = insets
5637
+ continue
5638
+ }
5639
+
5640
+ // Non-modal overlays are viewport children, not full-screen
5641
+ // scrims. Their absolute style uses the same full screenViewport
5642
+ // origin that View.measure returns. Safe-area-aware placement is
5643
+ // owned by the caller (for example, STX's anchor/clamp logic), so
5644
+ // an authored left/top is never shifted by an implicit inset.
5645
+ let viewport = view.bounds
5646
+ let style = modal.craftLayoutStyle
5647
+ let width = style.left != nil && style.right != nil
5648
+ ? max(0, viewport.width - (style.left ?? 0) - (style.right ?? 0))
5649
+ : style.width?.resolve(viewport.width)
5650
+ ?? min(viewport.width, modal.contentStack.sizeThatFits(viewport.size).width)
5651
+ let height = style.top != nil && style.bottom != nil
5652
+ ? max(0, viewport.height - (style.top ?? 0) - (style.bottom ?? 0))
5653
+ : style.height?.resolve(viewport.height)
5654
+ ?? min(viewport.height, modal.contentStack.sizeThatFits(viewport.size).height)
5655
+ let x = viewport.minX + (style.left ?? (style.right.map { viewport.width - $0 - width } ?? 0))
5656
+ let y = viewport.minY + (style.top ?? (style.bottom.map { viewport.height - $0 - height } ?? 0))
5657
+ let frame = CGRect(x: x, y: y, width: width, height: height)
5658
+ if modal.frame != frame { modal.frame = frame }
5659
+ // The frame already excludes the safe area; inset the content no
5660
+ // further or authored top/left coordinates would be shifted twice.
5661
+ modal.contentInsets = .zero
5662
+ }
5663
+ }
5664
+
5665
+ /// A non-modal popup above a blocking modal must not leak its outside tap
5666
+ /// to the modal's scrim and dismiss the wrong layer.
5667
+ fileprivate func hasBlockingModalBelow(_ modal: CraftNativeModalView) -> Bool {
5668
+ guard let index = view.subviews.firstIndex(where: { $0 === modal }) else { return false }
5669
+ return view.subviews[..<index].contains { candidate in
5670
+ guard let other = candidate as? CraftNativeModalView else { return false }
5671
+ return other.blocksBackground && !other.isHidden && other.alpha > 0.01
5672
+ }
5673
+ }
5674
+
5675
+ /// The native route's Back button dismisses the top presented modal before
5676
+ /// it pops the route, matching Android's activity back callback.
5677
+ @discardableResult
5678
+ func handleBackForModal() -> Bool {
5679
+ let modal = view.subviews.reversed().compactMap { $0 as? CraftNativeModalView }.first {
5680
+ !$0.isHidden && $0.alpha > 0.01 && ($0.blocksBackground || $0.dismissOnOutsideTap)
5317
5681
  }
5682
+ guard let modal else { return false }
5683
+ let hasHandler = modal.onRequestClose != nil
5684
+ modal.requestClose(reason: "back")
5685
+ return hasHandler
5318
5686
  }
5319
5687
 
5320
5688
  private static func safeJavaScriptString(_ value: JSValue?) -> String? {
@@ -5415,6 +5783,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5415
5783
  }
5416
5784
  jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
5417
5785
  jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
5786
+ // The next display frame, from the script's thread; the frame itself
5787
+ // comes back there as `__craftNativeFireFrame`.
5788
+ let requestFrame: @convention(block) () -> Void = { [weak self] in
5789
+ CraftNativeScriptThread.toMain { self?.requestFrame() }
5790
+ }
5791
+ jsContext.setObject(requestFrame, forKeyedSubscript: "craftNativeRequestFrame" as NSString)
5418
5792
  let capabilityJSON = inputs.capabilityJSON
5419
5793
  // The app's site and build, as JSON strings: the site an all-native
5420
5794
  // app's paths and links belong to (`devServerURL`), and the version a
@@ -5450,6 +5824,41 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5450
5824
  jsContext.evaluateScript(Self.nativeWebAPICompatibilityPrelude)
5451
5825
  installHostAPIs()
5452
5826
  jsContext.evaluateScript("""
5827
+ // requestAnimationFrame on the display's frames (CraftNativeFrameClock),
5828
+ // at its own rate, 120 a second on ProMotion: a timer guessing at 60
5829
+ // would cap every animation a screen draws itself. One request covers
5830
+ // every callback for the frame; a callback that throws does not stop
5831
+ // the others, and is reported after them.
5832
+ if (typeof globalThis.requestAnimationFrame !== 'function') {
5833
+ (function() {
5834
+ var frames = [];
5835
+ var cancelled = new Set();
5836
+ var nextFrame = 0;
5837
+ globalThis.requestAnimationFrame = function(callback) {
5838
+ if (typeof callback !== 'function') return 0;
5839
+ var id = ++nextFrame;
5840
+ frames.push({ id: id, callback: callback });
5841
+ if (frames.length === 1) craftNativeRequestFrame();
5842
+ return id;
5843
+ };
5844
+ globalThis.cancelAnimationFrame = function(id) {
5845
+ if (frames.some(function(frame) { return frame.id === id; })) cancelled.add(id);
5846
+ };
5847
+ globalThis.__craftNativeFireFrame = function() {
5848
+ var due = frames;
5849
+ var skip = cancelled;
5850
+ frames = [];
5851
+ cancelled = new Set();
5852
+ var now = globalThis.performance && typeof globalThis.performance.now === 'function' ? globalThis.performance.now() : Date.now();
5853
+ var failures = [];
5854
+ for (var i = 0; i < due.length; i++) {
5855
+ if (skip.has(due[i].id)) continue;
5856
+ try { due[i].callback(now); } catch (error) { failures.push(error); }
5857
+ }
5858
+ failures.forEach(function(error) { globalThis.setTimeout(function() { throw error; }, 0); });
5859
+ };
5860
+ })();
5861
+ }
5453
5862
  if (typeof globalThis.setTimeout !== 'function') {
5454
5863
  (function() {
5455
5864
  var timers = new Map();
@@ -5955,6 +6364,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5955
6364
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
5956
6365
  private var tabBarHidden = false
5957
6366
  private var tabAccessory: [String: Any]?
6367
+ /// Whether the screen has said anything about the accessory yet
6368
+ /// (`tabAccessory`, null included): until it has, the bar keeps showing
6369
+ /// the one it had (CraftChrome.releaseTabAccessory).
6370
+ private var tabAccessoryDeclared = false
5958
6371
  /// Whether a back-swipe may leave this screen at all, the edge's
5959
6372
  /// included (`swipeBack: false`): a screen that steps through its own
5960
6373
  /// pages with a Back of its own (a first-run tour's questions) would
@@ -6003,6 +6416,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6003
6416
  guard !isAppScript else { return }
6004
6417
  if options.keys.contains("tabAccessory") {
6005
6418
  tabAccessory = options["tabAccessory"] as? [String: Any]
6419
+ tabAccessoryDeclared = true
6006
6420
  if navigationController?.topViewController === self, view.window != nil {
6007
6421
  CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
6008
6422
  }
@@ -6392,7 +6806,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6392
6806
  let previous = info["previous"] as? String
6393
6807
  self.send(type: "SESSION_CHANGED", payload: ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
6394
6808
  let context = self.jsContext
6395
- CraftNativeScriptThread.run { context.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()]) }
6809
+ CraftNativeScriptThread.run(label: "session \(self.routeName ?? "screen")") { context.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()]) }
6396
6810
  })
6397
6811
  }
6398
6812
  if config.enablePushNotifications {
@@ -6677,8 +7091,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6677
7091
  }
6678
7092
  let module = payload["module"] as? String ?? ""
6679
7093
  let method = payload["method"] as? String ?? ""
7094
+ // With frame pacing on: how long the capability took to answer,
7095
+ // which a screen's script waits on (a request, the keychain).
7096
+ let pacing = CraftFramePacing.shared
7097
+ let asked = pacing.enabled ? CACurrentMediaTime() : 0
7098
+ let route = routeName ?? "screen"
7099
+ let what = module == "Network" ? "\(module).\(method) \(((args.first as? [String: Any])?["url"] as? String).flatMap { URL(string: $0)?.path } ?? "")" : "\(module).\(method)"
6680
7100
  let reply: (Result<Any, CraftNativeActionError>) -> Void = { [weak self] answer in
6681
7101
  guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
7102
+ if pacing.enabled { pacing.capability("\(what) \(route)", took: CACurrentMediaTime() - asked) }
6682
7103
  self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
6683
7104
  self.screenRequestCancellers.removeValue(forKey: requestToken)
6684
7105
  switch answer {
@@ -6907,6 +7328,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6907
7328
  hybridRendered = true
6908
7329
  // The app script's drawing is never on screen: not a first frame.
6909
7330
  if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
7331
+ // Drawn without a word about the accessory: it wants none.
7332
+ if !isAppScript, !tabAccessoryDeclared, isShownOnScreen { CraftChrome.shared.clearUnclaimedTabAccessory() }
6910
7333
  let waiting = renderWaiters
6911
7334
  renderWaiters.removeAll()
6912
7335
  waiting.forEach { $0() }
@@ -7328,6 +7751,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7328
7751
  let modal = result as! CraftNativeModalView
7329
7752
  let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
7330
7753
  modal.transparent = props["transparent"] as? Bool == true
7754
+ modal.blocksBackground = props["nonModal"] as? Bool != true
7755
+ modal.dismissOnOutsideTap = props["dismissOnOutsideTap"] as? Bool == true
7331
7756
  let visible: Bool
7332
7757
  if let propVisible = props["visible"] as? Bool {
7333
7758
  // An explicit visible prop owns modal visibility. The
@@ -7385,7 +7810,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7385
7810
  // UIKit has already changed the value before emitting the
7386
7811
  // event. Echoing it through the script must not restart its
7387
7812
  // native animation.
7388
- if toggle.isOn != value { toggle.isOn = value }
7813
+ let id = ObjectIdentifier(toggle)
7814
+ if switchUserValues[id] != value, toggle.isOn != value {
7815
+ // Programmatic changes should settle immediately. A
7816
+ // native tap is handled above without touching UIKit
7817
+ // while its own thumb animation is in flight.
7818
+ toggle.setOn(value, animated: false)
7819
+ }
7389
7820
  }
7390
7821
  toggle.isEnabled = props["disabled"] as? Bool != true
7391
7822
  if let track = props["trackColor"] as? [String: Any] {
@@ -7512,6 +7943,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7512
7943
  paused: (props["paused"] as? Bool) == true,
7513
7944
  loop: (props["loop"] as? Bool) == true,
7514
7945
  muted: (props["muted"] as? Bool) == true,
7946
+ currentTime: props["currentTime"],
7515
7947
  controls: (props["controls"] as? Bool) == true,
7516
7948
  resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String,
7517
7949
  pictureInPicture: (props["pictureInPicture"] as? Bool) == true
@@ -8104,6 +8536,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8104
8536
  imageLoadEndHandlers.removeValue(forKey: id)
8105
8537
  imageErrorHandlers.removeValue(forKey: id)
8106
8538
  checkboxIndeterminate.removeValue(forKey: id)
8539
+ switchUserValues.removeValue(forKey: id)
8107
8540
  if let video = node.view as? CraftNativeVideoView { video.stop() }
8108
8541
  if let map = node.view as? CraftNativeMapView { map.destroy() }
8109
8542
  for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
@@ -9086,7 +9519,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9086
9519
  let hasLinkTarget = ["href", "to"].contains { !(props[$0] as? String ?? "").isEmpty }
9087
9520
  let linkedControl = ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type) && hasLinkTarget
9088
9521
  let touchable = ["TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type)
9089
- let role = (props["accessibilityRole"] as? String) ?? (linkedControl ? "link" : touchable ? "button" : nil)
9522
+ let role = (props["accessibilityRole"] as? String)
9523
+ ?? (linkedControl ? "link" : touchable ? "button" : ["FlatList", "SectionList"].contains(type) ? "list" : nil)
9090
9524
  // A scroll view, a list or a group of controls holds what VoiceOver
9091
9525
  // reads: its label names the group, as a tab strip or a pager's
9092
9526
  // name, and never stands in for its content. As an element of its
@@ -9281,7 +9715,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9281
9715
  }
9282
9716
 
9283
9717
  @objc private func switchChanged(_ sender: UISwitch) {
9284
- guard let handler = handlers[ObjectIdentifier(sender)] else { return }
9718
+ let id = ObjectIdentifier(sender)
9719
+ switchUserValues[id] = sender.isOn
9720
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self, weak sender] in
9721
+ guard let self, let sender, self.switchUserValues[id] == sender.isOn else { return }
9722
+ self.switchUserValues.removeValue(forKey: id)
9723
+ }
9724
+ guard let handler = handlers[id] else { return }
9285
9725
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
9286
9726
  }
9287
9727
 
@@ -9555,7 +9995,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9555
9995
  guard let data = try? JSONSerialization.data(withJSONObject: message),
9556
9996
  let json = String(data: data, encoding: .utf8) else { return }
9557
9997
  let context = jsContext
9558
- CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json]) }
9998
+ let route = routeName ?? "screen"
9999
+ CraftNativeScriptThread.run(label: "\(type) \(route)") { context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json]) }
9559
10000
  }
9560
10001
 
9561
10002
  private func number(_ value: Any?) -> CGFloat? {
@@ -9962,6 +10403,7 @@ extension CraftNativeScreenController {
9962
10403
  }
9963
10404
  animation.fromValue = from
9964
10405
  let key = "\(CraftNativeRunningAnimation.key).\(keyPath)"
10406
+ animation.atDisplayRate()
9965
10407
  layer.add(animation, forKey: key)
9966
10408
  running.layers.append((layer, key))
9967
10409
  }