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.
- package/dist/android/templates/AndroidManifest.xml.template +3 -1
- package/dist/android/templates/CraftNativeServices.kt.template +41 -3
- package/dist/android/templates/MainActivityNative.kt.template +221 -3
- package/dist/api/mobile.d.ts +7 -0
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +10 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +10 -23
- package/dist/ios/templates/CraftHybrid.swift +6 -2
- package/dist/ios/templates/CraftNativeActions.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +403 -9
- package/dist/ios/templates/CraftNativeServices.swift +137 -20
- package/dist/ios/templates/CraftNativeUI.swift +2 -1
- package/dist/mobile.js +1 -0
- package/package.json +1 -1
|
@@ -450,7 +450,17 @@ private func craftNativeAlignContentCode(_ value: String) -> UInt32 {
|
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
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"'`.
|