craft-native 0.0.181 → 0.0.183

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.
@@ -450,7 +450,17 @@ private func craftNativeAlignContentCode(_ value: String) -> UInt32 {
450
450
  }
451
451
  }
452
452
 
453
- private final class CraftNativeVideoView: UIView {
453
+ /// A native screen's `<Video>`: AVPlayerViewController in place.
454
+ ///
455
+ /// With sound, a video takes the audio session as a playing sound does
456
+ /// (`CraftNativeSounds`): playback, mixed with the person's music, heard
457
+ /// through the silent switch, and with speech ducking the music under a cue
458
+ /// as it does for any other sound. With `pictureInPicture` it carries on in
459
+ /// the system's floating player when the app goes to the background or
460
+ /// another app opens (the app needs the `audio` background mode, which
461
+ /// `enableBackgroundAudio` writes), and comes back into its place when the
462
+ /// app does.
463
+ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate {
454
464
  private let playerController = AVPlayerViewController()
455
465
  private var player: AVPlayer?
456
466
  private var sourceURI = ""
@@ -460,6 +470,8 @@ private final class CraftNativeVideoView: UIView {
460
470
  private var timeObserver: Any?
461
471
  private var loops = false
462
472
  private var wasPlaying: Bool?
473
+ /// This video holds a claim on the audio session (`CraftNativeSounds`).
474
+ private var claimedSession = false
463
475
  /// The screen's handlers by event name (`onEnd`, `onPlay`, `onPause`,
464
476
  /// `onProgress`, `onLoad`, `onError`), and how an event reaches them.
465
477
  var handlers: [String: String] = [:]
@@ -477,6 +489,8 @@ private final class CraftNativeVideoView: UIView {
477
489
  // to UI tests and assistive technology.
478
490
  playerController.view.isAccessibilityElement = false
479
491
  playerController.view.accessibilityElementsHidden = true
492
+ playerController.delegate = self
493
+ playerController.allowsPictureInPicturePlayback = false
480
494
  }
481
495
 
482
496
  required init?(coder: NSCoder) {
@@ -515,9 +529,24 @@ private final class CraftNativeVideoView: UIView {
515
529
  wasPlaying = nil
516
530
  player?.pause()
517
531
  player = nil
532
+ releaseSession()
518
533
  }
519
534
 
520
- func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?) {
535
+ /// Playback, mixed with the music, before the player first makes a sound:
536
+ /// an AVPlayer started under the app's default session stops the music.
537
+ private func claimSession() {
538
+ guard !claimedSession else { return }
539
+ claimedSession = true
540
+ CraftNativeSounds.claimSession(respectSilentSwitch: false)
541
+ }
542
+
543
+ private func releaseSession() {
544
+ guard claimedSession else { return }
545
+ claimedSession = false
546
+ CraftNativeSounds.releaseSession()
547
+ }
548
+
549
+ func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?, pictureInPicture: Bool = false) {
521
550
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String ?? ""
522
551
  if uri != sourceURI {
523
552
  detachPlayer()
@@ -545,14 +574,43 @@ private final class CraftNativeVideoView: UIView {
545
574
  }
546
575
  guard let player else { return }
547
576
  player.isMuted = muted
577
+ // A muted video makes no sound, so it leaves the session as it is.
578
+ if muted { releaseSession() } else { claimSession() }
548
579
  playerController.showsPlaybackControls = controls
549
580
  playerController.videoGravity = resizeMode == "cover" ? .resizeAspectFill
550
581
  : resizeMode == "stretch" ? .resize
551
582
  : .resizeAspect
583
+ // Picture in Picture needs the playback session, so a muted video has none.
584
+ let floating = pictureInPicture && !muted
585
+ playerController.allowsPictureInPicturePlayback = floating
586
+ if #available(iOS 14.2, *) {
587
+ playerController.canStartPictureInPictureAutomaticallyFromInline = floating
588
+ }
552
589
  loops = loop
553
590
  if paused || !autoplay { player.pause() } else { player.play() }
554
591
  }
555
592
 
593
+ // MARK: AVPlayerViewControllerDelegate
594
+
595
+ /// The video stays in its place on the screen while it floats.
596
+ func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
597
+ false
598
+ }
599
+
600
+ /// Back from the floating player (the app came back, or its button was
601
+ /// tapped): the video is still in its place, so there is nothing to put back.
602
+ func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
603
+ completionHandler(player != nil)
604
+ }
605
+
606
+ func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
607
+ fire("onPictureInPictureStart")
608
+ }
609
+
610
+ func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
611
+ fire("onPictureInPictureStop")
612
+ }
613
+
556
614
  /// Load, play and pause, time, the end and failure, as the web's video
557
615
  /// element reports them, so `@ended` and `@timeupdate` work as on the page.
558
616
  private func observe(_ player: AVPlayer) {
@@ -3132,6 +3190,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3132
3190
  /// The compiler can emit an anchor as `TouchableOpacity`, so this map is
3133
3191
  /// intentionally shared by Link and the generic pressable path.
3134
3192
  private var linkTargets: [ObjectIdentifier: String] = [:]
3193
+ /// Views by their `transitionID`: one element across an animated change.
3194
+ private var transitionIDs: [ObjectIdentifier: String] = [:]
3195
+ /// `craft.animation.next`: the screen as it was, waiting for the change.
3196
+ private var animationTransaction: CraftNativeAnimationTransaction?
3197
+ /// The animation of the last change, still running.
3198
+ private var runningAnimation: CraftNativeRunningAnimation?
3135
3199
  private var renderedRoot: RenderedNode?
3136
3200
  private weak var lastFocusedInput: UIView?
3137
3201
 
@@ -3819,7 +3883,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3819
3883
  private var rootTrailingToSafeArea: NSLayoutConstraint?
3820
3884
  private var rootTrailingToView: NSLayoutConstraint?
3821
3885
  private var rootIsSafeAreaView = false
3822
- private var navigationBarHidden: Bool?
3886
+ private(set) var navigationBarHidden: Bool?
3823
3887
  /// A native `<StatusBar>` node overrides the app default for this screen.
3824
3888
  private var statusBarStyleOverride: UIStatusBarStyle?
3825
3889
  private var statusBarHiddenOverride: Bool?
@@ -4415,6 +4479,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4415
4479
  }
4416
4480
  case "NAVIGATION_SET_OPTIONS":
4417
4481
  applyNavigationOptions(payload)
4482
+ case "ANIMATE_NEXT":
4483
+ // The next change the screen makes animates (craft.animation.next).
4484
+ beginAnimatedChange(payload)
4418
4485
  case "NAVIGATE_OPEN":
4419
4486
  // A path rather than a screen: the hybrid shell opens it, natively
4420
4487
  // or in the web view.
@@ -4728,6 +4795,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4728
4795
  linkTargets.removeValue(forKey: ObjectIdentifier(result))
4729
4796
  }
4730
4797
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
4798
+ if let transitionID = props["transitionID"] as? String, !transitionID.isEmpty {
4799
+ transitionIDs[ObjectIdentifier(result)] = transitionID
4800
+ } else {
4801
+ transitionIDs.removeValue(forKey: ObjectIdentifier(result))
4802
+ }
4731
4803
  if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "SectionList" && type != "FlatList" && type != "Modal" {
4732
4804
  result.setContentHuggingPriority(.required, for: .vertical)
4733
4805
  }
@@ -5062,7 +5134,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5062
5134
  loop: (props["loop"] as? Bool) == true,
5063
5135
  muted: (props["muted"] as? Bool) == true,
5064
5136
  controls: (props["controls"] as? Bool) == true,
5065
- resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
5137
+ resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String,
5138
+ pictureInPicture: (props["pictureInPicture"] as? Bool) == true
5066
5139
  )
5067
5140
  case "Map":
5068
5141
  let map = result as! CraftNativeMapView
@@ -5267,17 +5340,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5267
5340
  }
5268
5341
 
5269
5342
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
5343
+ if let authored = authoredKey(node, props: props) { return authored }
5344
+ return (node["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
5345
+ }
5346
+
5347
+ /// The key a node's author gave it. A wrapper of one child (a
5348
+ /// component's) is keyed by what it wraps, so a keyed row drawn through
5349
+ /// a component keeps its key. Never a key further down beside siblings:
5350
+ /// one an `:if` shows and hides (a Back button) changed the identity of
5351
+ /// everything above it, and the whole subtree was built again, a pager's
5352
+ /// scroll position with it.
5353
+ private func authoredKey(_ node: [String: Any], props: [String: Any]) -> String? {
5270
5354
  // The current @stacksjs/stx native compiler emits key in props; accept the IR
5271
5355
  // field too so keyed children keep working when the compiler adopts it.
5272
5356
  let direct = [node["key"], props["key"], props["testID"]]
5273
5357
  .compactMap { $0 as? String }
5274
5358
  .first { !$0.isEmpty }
5275
5359
  if let direct { return direct }
5276
- for child in node["children"] as? [[String: Any]] ?? [] {
5277
- let childProps = child["props"] as? [String: Any] ?? [:]
5278
- if let nested = explicitKey(child, props: childProps) { return nested }
5279
- }
5280
- return (node["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
5360
+ let children = node["children"] as? [Any] ?? []
5361
+ guard children.count == 1, let only = children.first as? [String: Any] else { return nil }
5362
+ return authoredKey(only, props: only["props"] as? [String: Any] ?? [:])
5281
5363
  }
5282
5364
 
5283
5365
  private func accessibilityIdentifier(_ node: [String: Any], props: [String: Any]) -> String? {
@@ -5470,6 +5552,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5470
5552
  }
5471
5553
  handlers.removeValue(forKey: id)
5472
5554
  linkTargets.removeValue(forKey: id)
5555
+ transitionIDs.removeValue(forKey: id)
5473
5556
  pressInHandlers.removeValue(forKey: id)
5474
5557
  pressOutHandlers.removeValue(forKey: id)
5475
5558
  layoutHandlers.removeValue(forKey: id)
@@ -6787,6 +6870,317 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6787
6870
  }
6788
6871
  }
6789
6872
 
6873
+ // MARK: - Animated changes
6874
+
6875
+ /// What `craft.animation.next(options)` asked for. `duration` in seconds
6876
+ /// (0.1 to 1, 0.35 when left out); `curve` "spring" (the default, iOS's own
6877
+ /// smooth spring with no bounce unless `bounce` says), "easeInOut" or
6878
+ /// "linear".
6879
+ struct CraftNativeAnimationSpec {
6880
+ let duration: TimeInterval
6881
+ let curve: String
6882
+ let bounce: CGFloat
6883
+
6884
+ init(_ payload: [String: Any]) {
6885
+ let asked = (payload["duration"] as? NSNumber)?.doubleValue ?? 0.35
6886
+ duration = min(max(asked.isFinite ? asked : 0.35, 0.1), 1)
6887
+ let name = payload["curve"] as? String ?? "spring"
6888
+ curve = ["spring", "easeInOut", "linear"].contains(name) ? name : "spring"
6889
+ let springiness = (payload["bounce"] as? NSNumber)?.doubleValue ?? 0
6890
+ bounce = CGFloat(min(max(springiness.isFinite ? springiness : 0, 0), 0.5))
6891
+ }
6892
+
6893
+ var timing: UITimingCurveProvider {
6894
+ switch curve {
6895
+ case "linear": return UICubicTimingParameters(animationCurve: .linear)
6896
+ case "easeInOut": return UICubicTimingParameters(animationCurve: .easeInOut)
6897
+ default: return UISpringTimingParameters(dampingRatio: 1 - bounce)
6898
+ }
6899
+ }
6900
+ }
6901
+
6902
+ /// How long a change animates while one runs on any screen, so what the
6903
+ /// app changes beside it (the status bar's style) moves in step.
6904
+ enum CraftNativeAnimationClock {
6905
+ static var duration: TimeInterval?
6906
+ }
6907
+
6908
+ /// A view as it was when the change was asked for.
6909
+ final class CraftNativeAnimationRecord {
6910
+ let view: UIView
6911
+ weak var superview: UIView?
6912
+ /// In its parent's coordinates, and in the screen's.
6913
+ let frame: CGRect
6914
+ let frameOnScreen: CGRect
6915
+ let alpha: CGFloat
6916
+ let backgroundColor: UIColor?
6917
+ let fontSize: CGFloat?
6918
+ let textColor: UIColor?
6919
+ let transitionID: String?
6920
+
6921
+ init(view: UIView, screen: UIView, transitionID: String?) {
6922
+ self.view = view
6923
+ superview = view.superview
6924
+ frame = view.frame
6925
+ frameOnScreen = view.superview?.convert(view.frame, to: screen) ?? view.frame
6926
+ alpha = view.alpha
6927
+ backgroundColor = view.backgroundColor
6928
+ fontSize = (view as? UILabel)?.font.pointSize
6929
+ textColor = (view as? UILabel)?.textColor
6930
+ self.transitionID = transitionID
6931
+ }
6932
+ }
6933
+
6934
+ /// The screen between `craft.animation.next` and the change it animates.
6935
+ final class CraftNativeAnimationTransaction {
6936
+ let spec: CraftNativeAnimationSpec
6937
+ let reduceMotion: Bool
6938
+ var records: [ObjectIdentifier: CraftNativeAnimationRecord] = [:]
6939
+ var screenBackground: UIColor?
6940
+ /// Reduce Motion: the screen as it was, crossfaded into the new one.
6941
+ var picture: UIView?
6942
+
6943
+ init(spec: CraftNativeAnimationSpec, reduceMotion: Bool) {
6944
+ self.spec = spec
6945
+ self.reduceMotion = reduceMotion
6946
+ }
6947
+ }
6948
+
6949
+ /// An animation under way: finished at once if another change comes first.
6950
+ final class CraftNativeRunningAnimation {
6951
+ var animators: [UIViewPropertyAnimator] = []
6952
+ var cleanup: [() -> Void] = []
6953
+ private var done = false
6954
+
6955
+ func finish() {
6956
+ guard !done else { return }
6957
+ done = true
6958
+ for animator in animators where animator.state == .active {
6959
+ animator.stopAnimation(false)
6960
+ animator.finishAnimation(at: .end)
6961
+ }
6962
+ cleanup.forEach { $0() }
6963
+ cleanup.removeAll()
6964
+ }
6965
+ }
6966
+
6967
+ extension CraftNativeScreenController {
6968
+ /// `craft.animation.next(options)`: what the screen changes next (a
6969
+ /// render, a patch, its own background and bar) animates instead of
6970
+ /// cutting, as SwiftUI's `withAnimation` or React Native's
6971
+ /// `LayoutAnimation`. Views that stay move and resize to their new
6972
+ /// places; new views fade in and removed ones fade out where they were; a
6973
+ /// view whose font size changed scales into it. Two views with the same
6974
+ /// `transitionID`, one going and one coming, are one element: it moves and
6975
+ /// scales from the old one's place to the new one's, crossfading the two.
6976
+ /// With Reduce Motion on, the whole screen crossfades instead.
6977
+ func beginAnimatedChange(_ payload: [String: Any]) {
6978
+ guard !isAppScript, isViewLoaded else { return }
6979
+ // The last change's animation lands where it was going first.
6980
+ runningAnimation?.finish()
6981
+ runningAnimation = nil
6982
+ if animationTransaction != nil { return }
6983
+ let transaction = CraftNativeAnimationTransaction(spec: CraftNativeAnimationSpec(payload), reduceMotion: UIAccessibility.isReduceMotionEnabled)
6984
+ transaction.screenBackground = view.backgroundColor
6985
+ if transaction.reduceMotion {
6986
+ transaction.picture = view.window == nil ? nil : view.snapshotView(afterScreenUpdates: false)
6987
+ } else {
6988
+ view.layoutIfNeeded()
6989
+ recordAnimationState(renderedRoot, into: transaction)
6990
+ }
6991
+ animationTransaction = transaction
6992
+ CraftNativeAnimationClock.duration = transaction.spec.duration
6993
+ // The change comes from the same turn of the script (the state it
6994
+ // changed renders in a microtask); a slow one still animates.
6995
+ DispatchQueue.main.async { [weak self] in self?.commitAnimatedChange() }
6996
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in self?.commitAnimatedChange() }
6997
+ }
6998
+
6999
+ private func recordAnimationState(_ node: RenderedNode?, into transaction: CraftNativeAnimationTransaction) {
7000
+ guard let node else { return }
7001
+ let id = ObjectIdentifier(node.view)
7002
+ transaction.records[id] = CraftNativeAnimationRecord(view: node.view, screen: view, transitionID: transitionIDs[id])
7003
+ node.children.forEach { recordAnimationState($0, into: transaction) }
7004
+ }
7005
+
7006
+ private func renderedViews(_ node: RenderedNode?, into views: inout [UIView]) {
7007
+ guard let node else { return }
7008
+ views.append(node.view)
7009
+ node.children.forEach { renderedViews($0, into: &views) }
7010
+ }
7011
+
7012
+ private func commitAnimatedChange() {
7013
+ guard let transaction = animationTransaction else { return }
7014
+ animationTransaction = nil
7015
+ let spec = transaction.spec
7016
+ let running = CraftNativeRunningAnimation()
7017
+ runningAnimation = running
7018
+ running.cleanup.append { CraftNativeAnimationClock.duration = nil }
7019
+
7020
+ if transaction.reduceMotion {
7021
+ guard let picture = transaction.picture else { running.finish(); return }
7022
+ picture.frame = view.bounds
7023
+ picture.isUserInteractionEnabled = false
7024
+ view.addSubview(picture)
7025
+ let fade = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) { picture.alpha = 0 }
7026
+ fade.addCompletion { _ in running.finish() }
7027
+ running.animators.append(fade)
7028
+ running.cleanup.append { picture.removeFromSuperview() }
7029
+ fade.startAnimation()
7030
+ return
7031
+ }
7032
+
7033
+ UIView.performWithoutAnimation { view.layoutIfNeeded() }
7034
+ var after: [UIView] = []
7035
+ renderedViews(renderedRoot, into: &after)
7036
+ let present = Set(after.map(ObjectIdentifier.init))
7037
+ let records = transaction.records
7038
+
7039
+ // Going: no longer drawn. Only the topmost of a removed subtree moves
7040
+ // to the overlay; what is under it goes with it.
7041
+ let removed = records.values.filter { !present.contains(ObjectIdentifier($0.view)) }
7042
+ let removedIds = Set(removed.map { ObjectIdentifier($0.view) })
7043
+ let removedRoots = removed.filter { record in
7044
+ guard let parent = record.superview else { return true }
7045
+ return !removedIds.contains(ObjectIdentifier(parent))
7046
+ }
7047
+ // Coming: not there before.
7048
+ let inserted = after.filter { records[ObjectIdentifier($0)] == nil }
7049
+ let insertedIds = Set(inserted.map(ObjectIdentifier.init))
7050
+
7051
+ // One element across the change: the old view going, the new one coming.
7052
+ var oldShared: [String: CraftNativeAnimationRecord] = [:]
7053
+ for record in removed { if let tag = record.transitionID { oldShared[tag] = record } }
7054
+ var pairs: [(old: CraftNativeAnimationRecord, new: UIView)] = []
7055
+ for candidate in inserted {
7056
+ if let tag = transitionIDs[ObjectIdentifier(candidate)], let old = oldShared.removeValue(forKey: tag) {
7057
+ pairs.append((old, candidate))
7058
+ }
7059
+ }
7060
+ let pairedNew = Set(pairs.map { ObjectIdentifier($0.new) })
7061
+
7062
+ let overlay = UIView(frame: view.bounds)
7063
+ overlay.isUserInteractionEnabled = false
7064
+ overlay.backgroundColor = .clear
7065
+ view.addSubview(overlay)
7066
+ running.cleanup.append { overlay.removeFromSuperview() }
7067
+
7068
+ let main = UIViewPropertyAnimator(duration: spec.duration, timingParameters: spec.timing)
7069
+ // Fades take the first part, so nothing is half there for long.
7070
+ let fadeOut = UIViewPropertyAnimator(duration: spec.duration * 0.5, curve: .easeOut)
7071
+ let fadeIn = UIViewPropertyAnimator(duration: spec.duration * 0.6, curve: .easeInOut)
7072
+ running.animators = [main, fadeOut, fadeIn]
7073
+
7074
+ // Removed views fade where they were, over what replaced them.
7075
+ for record in removedRoots {
7076
+ let going = record.view
7077
+ going.translatesAutoresizingMaskIntoConstraints = true
7078
+ going.transform = .identity
7079
+ going.frame = record.frameOnScreen
7080
+ overlay.addSubview(going)
7081
+ fadeOut.addAnimations { going.alpha = 0 }
7082
+ }
7083
+
7084
+ // The background, the screen's own included, goes from the old colour.
7085
+ if let before = transaction.screenBackground, let now = view.backgroundColor, !before.isEqual(now) {
7086
+ view.backgroundColor = before
7087
+ main.addAnimations { self.view.backgroundColor = now }
7088
+ }
7089
+
7090
+ // Shared elements: the new view starts where the old one was, at its
7091
+ // size, and the old one travels with it as it fades.
7092
+ for (old, new) in pairs {
7093
+ let target = new.superview?.convert(new.frame, to: view) ?? new.frame
7094
+ let move = morph(from: old.frameOnScreen, to: target, uniform: true)
7095
+ let finalAlpha = new.alpha
7096
+ let base = new.transform
7097
+ new.transform = base.concatenating(move)
7098
+ new.alpha = 0
7099
+ main.addAnimations { new.transform = base }
7100
+ fadeIn.addAnimations { new.alpha = finalAlpha }
7101
+ let going = old.view
7102
+ going.translatesAutoresizingMaskIntoConstraints = true
7103
+ going.transform = .identity
7104
+ going.frame = old.frameOnScreen
7105
+ going.alpha = old.alpha
7106
+ overlay.addSubview(going)
7107
+ main.addAnimations { going.transform = move.inverted() }
7108
+ fadeOut.addAnimations { going.alpha = 0 }
7109
+ running.cleanup.append { new.transform = base; new.alpha = finalAlpha }
7110
+ }
7111
+
7112
+ // New views fade in. A new container holding a shared element keeps it
7113
+ // in sight: only the rest of it fades, and its colour comes up.
7114
+ for added in inserted where !(added.superview.map { insertedIds.contains(ObjectIdentifier($0)) } ?? false) {
7115
+ fadeInInserted(added, pairedNew: pairedNew, animator: fadeIn, running: running)
7116
+ }
7117
+
7118
+ // Views that stay go to their new places and sizes.
7119
+ for record in records.values where present.contains(ObjectIdentifier(record.view)) {
7120
+ let stays = record.view
7121
+ if let label = stays as? UILabel, let before = record.fontSize, abs(before - label.font.pointSize) > 0.5 {
7122
+ let base = label.transform
7123
+ let target = label.superview?.convert(label.frame, to: view) ?? label.frame
7124
+ label.transform = base.concatenating(morph(from: record.frameOnScreen, to: target, uniform: true))
7125
+ main.addAnimations { label.transform = base }
7126
+ running.cleanup.append { label.transform = base }
7127
+ } else if record.superview === stays.superview, record.frame != stays.frame {
7128
+ let final = stays.frame
7129
+ stays.frame = record.frame
7130
+ main.addAnimations { stays.frame = final }
7131
+ }
7132
+ if let before = record.backgroundColor, let now = stays.backgroundColor, !before.isEqual(now) {
7133
+ stays.backgroundColor = before
7134
+ main.addAnimations { stays.backgroundColor = now }
7135
+ }
7136
+ if let label = stays as? UILabel, let before = record.textColor, !before.isEqual(label.textColor) {
7137
+ let now = label.textColor
7138
+ label.textColor = before
7139
+ UIView.transition(with: label, duration: spec.duration * 0.6, options: [.transitionCrossDissolve, .allowAnimatedContent]) { label.textColor = now }
7140
+ }
7141
+ }
7142
+
7143
+ main.addCompletion { _ in running.finish() }
7144
+ fadeOut.startAnimation()
7145
+ fadeIn.startAnimation()
7146
+ main.startAnimation()
7147
+ }
7148
+
7149
+ /// The transform that puts a view at `final` where `start` was: moved
7150
+ /// centre to centre and scaled, by height alone when `uniform`, so text
7151
+ /// keeps its shape.
7152
+ private func morph(from start: CGRect, to final: CGRect, uniform: Bool) -> CGAffineTransform {
7153
+ guard final.width > 0, final.height > 0, start.width > 0, start.height > 0 else { return .identity }
7154
+ let sy = start.height / final.height
7155
+ let sx = uniform ? sy : start.width / final.width
7156
+ let dx = start.midX - final.midX
7157
+ let dy = start.midY - final.midY
7158
+ return CGAffineTransform(translationX: dx, y: dy).scaledBy(x: sx, y: sy)
7159
+ }
7160
+
7161
+ private func fadeInInserted(_ view: UIView, pairedNew: Set<ObjectIdentifier>, animator: UIViewPropertyAnimator, running: CraftNativeRunningAnimation) {
7162
+ if pairedNew.contains(ObjectIdentifier(view)) { return }
7163
+ guard containsShared(view, pairedNew: pairedNew) else {
7164
+ let finalAlpha = view.alpha
7165
+ view.alpha = 0
7166
+ animator.addAnimations { view.alpha = finalAlpha }
7167
+ running.cleanup.append { view.alpha = finalAlpha }
7168
+ return
7169
+ }
7170
+ if let colour = view.backgroundColor, colour != .clear {
7171
+ view.backgroundColor = colour.withAlphaComponent(0)
7172
+ animator.addAnimations { view.backgroundColor = colour }
7173
+ running.cleanup.append { view.backgroundColor = colour }
7174
+ }
7175
+ for child in view.subviews { fadeInInserted(child, pairedNew: pairedNew, animator: animator, running: running) }
7176
+ }
7177
+
7178
+ private func containsShared(_ view: UIView, pairedNew: Set<ObjectIdentifier>) -> Bool {
7179
+ if pairedNew.contains(ObjectIdentifier(view)) { return true }
7180
+ return view.subviews.contains { containsShared($0, pairedNew: pairedNew) }
7181
+ }
7182
+ }
7183
+
6790
7184
  /// `console` for native screens: the unified log, subsystem `craft.native`,
6791
7185
  /// category the screen's name. Read it with
6792
7186
  /// `log stream --predicate 'subsystem == "craft.native"'`.