@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.
- package/README.md +17 -6
- package/package.json +1 -1
- package/templates/CraftApp.swift +55 -7
- package/templates/CraftHealth.swift +38 -0
- package/templates/CraftHybrid.swift +50 -7
- package/templates/CraftNativeActions.swift +109 -22
- package/templates/CraftNativeFlatList.swift +187 -18
- package/templates/CraftNativeMutation.swift +3 -1
- package/templates/CraftNativeScreen.swift +466 -24
|
@@ -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
|
|
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
|
|
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:
|
|
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.
|
|
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),
|
|
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
|
-
|
|
4338
|
+
final class CraftNativeSvgView: UIView {
|
|
4271
4339
|
var drawing: [String: Any] = [:] {
|
|
4272
|
-
didSet {
|
|
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((
|
|
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
|
-
|
|
4803
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5316
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|