diff --git a/.gitignore b/.gitignore index 4a64961f..b73e0b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,7 @@ Pods Benchmark/Packages Benchmark/.build Benchmark/.swiftpm -Resources/MotionLogo-Dark.mov -Resources/MotionLogo-Light.mov +Resources/deprecated/MotionLogo-Dark.mov +Resources/deprecated/MotionLogo-Light.mov +build +Motion.zip diff --git a/Benchmark/.swiftpm/xcode/xcshareddata/xcschemes/MotionBenchmarkRunner.xcscheme b/Benchmark/.swiftpm/xcode/xcshareddata/xcschemes/MotionBenchmarkRunner.xcscheme index 6fb2c5be..eef6dc76 100644 --- a/Benchmark/.swiftpm/xcode/xcshareddata/xcschemes/MotionBenchmarkRunner.xcscheme +++ b/Benchmark/.swiftpm/xcode/xcshareddata/xcschemes/MotionBenchmarkRunner.xcscheme @@ -31,7 +31,7 @@ + + + + diff --git a/Resources/MotionMovieMaker/MotionMovieMaker.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Resources/MotionMovieMaker/MotionMovieMaker.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Resources/MotionMovieMaker/MotionMovieMaker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Resources/MotionMovieMaker/MotionMovieMaker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..fe020461 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "decomposed", + "kind" : "remoteSourceControl", + "location" : "https://github.com/b3ll/Decomposed", + "state" : { + "branch" : "main", + "revision" : "225ec7726a70ae586a5e0d65b450551d4f15b298" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "10bc670db657d11bdd561e07de30a9041311b2b1", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", + "version" : "1.0.2" + } + } + ], + "version" : 2 +} diff --git a/Resources/MotionMovieMaker/MotionMovieMaker.xcodeproj/xcshareddata/xcschemes/MotionMovieMaker.xcscheme b/Resources/MotionMovieMaker/MotionMovieMaker.xcodeproj/xcshareddata/xcschemes/MotionMovieMaker.xcscheme new file mode 100644 index 00000000..91985c01 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker.xcodeproj/xcshareddata/xcschemes/MotionMovieMaker.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/AppDelegate.swift b/Resources/MotionMovieMaker/MotionMovieMaker/AppDelegate.swift new file mode 100644 index 00000000..7061ee09 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// MotionMovieMaker +// +// Created by Adam Bell on 1/28/23. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + // Run this with a iPad Pro Simulator for best results. + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/Assets.xcassets/AccentColor.colorset/Contents.json b/Resources/MotionMovieMaker/MotionMovieMaker/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/MotionMovieMaker/MotionMovieMaker/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/Assets.xcassets/Contents.json b/Resources/MotionMovieMaker/MotionMovieMaker/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/Base.lproj/LaunchScreen.storyboard b/Resources/MotionMovieMaker/MotionMovieMaker/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/Base.lproj/Main.storyboard b/Resources/MotionMovieMaker/MotionMovieMaker/Base.lproj/Main.storyboard new file mode 100644 index 00000000..25a76385 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/BouncyView.swift b/Resources/MotionMovieMaker/MotionMovieMaker/BouncyView.swift new file mode 100644 index 00000000..2fa78130 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/BouncyView.swift @@ -0,0 +1,63 @@ +// +// BouncyView.swift +// MotionMovieMaker +// +// Created by Adam Bell on 1/28/23. +// + +import Motion +import UIKit + +public let MotionBlue = UIColor(red: 0.0/255.0, green: 212.0/255.0, blue: 255.0/255.0, alpha: 1.0) + +public class BouncyView: UIView { + + lazy var spring: SpringAnimation = { + let animation = SpringAnimation(initialValue: 1.0) + animation.configure(response: 0.4, dampingRatio: 0.4) + animation.onValueChanged(disableActions: true) { [weak self] (newValue) in + self?.layer.transform = CATransform3DMakeScale(newValue, newValue, 1.0) + } + return animation + }() + + public override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = MotionBlue + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override var bounds: CGRect { + didSet { + self.layer.cornerRadius = bounds.size.width / 2.0 + } + } + + public override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + + spring.toValue = 0.5 + spring.start() + } + + public override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + + spring.toValue = 1.0 + spring.velocity = 50.0 + spring.start() + } + + public override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + super.touchesCancelled(touches, with: event) + + spring.toValue = 1.0 + spring.velocity = 0.0 + spring.start() + } + +} diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/Info.plist b/Resources/MotionMovieMaker/MotionMovieMaker/Info.plist new file mode 100644 index 00000000..dd3c9afd --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/Info.plist @@ -0,0 +1,25 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/MotionMovieMaker-Bridging-Header.h b/Resources/MotionMovieMaker/MotionMovieMaker/MotionMovieMaker-Bridging-Header.h new file mode 100644 index 00000000..e11d920b --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/MotionMovieMaker-Bridging-Header.h @@ -0,0 +1,3 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/SceneDelegate.swift b/Resources/MotionMovieMaker/MotionMovieMaker/SceneDelegate.swift new file mode 100644 index 00000000..52e02529 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/SceneDelegate.swift @@ -0,0 +1,52 @@ +// +// SceneDelegate.swift +// MotionMovieMaker +// +// Created by Adam Bell on 1/28/23. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let _ = (scene as? UIWindowScene) else { return } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/Resources/MotionMovieMaker/MotionMovieMaker/ViewController.swift b/Resources/MotionMovieMaker/MotionMovieMaker/ViewController.swift new file mode 100644 index 00000000..17428221 --- /dev/null +++ b/Resources/MotionMovieMaker/MotionMovieMaker/ViewController.swift @@ -0,0 +1,223 @@ +// +// ViewController.swift +// MotionMovieMaker +// +// Created by Adam Bell on 1/28/23. +// + +import Decomposed +import Motion +import UIKit + +class ViewController: UIViewController { + + let serialRenderQueue = DispatchQueue(label: "animationRenderQueue", qos: .userInitiated) + + lazy private var titleLabel: UILabel = { + let label = UILabel(frame: .zero) + + let fontSize: CGFloat = 285 + let systemFont = UIFont.systemFont(ofSize: fontSize, weight: .semibold) + let roundedFont: UIFont + if let descriptor = systemFont.fontDescriptor.withDesign(.rounded) { + roundedFont = UIFont(descriptor: descriptor, size: fontSize) + } else { + roundedFont = systemFont + } + label.font = roundedFont + label.textColor = .label + label.minimumScaleFactor = 1.0 + label.adjustsFontSizeToFitWidth = false + + return label + }() + + lazy private var oLabel: UILabel = { + let label = UILabel(frame: .zero) + + label.font = self.titleLabel.font + label.text = "O" + return label + }() + + // See Sonic 2's intro for the inspiration :) + let motionAnimation = SpringAnimation(response: 0.5, dampingRatio: 0.575) + + let bouncyView = BouncyView(frame: .zero) + + private var frameNumber: Int = 0 + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + titleLabel.text = "M TION" + view.addSubview(titleLabel) + + view.addSubview(bouncyView) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + motionAnimation.onValueChanged(disableActions: true) { [unowned self] newValue in + bouncyView.layer.position.x = newValue + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + view.layoutIfNeeded() + + zoomRight(animated: false, completion: nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.startAnimation() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + oLabel.sizeToFit() + + titleLabel.sizeToFit() + titleLabel.layer.position = CGPoint(x: view.bounds.size.width / 2.0, y: view.bounds.size.height / 2.0) + + // Size the bouncy view based on the o's size in the font. + let bouncyViewSize = min(oLabel.bounds.size.width, oLabel.bounds.size.height) + bouncyView.bounds.size = CGSize(width: bouncyViewSize, height: bouncyViewSize) + bouncyView.layer.position.y = view.bounds.size.height / 2.0 + } + + func startAnimation() { + view.setNeedsLayout() + view.layoutIfNeeded() + + // This is… definitely something. + renderDelayFrames(count: 60) { + self.zoomLeft(velocity: -100.0) { + self.zoomRight(velocity: 100.0) { + self.settleInMiddle(velocity: 100.0) { + self.renderDelayFrames(count: 8 * 60 ) { + print("Done rendering: \(URL.documentsDirectory.absoluteString)") + } + } + } + } + } + } + + func zoomLeft(animated: Bool = true, velocity: CGFloat? = nil, completion: (() -> Void)?) { + motionAnimation.configure(response: 0.6, dampingRatio: 1.0) + + zoom(to: -bouncyView.bounds.size.width * 4.0, + animated: animated, + velocity: velocity, + resolvesUponReachingToValue: true, + completion: completion) + } + + func zoomRight(animated: Bool = true, velocity: CGFloat? = nil, completion: (() -> Void)?) { + motionAnimation.configure(response: 0.6, dampingRatio: 1.0) + + zoom(to: view.bounds.size.width + (bouncyView.bounds.size.width * 4.0), + animated: animated, + velocity: velocity, + resolvesUponReachingToValue: true, + completion: completion) + } + + func settleInMiddle(animated: Bool = true, velocity: CGFloat? = nil, completion: (() -> Void)?) { + motionAnimation.configure(response: 0.6, dampingRatio: 0.675) + + bouncyView.layer.filters = nil + + zoom(to: titleLabel.frame.minX + bouncyView.bounds.size.width + 150.0, + animated: animated, + velocity: velocity, + resolvesUponReachingToValue: false, + completion: completion) + } + + private func zoom(to value: CGFloat, animated: Bool = true, velocity: CGFloat? = nil, resolvesUponReachingToValue: Bool = false, completion: (() -> Void)?) { + if !animated { + motionAnimation.completion = nil + motionAnimation.stop() + + motionAnimation.updateValue(to: value, postValueChanged: true) + completion?() + return + } + + motionAnimation.velocity = velocity ?? motionAnimation.velocity + motionAnimation.toValue = value + motionAnimation.resolvesUponReachingToValue = resolvesUponReachingToValue + motionAnimation.completion = completion + + renderUntilResolved() + } + + private func renderUntilResolved() { + if motionAnimation.hasResolved() { + return + } + + + // Tick the animation manually after rendering each frame. + motionAnimation.tick(frame: .init(1.0 / 60.0)) + + renderToDisk(frameNumber: frameNumber) { + self.frameNumber += 1 + + self.renderUntilResolved() + } + } + + private var delayFramesRemaining: Int = 0 + + private func renderDelayFrames(count: Int, completion: @escaping () -> Void) { + self.delayFramesRemaining = count + renderDelayFrame(framesRemaining: &delayFramesRemaining, completion: completion) + } + + private func renderDelayFrame(framesRemaining: UnsafeMutablePointer, completion: @escaping () -> Void) { + if framesRemaining.pointee == 0 { + completion() + return + } + + renderToDisk(frameNumber: frameNumber) { + self.frameNumber += 1 + + framesRemaining.pointee -= 1 + + self.renderDelayFrame(framesRemaining: framesRemaining, completion: completion) + } + } + + private func renderToDisk(frameNumber: Int, completion: @escaping () -> Void) { + let renderer = UIGraphicsImageRenderer(bounds: view.bounds) + let image = renderer.image { ctx in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) + } + + // TOOD: For some reason this just fails to render random frames, figure out why. + serialRenderQueue.async { + guard let pngData = image.pngData() else { fatalError("Failed to generate pngData") } + + do { + try pngData.write(to: .documentsDirectory.appending(path: "\(String(format: "%03d", frameNumber)).png")) + } catch { + fatalError(error.localizedDescription) + } + + DispatchQueue.main.async { + completion() + } + } + } + +} diff --git a/Resources/Motion-Logo-Rendered-Optimized~olde.gif b/Resources/deprecated/Motion-Logo-Rendered-Optimized~olde.gif similarity index 100% rename from Resources/Motion-Logo-Rendered-Optimized~olde.gif rename to Resources/deprecated/Motion-Logo-Rendered-Optimized~olde.gif diff --git a/Resources/Motion-Logo-Rendered.gif b/Resources/deprecated/Motion-Logo-Rendered.gif similarity index 100% rename from Resources/Motion-Logo-Rendered.gif rename to Resources/deprecated/Motion-Logo-Rendered.gif diff --git a/Resources/Motion-Logo-Rendered.m4v b/Resources/deprecated/Motion-Logo-Rendered.m4v similarity index 100% rename from Resources/Motion-Logo-Rendered.m4v rename to Resources/deprecated/Motion-Logo-Rendered.m4v diff --git a/Resources/Motion-Logo-Rendered~dark.gif b/Resources/deprecated/Motion-Logo-Rendered~dark.gif similarity index 100% rename from Resources/Motion-Logo-Rendered~dark.gif rename to Resources/deprecated/Motion-Logo-Rendered~dark.gif diff --git a/Resources/Motion-Logo-Rendered~olde.gif b/Resources/deprecated/Motion-Logo-Rendered~olde.gif similarity index 100% rename from Resources/Motion-Logo-Rendered~olde.gif rename to Resources/deprecated/Motion-Logo-Rendered~olde.gif diff --git a/Resources/deprecated/MotionLogo-Dark-Cropped.gif b/Resources/deprecated/MotionLogo-Dark-Cropped.gif new file mode 100644 index 00000000..0e057c9d Binary files /dev/null and b/Resources/deprecated/MotionLogo-Dark-Cropped.gif differ diff --git a/Resources/deprecated/MotionLogo-Dark.gif b/Resources/deprecated/MotionLogo-Dark.gif new file mode 100644 index 00000000..33e9f971 Binary files /dev/null and b/Resources/deprecated/MotionLogo-Dark.gif differ diff --git a/Resources/MotionLogo-Dark.motn b/Resources/deprecated/MotionLogo-Dark.motn similarity index 100% rename from Resources/MotionLogo-Dark.motn rename to Resources/deprecated/MotionLogo-Dark.motn diff --git a/Resources/deprecated/MotionLogo-Light-Cropped.gif b/Resources/deprecated/MotionLogo-Light-Cropped.gif new file mode 100644 index 00000000..831cc7c1 Binary files /dev/null and b/Resources/deprecated/MotionLogo-Light-Cropped.gif differ diff --git a/Resources/deprecated/MotionLogo-Light.gif b/Resources/deprecated/MotionLogo-Light.gif new file mode 100644 index 00000000..20123d8b Binary files /dev/null and b/Resources/deprecated/MotionLogo-Light.gif differ diff --git a/Resources/MotionLogo-Light.motn b/Resources/deprecated/MotionLogo-Light.motn similarity index 100% rename from Resources/MotionLogo-Light.motn rename to Resources/deprecated/MotionLogo-Light.motn diff --git a/Resources/MotionLogo/MotionLogo.motn b/Resources/deprecated/MotionLogo/MotionLogo.motn similarity index 100% rename from Resources/MotionLogo/MotionLogo.motn rename to Resources/deprecated/MotionLogo/MotionLogo.motn diff --git a/Resources/deprecated/render_files.sh b/Resources/deprecated/render_files.sh new file mode 100755 index 00000000..193dfde4 --- /dev/null +++ b/Resources/deprecated/render_files.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +rm MotionLogo-Light.gif +rm MotionLogo-Light-Cropped.gif +rm MotionLogo-Dark.gif +rm MotionLogo-Dark-Cropped.gif + +gifski --fps 50 --extra --width 1920 --height 1080 -o MotionLogo-Light.gif MotionLogo-Light.mov + +gifsicle --crop 180,400+1560x280 --output MotionLogo-Light-Cropped.gif MotionLogo-Light.gif + +gifski --fps 50 --extra --width 1920 --height 1080 -o MotionLogo-Dark.gif MotionLogo-Dark.mov + +gifsicle --crop 180,400+1560x280 --output MotionLogo-Dark-Cropped.gif MotionLogo-Dark.gif diff --git a/Resources/render_files.sh b/Resources/render_files.sh index 193dfde4..2ad6b963 100755 --- a/Resources/render_files.sh +++ b/Resources/render_files.sh @@ -1,14 +1,25 @@ #!/bin/bash -rm MotionLogo-Light.gif -rm MotionLogo-Light-Cropped.gif -rm MotionLogo-Dark.gif -rm MotionLogo-Dark-Cropped.gif +rm -f MotionLogo-Light.mp4 || true +rm -f MotionLogo-Light.gif || true +rm -f MotionLogo-Light-Cropped.gif || true -gifski --fps 50 --extra --width 1920 --height 1080 -o MotionLogo-Light.gif MotionLogo-Light.mov +rm -f MotionLogo-Dark.mp4 || true +rm -f MotionLogo-Dark.gif || true +rm -f MotionLogo-Dark-Cropped.gif || true -gifsicle --crop 180,400+1560x280 --output MotionLogo-Light-Cropped.gif MotionLogo-Light.gif +# light -gifski --fps 50 --extra --width 1920 --height 1080 -o MotionLogo-Dark.gif MotionLogo-Dark.mov +ffmpeg -r 60 -pattern_type glob -i "light/*.png" -c:v libx265 -x265-params lossless=1 MotionLogo-Light.mp4 -gifsicle --crop 180,400+1560x280 --output MotionLogo-Dark-Cropped.gif MotionLogo-Dark.gif +gifski --fps 50 --width 2732 --height 2048 --extra --quality 100 --motion-quality 100 --lossy-quality 100 --repeat 0 -o MotionLogo-Light.gif MotionLogo-Light.mp4 + +gifsicle --crop 000,640+2732x768 --output MotionLogo-Light-Cropped.gif MotionLogo-Light.gif + +# dark + +ffmpeg -r 60 -pattern_type glob -i "dark/*.png" -c:v libx265 -x265-params lossless=1 MotionLogo-Dark.mp4 + +gifski --fps 50 --width 2732 --height 2048 --extra --quality 100 --motion-quality 100 --lossy-quality 100 --repeat 0 -o MotionLogo-Dark.gif MotionLogo-Dark.mp4 + +gifsicle --crop 000,640+2732x768 --output MotionLogo-Dark-Cropped.gif MotionLogo-Dark.gif diff --git a/Sources/Motion/Animations/AnimationGroup.swift b/Sources/Motion/Animations/AnimationGroup.swift index 55dbe0a3..f16eb9b6 100644 --- a/Sources/Motion/Animations/AnimationGroup.swift +++ b/Sources/Motion/Animations/AnimationGroup.swift @@ -25,9 +25,9 @@ public final class AnimationGroup: Animation { - Parameters: - animations: The animations to run as a group. */ - init(_ animations: Animation...) { + init(environment: AnimationEnvironment = .default, _ animations: Animation...) { self.animations = animations - super.init() + super.init(environment: environment) } /// Returns whether or not all of the animations have resolved. diff --git a/Sources/Motion/Animations/BasicAnimation.swift b/Sources/Motion/Animations/BasicAnimation.swift index d133da2c..ed98bbd3 100644 --- a/Sources/Motion/Animations/BasicAnimation.swift +++ b/Sources/Motion/Animations/BasicAnimation.swift @@ -69,10 +69,11 @@ public final class BasicAnimation: ValueAnimation = .linear) { + public init(easingFunction: EasingFunction = .linear, environment: AnimationEnvironment = .default) { self.easingFunction = easingFunction - super.init() + super.init(environment: environment) } override public func start() { @@ -82,7 +83,7 @@ public final class BasicAnimation: ValueAnimation(easingFunction: inout EasingFunction, range: inout ClosedRange, value: inout SIMDType) -> CFTimeInterval? { + internal func solveAccumulatedTime(easingFunction: EasingFunction, range: inout ClosedRange, value: inout SIMDType) -> CFTimeInterval? { /* Must Be Mirrored Below */ if !range.contains(value) { @@ -106,7 +107,7 @@ public final class BasicAnimation: ValueAnimation) @_specialize(kind: partial, where SIMDType == SIMD64) @_specialize(kind: partial, where SIMDType == SIMD64) - internal func solveAccumulatedTime(easingFunction: inout EasingFunction, range: inout ClosedRange, value: inout SIMDType) -> CFTimeInterval? { + internal func solveAccumulatedTime(easingFunction: EasingFunction, range: inout ClosedRange, value: inout SIMDType) -> CFTimeInterval? { /* Must Be Mirrored Above */ if !range.contains(value) { @@ -125,9 +126,9 @@ public final class BasicAnimation: ValueAnimation: ValueAnimation(value: inout SIMDType, epsilon: inout SIMDType.EpsilonType, toValue: inout SIMDType) -> Bool { /* Must Be Mirrored Below */ - return value.approximatelyEqual(to: toValue, epsilon: epsilon) + return value.isApproximatelyEqual(to: toValue, epsilon: epsilon) } #else @_specialize(kind: partial, where SIMDType == SIMD2) @@ -189,7 +190,7 @@ public final class BasicAnimation: ValueAnimation(value: inout SIMDType, epsilon: inout SIMDType.EpsilonType, toValue: inout SIMDType) -> Bool { /* Must Be Mirrored Above */ - return value.approximatelyEqual(to: toValue, epsilon: epsilon) + return value.isApproximatelyEqual(to: toValue, epsilon: epsilon) } #endif @@ -208,7 +209,7 @@ public final class BasicAnimation: ValueAnimation: ValueAnimation: ValueAnimation(easingFunction: inout EasingFunction, range: inout ClosedRange, fraction: SIMDType.Scalar, value: inout SIMDType) where SIMDType.Scalar == SIMDType.SIMDType.Scalar { + internal func tickOptimized(easingFunction: EasingFunction, range: inout ClosedRange, fraction: SIMDType.Scalar, value: inout SIMDType) where SIMDType.Scalar == SIMDType.SIMDType.Scalar { /* Must Be Mirrored Below */ - value = easingFunction.solveInterpolatedValue(range, fraction: fraction) + value = easingFunction.solveInterpolatedValueSIMD(range, fraction: fraction) } #else @_specialize(kind: partial, where SIMDType == SIMD2) @@ -250,10 +251,10 @@ public final class BasicAnimation: ValueAnimation) @_specialize(kind: partial, where SIMDType == SIMD64) @_specialize(kind: partial, where SIMDType == SIMD64) - internal func tickOptimized(easingFunction: inout EasingFunction, range: inout ClosedRange, fraction: SIMDType.Scalar, value: inout SIMDType) where SIMDType.Scalar == SIMDType.SIMDType.Scalar { + internal func tickOptimized(easingFunction: EasingFunction, range: inout ClosedRange, fraction: SIMDType.Scalar, value: inout SIMDType) where SIMDType.Scalar == SIMDType.SIMDType.Scalar { /* Must Be Mirrored Above */ - value = easingFunction.solveInterpolatedValue(range, fraction: fraction) + value = easingFunction.solveInterpolatedValueSIMD(range, fraction: fraction) } #endif diff --git a/Sources/Motion/Animations/DecayAnimation.swift b/Sources/Motion/Animations/DecayAnimation.swift index 97eb91e6..37f02d55 100644 --- a/Sources/Motion/Animations/DecayAnimation.swift +++ b/Sources/Motion/Animations/DecayAnimation.swift @@ -38,6 +38,39 @@ public final class DecayAnimation: ValueAnimation /// Returns whether or not the animation has resolved. It is considered resolved when its velocity reaches zero. @@ -82,11 +115,12 @@ public final class DecayAnimation: ValueAnimation: ValueAnimation)`. + + - Note: Applying `roundingFactor` will aid in the ability for the `DecayAnimation` to correctly stop on pixel boundaries when adjusting the `toValue`. + */ public override var toValue: Value { - get { return .zero } - set { } + get { + return Value(_toValue) + } + set { + _toValue = newValue.simdRepresentation() + } } // MARK: - AnimationDriverObserver public override func tick(frame: AnimationFrame) { - tickOptimized(Value.SIMDType.Scalar(frame.duration), decay: &decay, value: &_value, velocity: &_velocity) + tickOptimized(Value.SIMDType.Scalar(frame.duration), decay: decay, value: &_value, velocity: &_velocity) _valueChanged?(value) if hasResolved(velocity: &_velocity) { + if roundingFactor != 0.0 { + self._value = decay.roundSIMD(_value, toNearest: roundingFactor) + _valueChanged?(value) + } + stop() completion?() @@ -134,10 +190,10 @@ public final class DecayAnimation: ValueAnimation(_ dt: SIMDType.SIMDType.Scalar, decay: inout DecayFunction, value: inout SIMDType, velocity: inout SIMDType) { + internal func tickOptimized(_ dt: SIMDType.SIMDType.Scalar, decay: DecayFunction, value: inout SIMDType, velocity: inout SIMDType) where SIMDType.SIMDType == SIMDType { /* Must Be Mirrored Below */ - value = decay.solve(dt: dt, x0: value, velocity: &velocity) + value = decay.solveSIMD(dt: dt, x0: value, velocity: &velocity) } #else @_specialize(kind: partial, where SIMDType == SIMD2) @@ -154,10 +210,10 @@ public final class DecayAnimation: ValueAnimation) @_specialize(kind: partial, where SIMDType == SIMD64) @_specialize(kind: partial, where SIMDType == SIMD64) - internal func tickOptimized(_ dt: SIMDType.SIMDType.Scalar, decay: inout DecayFunction, value: inout SIMDType, velocity: inout SIMDType) { + internal func tickOptimized(_ dt: SIMDType.SIMDType.Scalar, decay: DecayFunction, value: inout SIMDType, velocity: inout SIMDType) where SIMDType.SIMDType == SIMDType { /* Must Be Mirrored Above */ - value = decay.solve(dt: dt, x0: value, velocity: &velocity) + value = decay.solveSIMD(dt: dt, x0: value, velocity: &velocity) } #endif diff --git a/Sources/Motion/Animations/Functions/DecayFunction.swift b/Sources/Motion/Animations/Functions/DecayFunction.swift index 33d61567..9d01063c 100644 --- a/Sources/Motion/Animations/Functions/DecayFunction.swift +++ b/Sources/Motion/Animations/Functions/DecayFunction.swift @@ -29,6 +29,13 @@ public struct DecayFunction { } } + /** + A value used to round the final value. Defaults to 0.5. + + - Description: This is useful when implementing things like scroll views, where the final value will rest on nice pixel values so that text remains sharp. It defaults to 0.5, but applying 1.0 / the scale factor of the view will lead to similar behaviours as `UIScrollView`. Setting this to `0.0` disables any rounding. + */ + public var roundingFactor: Value.SIMDType.Scalar = 0.5 + /// A cached invocation of `1.0 / (ln(decayConstant) * 1000.0)` private(set) public var one_ln_decayConstant_1000: Value.SIMDType.Scalar = 0.0 @@ -54,12 +61,12 @@ public struct DecayFunction { - Parameters: - dt: The duration in seconds since the last frame. - x0: The starting value. - - velocity: The velocity of the spring. + - velocity: The velocity of the decay. - Returns: The new value as its velocity decays. */ @inlinable public func solveSIMD(dt: Value.SIMDType.Scalar, x0: Value.SIMDType, velocity: inout Value.SIMDType) -> Value.SIMDType { - let d_1000_dt = Value.SIMDType.Scalar.pow(decayConstant, 1000.0 * dt) + let d_1000_dt = Value.SIMDType.Scalar.pow(decayConstant, dt * 1000.0) // Analytic decay equation with constants extracted out. let x = x0 + velocity * ((d_1000_dt - 1.0) * one_ln_decayConstant_1000) @@ -83,6 +90,152 @@ public struct DecayFunction { return Value(x) } + /** + Solves the destination for the decay function based on the given parameters for a `SupportedSIMD` type. + + - Parameters: + - value: The starting value. + - velocity: The starting velocity of the decay. + - decayConstant: The decay constant. + - roundingFactor: The desired rounding factor. Supplying `0.0` will disable any rounding. + + - Returns: The destination when the decay reaches zero velocity. + */ + @_specialize(kind: partial, where SIMDType == SIMD2) + @_specialize(kind: partial, where SIMDType == SIMD2) + @_specialize(kind: partial, where SIMDType == SIMD3) + @_specialize(kind: partial, where SIMDType == SIMD3) + @_specialize(kind: partial, where SIMDType == SIMD4) + @_specialize(kind: partial, where SIMDType == SIMD4) + @_specialize(kind: partial, where SIMDType == SIMD8) + @_specialize(kind: partial, where SIMDType == SIMD8) + @_specialize(kind: partial, where SIMDType == SIMD16) + @_specialize(kind: partial, where SIMDType == SIMD16) + @_specialize(kind: partial, where SIMDType == SIMD32) + @_specialize(kind: partial, where SIMDType == SIMD32) + @_specialize(kind: partial, where SIMDType == SIMD64) + @_specialize(kind: partial, where SIMDType == SIMD64) + @inlinable public func solveToValueSIMD(value: SIMDType, velocity: SIMDType, decayConstant: SIMDType.Scalar, roundingFactor: SIMDType.Scalar) -> SIMDType { + let decay = (SIMDType.Scalar.log(decayConstant) * 1000.0) + let toValue = value - velocity / decay + return roundSIMD(toValue, toNearest: roundingFactor) + } + + /** + Solves the destination for the decay function based on the given parameters for a `Value`. + + - Parameters: + - value: The starting value. + - velocity: The starting velocity of the decay. + - decayConstant: The decay constant. + - roundingFactor: The desired rounding factor. Supplying `0.0` will disable any rounding. + + - Returns: The destination when the decay reaches zero velocity. + + - Note: This mirrors the `SupportedSIMD` version above, but for `Value` types. + */ + @inlinable public func solveToValue(value: Value, velocity: Value) -> Value { + let toValue = solveToValueSIMD(value: value.simdRepresentation(), velocity: velocity.simdRepresentation(), decayConstant: decayConstant, roundingFactor: roundingFactor) + return Value(toValue) + } + + /** + Solves the velocity required to reach a desired destination for a decay function based on the given parameters for a `SupportedSIMD` type. + + - Parameters: + - value: The starting value. + - toValue: The desired destination for the decay. + - decayConstant: The decay constant. + + - Returns: The velocity required to reach `toValue`. + */ + @_specialize(kind: partial, where SIMDType == SIMD2) + @_specialize(kind: partial, where SIMDType == SIMD2) + @_specialize(kind: partial, where SIMDType == SIMD3) + @_specialize(kind: partial, where SIMDType == SIMD3) + @_specialize(kind: partial, where SIMDType == SIMD4) + @_specialize(kind: partial, where SIMDType == SIMD4) + @_specialize(kind: partial, where SIMDType == SIMD8) + @_specialize(kind: partial, where SIMDType == SIMD8) + @_specialize(kind: partial, where SIMDType == SIMD16) + @_specialize(kind: partial, where SIMDType == SIMD16) + @_specialize(kind: partial, where SIMDType == SIMD32) + @_specialize(kind: partial, where SIMDType == SIMD32) + @_specialize(kind: partial, where SIMDType == SIMD64) + @_specialize(kind: partial, where SIMDType == SIMD64) + @inlinable public func solveVelocitySIMD(value: SIMDType, toValue: SIMDType, decayConstant: SIMDType.Scalar) -> SIMDType { + let decay = (SIMDType.Scalar.log(decayConstant) * 1000.0) + return (value - toValue) * decay + } + + /** + Solves the velocity required to reach a desired destination for a decay function based on the given parameters for a `Value`. + + - Parameters: + - value: The starting value. + - toValue: The desired destination for the decay. + + - Returns: The velocity required to reach `toValue`. + + - Note: This mirrors the `SupportedSIMD` version above, but for `Value` types. + */ + @inlinable public func solveVelocity(value: Value, toValue: Value) -> Value { + let velocity = solveVelocitySIMD(value: value.simdRepresentation(), toValue: toValue.simdRepresentation(), decayConstant: decayConstant) + return Value(velocity) + } + + // MARK: - Rounding + + /** + Rounds the given value to the nearest decimal point and factor (supplied by the `roundingFactor`). + - Description: i.e. supplying a rounding factor of `0.5` for a value of `3.70`, will return `3.5`. + + - Parameters: + - value: The value to round. + - toValue: The rounding factor. + + - Returns: The value rounded to the nearest supplied decimal and factor. + + - Note: This mirrors the `SupportedSIMD` version above, but for `Value` types. + */ + @inlinable public func round(_ value: Value, toNearest roundingFactor: Value.SIMDType.Scalar) -> Value { + let roundedValue = roundSIMD(value.simdRepresentation(), toNearest: roundingFactor) + return Value(roundedValue) + } + + /** + Rounds the given value to the nearest decimal point and factor (supplied by the `roundingFactor`). + - Description: i.e. supplying a rounding factor of `0.5` for a value of `3.70`, will return `3.5`. + + - Parameters: + - value: The value to round. + - toValue: The rounding factor. + + - Returns: The value rounded to the nearest supplied decimal and factor. + */ + @_specialize(kind: partial, where SIMDType == SIMD2) + @_specialize(kind: partial, where SIMDType == SIMD2) + @_specialize(kind: partial, where SIMDType == SIMD3) + @_specialize(kind: partial, where SIMDType == SIMD3) + @_specialize(kind: partial, where SIMDType == SIMD4) + @_specialize(kind: partial, where SIMDType == SIMD4) + @_specialize(kind: partial, where SIMDType == SIMD8) + @_specialize(kind: partial, where SIMDType == SIMD8) + @_specialize(kind: partial, where SIMDType == SIMD16) + @_specialize(kind: partial, where SIMDType == SIMD16) + @_specialize(kind: partial, where SIMDType == SIMD32) + @_specialize(kind: partial, where SIMDType == SIMD32) + @_specialize(kind: partial, where SIMDType == SIMD64) + @_specialize(kind: partial, where SIMDType == SIMD64) + @inlinable public func roundSIMD(_ value: SIMDType, toNearest roundingFactor: SIMDType.Scalar) -> SIMDType { + if roundingFactor.isApproximatelyEqual(to: 0.0) { + return value + } + + let rounded = (value / roundingFactor).rounded(.toNearestOrAwayFromZero) * roundingFactor + return rounded + } + } extension DecayFunction where Value: SupportedSIMD { @@ -96,4 +249,32 @@ extension DecayFunction where Value: SupportedSIMD { return solveSIMD(dt: dt, x0: x0, velocity: &velocity) } + /** + Solves the decay function for a given `Value`. + + - Note: This mirrors the `SupportedSIMD` version above, but acts as a fast path to call the `solveToValueSIMD` method directly instead of boxing and unboxing the same value. + */ + @inlinable public func solveToValue(value: Value.SIMDType, velocity: Value.SIMDType) -> Value.SIMDType { + return solveToValueSIMD(value: value, velocity: velocity, decayConstant: decayConstant, roundingFactor: roundingFactor) + } + + /** + Solves the velocity required to reach a desired destination for a decay function based on the given parameters for a `Value`. + + - Note: This mirrors the `SupportedSIMD` version above, but acts as a fast path to call the `solveVelocitySIMD` method directly instead of boxing and unboxing the same value. + */ + @inlinable public func solveVelocity(value: Value.SIMDType, toValue: Value.SIMDType) -> Value.SIMDType { + return solveVelocitySIMD(value: value, toValue: toValue, decayConstant: decayConstant) + } + + /** + Rounds the given value to the nearest decimal point and factor (supplied by the `roundingFactor`). + - Description: i.e. supplying a rounding factor of `0.5` for a value of `3.70`, will return `3.5`. + + - Note: This mirrors the `SupportedSIMD` version above, but acts as a fast path to call the `roundSIMD` method directly instead of boxing and unboxing the same value. + */ + @inlinable public func round(_ value: Value.SIMDType, toNearest roundingFactor: Value.SIMDType.Scalar) -> Value.SIMDType { + return roundSIMD(value, toNearest: roundingFactor) + } + } diff --git a/Sources/Motion/Animations/Functions/EasingFunctions.swift b/Sources/Motion/Animations/Functions/EasingFunctions.swift index 20a054b3..e5c5d3b7 100644 --- a/Sources/Motion/Animations/Functions/EasingFunctions.swift +++ b/Sources/Motion/Animations/Functions/EasingFunctions.swift @@ -54,7 +54,7 @@ public struct EasingFunction: Hashable { - Returns: An interpolated SIMD value between the supplied range's bounds based on a fraction (from 0.0 to 1.0) of the easing function. */ - @inlinable public func solveInterpolatedValueSIMD(_ range: ClosedRange, fraction: Value.SIMDType.Scalar) -> Value.SIMDType { + @inlinable public func solveInterpolatedValueSIMD(_ range: ClosedRange, fraction: SIMDType.Scalar) -> SIMDType where SIMDType.Scalar == Value.SIMDType.Scalar { let x = bezier.solve(x: fraction) let min = range.lowerBound @@ -80,7 +80,7 @@ public struct EasingFunction: Hashable { @inlinable internal func solveAccumulatedTimeSIMD(_ range: ClosedRange, value: Value.SIMDType) -> CFTimeInterval? { guard let usableIndex = value.indices.first(where: { i -> Bool in let fractionComplete = value[i] / (range.upperBound[i] - range.lowerBound[i]) - return !(fractionComplete.approximatelyEqual(to: 0.0) || fractionComplete.approximatelyEqual(to: 1.0)) + return !(fractionComplete.isApproximatelyEqual(to: 0.0) || fractionComplete.isApproximatelyEqual(to: 1.0)) }) else { return nil } let fractionComplete = value[usableIndex] / (range.upperBound[usableIndex] - range.lowerBound[usableIndex]) diff --git a/Sources/Motion/Animations/Functions/SpringFunction.swift b/Sources/Motion/Animations/Functions/SpringFunction.swift index e502adb2..ecee9446 100644 --- a/Sources/Motion/Animations/Functions/SpringFunction.swift +++ b/Sources/Motion/Animations/Functions/SpringFunction.swift @@ -15,6 +15,7 @@ import simd - Note: This can be used on its own, but it's mainly used by `SpringAnimation`'s `tick` method. - SeeAlso: `SpringAnimation` */ + public struct SpringFunction { /** @@ -110,7 +111,7 @@ public struct SpringFunction { */ public mutating func configure(response response_: Value.SIMDType.Scalar, dampingRatio: Value.SIMDType.Scalar) { let response: Value.SIMDType.Scalar - if response_.approximatelyEqual(to: 0.0) { + if response_.isApproximatelyEqual(to: 0.0) { // Having a zero response is unsupported, so we'll just supply an arbitrarily small value. response = 0.0001 } else { @@ -165,12 +166,13 @@ public struct SpringFunction { typealias SIMDType = Value.SIMDType let x: SIMDType if dampingRatio < 1.0 { - let decayEnvelope = SIMDType.Scalar.exp(-dampingRatio * w0 * dt) + let dampingRatio_w0 = dampingRatio * w0 + let decayEnvelope = SIMDType.Scalar.exp(-dampingRatio_w0 * dt) let sin_wD_dt = SIMDType.Scalar.sin(wD * dt) let cos_wD_dt = SIMDType.Scalar.cos(wD * dt) - let velocity_x0_dampingRatio_w0 = (velocity + x0 * (dampingRatio * w0)) + let velocity_x0_dampingRatio_w0 = (velocity + x0 * dampingRatio_w0) let A = x0 let B = velocity_x0_dampingRatio_w0 / wD @@ -180,8 +182,8 @@ public struct SpringFunction { // Derivative of the above analytic equation to get the speed of a spring. (velocity) let d_x = velocity_x0_dampingRatio_w0 * cos_wD_dt - x0 * (wD * sin_wD_dt) - velocity = -(dampingRatio * w0 * x - decayEnvelope * d_x) - } else if dampingRatio.approximatelyEqual(to: 1.0) { + velocity = -(dampingRatio_w0 * x - decayEnvelope * d_x) + } else if dampingRatio.isApproximatelyEqual(to: 1.0) { let decayEnvelope = SIMDType.Scalar.exp(-w0 * dt) let A = x0 diff --git a/Sources/Motion/Animations/SpringAnimation.swift b/Sources/Motion/Animations/SpringAnimation.swift index 402e8ecc..93c1e325 100644 --- a/Sources/Motion/Animations/SpringAnimation.swift +++ b/Sources/Motion/Animations/SpringAnimation.swift @@ -141,10 +141,11 @@ public final class SpringAnimation: ValueAnimation: ValueAnimation: ValueAnimation 1.0`: An underdamped spring. + - environment: The ``AnimationEnvironment`` where this animation will run. - Note: For more information on how these values work, check out the WWDC talk on fluid animations: https://developer.apple.com/videos/play/wwdc2018/803/. */ - public convenience init(initialValue: Value = .zero, response: Value.SIMDType.Scalar, dampingRatio: Value.SIMDType.Scalar) { - self.init(initialValue: initialValue) + public convenience init(initialValue: Value = .zero, response: Value.SIMDType.Scalar, dampingRatio: Value.SIMDType.Scalar, environment: AnimationEnvironment = .default) { + self.init(initialValue: initialValue, environment: environment) configure(response: response, dampingRatio: dampingRatio) } @@ -209,24 +212,45 @@ public final class SpringAnimation: ValueAnimation Bool { - let resolvedState = hasResolved(value: &_value, epsilon: &resolvingEpsilon, toValue: &_toValue, velocity: &_velocity) + var previousValueDelta: Value.SIMDType? = nil + let resolvedState = hasResolved(value: &_value, epsilon: &resolvingEpsilon, toValue: &_toValue, velocity: &_velocity, previousValueDelta: &previousValueDelta) return resolvedState.valueResolved && resolvedState.velocityResolved } #if DEBUG - internal func hasResolved(value: inout SIMDType, epsilon: inout SIMDType.EpsilonType, toValue: inout SIMDType, velocity: inout SIMDType) -> (valueResolved: Bool, velocityResolved: Bool) { + internal func hasResolved(value: inout SIMDType, epsilon: inout SIMDType.EpsilonType, toValue: inout SIMDType, velocity: inout SIMDType, previousValueDelta: inout SIMDType?) -> (valueResolved: Bool, velocityResolved: Bool) { /* Must Be Mirrored Below */ - let valueResolved = value.approximatelyEqual(to: toValue, epsilon: epsilon) - if !valueResolved { + let valueResolved = value.isApproximatelyEqual(to: toValue, epsilon: epsilon) + if !valueResolved, !resolvesUponReachingToValue { return (false, false) } - if resolvesUponReachingToValue { - return (valueResolved, true) + if resolvesUponReachingToValue, !valueResolved, let previousValueDelta = previousValueDelta { + let currentValueDelta = toValue - value + let hasReachedOrExceededToValue = { + var index = 0 + let count = currentValueDelta.scalarCount + var allValuesReachedOrExceededToValues = true + while index < count, allValuesReachedOrExceededToValues { + /** + - Note: An overshoot has happened once the sign of the value before executing the spring is different than the sign of the value after the spring + + For example: `SpringAnimation` animating with `toValue` of 0.0, starting at 1.0, will have a value of ~0.1 on one frame, the next frame it could be either 0.0 or -0.1, in which case either the values have been reached or exeeded (sign changed from + to -). + */ + allValuesReachedOrExceededToValues = allValuesReachedOrExceededToValues && + (previousValueDelta[index].sign != currentValueDelta[index].sign) || (value[index].isApproximatelyEqual(to: toValue[index], epsilon: 0.01)) + + index += 1 + } + + return allValuesReachedOrExceededToValues + }() + + return (hasReachedOrExceededToValue, true) } - let velocityResolved = velocity.approximatelyEqual(to: .zero, epsilon: epsilon) + let velocityResolved = velocity.isApproximatelyEqual(to: .zero, epsilon: epsilon) return (valueResolved, velocityResolved) } #else @@ -244,19 +268,39 @@ public final class SpringAnimation: ValueAnimation) @_specialize(kind: partial, where SIMDType == SIMD64) @_specialize(kind: partial, where SIMDType == SIMD64) - internal func hasResolved(value: inout SIMDType, epsilon: inout SIMDType.EpsilonType, toValue: inout SIMDType, velocity: inout SIMDType) -> (valueResolved: Bool, velocityResolved: Bool) { + internal func hasResolved(value: inout SIMDType, epsilon: inout SIMDType.EpsilonType, toValue: inout SIMDType, velocity: inout SIMDType, previousValueDelta: inout SIMDType?) -> (valueResolved: Bool, velocityResolved: Bool) { /* Must Be Mirrored Above */ - let valueResolved = value.approximatelyEqual(to: toValue, epsilon: epsilon) - if !valueResolved { + let valueResolved = value.isApproximatelyEqual(to: toValue, epsilon: epsilon) + if !valueResolved, !resolvesUponReachingToValue { return (false, false) } - if resolvesUponReachingToValue { - return (valueResolved, true) + if resolvesUponReachingToValue, !valueResolved, let previousValueDelta = previousValueDelta { + let currentValueDelta = toValue - value + let hasReachedOrExceededToValue = { + var index = 0 + let count = currentValueDelta.scalarCount + var allValuesReachedOrExceededToValues = true + while index < count, allValuesReachedOrExceededToValues { + /** + - Note: An overshoot has happened once the sign of the value before executing the spring is different than the sign of the value after the spring + + For example: `SpringAnimation` animating with `toValue` of 0.0, starting at 1.0, will have a value of ~0.1 on one frame, the next frame it could be either 0.0 or -0.1, in which case either the values have been reached or exeeded (sign changed from + to -). + */ + allValuesReachedOrExceededToValues = allValuesReachedOrExceededToValues && + (previousValueDelta[index].sign != currentValueDelta[index].sign) || (value[index].isApproximatelyEqual(to: toValue[index], epsilon: 0.01)) + + index += 1 + } + + return allValuesReachedOrExceededToValues + }() + + return (hasReachedOrExceededToValue, true) } - let velocityResolved = velocity.approximatelyEqual(to: .zero, epsilon: epsilon) + let velocityResolved = velocity.isApproximatelyEqual(to: .zero, epsilon: epsilon) return (valueResolved, velocityResolved) } #endif @@ -276,11 +320,27 @@ public final class SpringAnimation: ValueAnimation 0.0 else { return } + + // This subtraction here makes it approx 13%! slower + // Conditional negates it. + var previousValueDelta: Value.SIMDType? + if resolvesUponReachingToValue { + previousValueDelta = _toValue - _value + } else { + previousValueDelta = nil + } - _valueChanged?(value) + tickOptimized(Value.SIMDType.Scalar(dt), spring: spring, value: &_value, toValue: &_toValue, velocity: &_velocity, clampingRange: &_clampingRange) - let resolvedState = hasResolved(value: &_value, epsilon: &resolvingEpsilon, toValue: &_toValue, velocity: &_velocity) + let resolvedState = hasResolved(value: &_value, epsilon: &resolvingEpsilon, toValue: &_toValue, velocity: &_velocity, previousValueDelta: &previousValueDelta) + if !resolvedState.valueResolved || !resolvedState.velocityResolved { + _valueChanged?(value) + } if resolvedState.valueResolved && resolvedState.velocityResolved { stop() @@ -300,12 +360,12 @@ public final class SpringAnimation: ValueAnimation(_ dt: SIMDType.Scalar, spring: inout SpringFunction, value: inout SIMDType, toValue: inout SIMDType, velocity: inout SIMDType, clampingRange: inout ClosedRange?) where SIMDType.Scalar == SIMDType.SIMDType.Scalar { + internal func tickOptimized(_ dt: SIMDType.Scalar, spring: SpringFunction, value: inout SIMDType, toValue: inout SIMDType, velocity: inout SIMDType, clampingRange: inout ClosedRange?) where SIMDType.Scalar == SIMDType.SIMDType.Scalar, SIMDType == SIMDType.SIMDType { /* Must Be Mirrored Below */ let x0 = toValue - value - let x = spring.solve(dt: dt, x0: x0, velocity: &velocity) + let x = spring.solveSIMD(dt: dt, x0: x0, velocity: &velocity) value = toValue - x @@ -328,12 +388,12 @@ public final class SpringAnimation: ValueAnimation) @_specialize(kind: partial, where SIMDType == SIMD64) @_specialize(kind: partial, where SIMDType == SIMD64) - internal func tickOptimized(_ dt: SIMDType.Scalar, spring: inout SpringFunction, value: inout SIMDType, toValue: inout SIMDType, velocity: inout SIMDType, clampingRange: inout ClosedRange?) where SIMDType.Scalar == SIMDType.SIMDType.Scalar { + internal func tickOptimized(_ dt: SIMDType.Scalar, spring: SpringFunction, value: inout SIMDType, toValue: inout SIMDType, velocity: inout SIMDType, clampingRange: inout ClosedRange?) where SIMDType.Scalar == SIMDType.SIMDType.Scalar, SIMDType == SIMDType.SIMDType { /* Must Be Mirrored Above */ let x0 = toValue - value - let x = spring.solve(dt: dt, x0: x0, velocity: &velocity) + let x = spring.solveSIMD(dt: dt, x0: x0, velocity: &velocity) value = toValue - x diff --git a/Sources/Motion/Animations/ValueAnimation.swift b/Sources/Motion/Animations/ValueAnimation.swift index 0efe0215..065b822b 100644 --- a/Sources/Motion/Animations/ValueAnimation.swift +++ b/Sources/Motion/Animations/ValueAnimation.swift @@ -23,6 +23,9 @@ open class Animation: AnimationDriverObserver { - Note: Calling `start` or `stop` enables (or disables) this value. Animations will only run when this is `true` and `hasResolved` is false. */ open var enabled: Bool = false { + willSet { + registerWithAnimatorIfNeeded() + } didSet { enabledDidChange(enabled) } @@ -36,13 +39,35 @@ open class Animation: AnimationDriverObserver { */ open var completion: (() -> Void)? = nil + internal weak var environment: AnimationEnvironment? + /// Default initializer. Animations must be strongly held to continue to animate. - public init() { - Animator.shared.observe(self) + public init(environment: AnimationEnvironment) { + self.environment = environment + } + + private weak var animator: Animator? + + private func registerWithAnimatorIfNeeded() { + assert(environment != nil) + + guard let currentAnimator = animator else { + self.animator = environment?.animator + self.animator?.observe(self) + return + } + + if let environmentAnimator = environment?.animator { + if currentAnimator !== environmentAnimator { + currentAnimator.unobserve(self) + environmentAnimator.observe(self) + self.animator = environmentAnimator + } + } } deinit { - Animator.shared.unobserve(self) + animator?.unobserve(self) } /// Starts the animation if `hasResolved` is false. diff --git a/Sources/Motion/Documentation.docc/Resources/Motion-Logo-Rendered.gif b/Sources/Motion/Documentation.docc/Resources/Motion-Logo-Rendered.gif index 831cc7c1..712f0b93 100644 Binary files a/Sources/Motion/Documentation.docc/Resources/Motion-Logo-Rendered.gif and b/Sources/Motion/Documentation.docc/Resources/Motion-Logo-Rendered.gif differ diff --git a/Sources/Motion/Documentation.docc/Resources/Motion-Logo-Rendered~dark.gif b/Sources/Motion/Documentation.docc/Resources/Motion-Logo-Rendered~dark.gif index 0e057c9d..67d5fdc8 100644 Binary files a/Sources/Motion/Documentation.docc/Resources/Motion-Logo-Rendered~dark.gif and b/Sources/Motion/Documentation.docc/Resources/Motion-Logo-Rendered~dark.gif differ diff --git a/Sources/Motion/Protocols/AnimationEnvironment.swift b/Sources/Motion/Protocols/AnimationEnvironment.swift new file mode 100644 index 00000000..8e11b506 --- /dev/null +++ b/Sources/Motion/Protocols/AnimationEnvironment.swift @@ -0,0 +1,56 @@ +import Foundation + +#if os(macOS) +import AppKit +import CoreGraphics +#else +public typealias CGDirectDisplayID = UInt32 +#endif + +/** + Adopted by types that can provide an ``Animator`` to drive Motion animations in a given environment. + + - Note: The animation environment is only relevant for native Mac apps. + On iOS, you don't have to specify an animation environment and using ``AnimationEnvironment/default`` will always work. + */ +public protocol AnimationEnvironment: AnyObject { + /// The ``Animator`` type to be used for animations in this environment. + var animator: Animator { get } + + #if os(macOS) + /// Identifier for the CoreGraphics display represented by this environment. + var displayID: CGDirectDisplayID? { get } + + /// The preferred FPS for animations running in this environment. + var preferredFramesPerSecond: Int { get } + #endif +} + +public extension AnimationEnvironment where Self == DefaultAnimationEnvironment { + /** + The default animation environment for the current device. + + On iOS, this will be the shared animator. + + On macOS, this will be the environment representing the main screen (`NSScreen.main`). + + - Warning: On macOS, you should always provide the most specific animation environment available + when initializing an animation, such as the `NSView` where the animation will be rendered. + Failing to do so may result in animations that don't use the optimal frame rate for the Mac's screen, + or assertion failures in debug builds when an environment can't be inferred automatically. + - SeeAlso: To learn more about environment specificity on macOS, see the documentation for ``AnimationEnvironmentProxy``. + */ + static var `default`: AnimationEnvironment { DefaultAnimationEnvironment.shared } +} + +// MARK: - iOS Stubs + +#if canImport(UIKit) +public extension AnimationEnvironment { + var animator: Animator { Animator.shared } +} + +public final class DefaultAnimationEnvironment: AnimationEnvironment { + public static let shared = DefaultAnimationEnvironment() +} +#endif diff --git a/Sources/Motion/Protocols/EquatableEnough.swift b/Sources/Motion/Protocols/EquatableEnough.swift index e2eff264..5105aeae 100644 --- a/Sources/Motion/Protocols/EquatableEnough.swift +++ b/Sources/Motion/Protocols/EquatableEnough.swift @@ -7,6 +7,7 @@ import CoreGraphics import Foundation +import RealModule import simd // MARK: - FloatingPointInitializable @@ -40,7 +41,7 @@ public protocol EquatableEnough: Comparable { Declares whether or not something else is equal to `self` within a given tolerance. (e.g. a floating point value that is equal to another floating point value within a given epsilon) */ - func approximatelyEqual(to: Self, epsilon: EpsilonType) -> Bool + func isApproximatelyEqual(to: Self, epsilon: EpsilonType) -> Bool } @@ -85,9 +86,11 @@ public extension EquatableEnough where Self: FloatingPoint & FloatingPointInitia /** Declares whether or not something else is equal to `self` within a given tolerance. (e.g. a floating point value that is equal to another floating point value within a given epsilon) + + Bridges to Swift Numerics' `isApproximatelyEqual` (https://github.com/schwa/ApproximateEquality/blob/main/Sources/ApproximateEquality/ApproximateEquality.swift). */ - @inlinable func approximatelyEqual(to other: Self, epsilon: Self = .epsilon) -> Bool { - return abs(self - other) < epsilon + @inlinable func isApproximatelyEqual(to other: Self, epsilon: Self = .epsilon) -> Bool { + return isApproximatelyEqual(to: other, absoluteTolerance: epsilon, relativeTolerance: .zero) } } @@ -113,116 +116,20 @@ public extension EquatableEnough where Self: FloatingPoint & FloatingPointInitia return copy } -/* - Note: These are probably not the most optimal, especially in the bigger SIMD types. I haven't yet figured out how to do an equality within a given tolerance across all values simultaneously, so for now it's just a `reduce`. - In addition, having to write all of these generics is probably wrong, I just haven't gotten around to simplifying it all yet and doing performance tracing on the change / verifying specialization. - - Something like extension `EquatableEnough where Self: SupportedSIMD` would probably be better. +/** + - Note: These are probably not the most optimal, especially in the bigger SIMD types. I haven't yet figured out how to do an equality within a given tolerance across all values simultaneously, so for now it's just an early return if anything isn't approximately equal. */ -extension SIMD2: EquatableEnough, Comparable where Scalar: FloatingPointInitializable & EquatableEnough { - - /// Declares whether or not another `SIMD2` is equal to `self` within a given tolerance (epsilon). - @inlinable public func approximatelyEqual(to other: Self, epsilon: Scalar = .epsilon) -> Bool { - return self.indices.reduce(true) { - return $0 && self[$1].approximatelyEqual(to: other[$1], epsilon: epsilon) - } - } - - /// Returns whether or not all values of `lhs` (individually, sequentially) are less than all values of `rhs` (following the same ordering as `lhs`). - @inlinable public static func < (lhs: Self, rhs: Self) -> Bool { - return all(lhs .< rhs) - } - -} - - -extension SIMD3: EquatableEnough, Comparable where Scalar: FloatingPointInitializable & EquatableEnough { - - /// Declares whether or not another `SIMD3` is equal to `self` within a given tolerance (epsilon). - @inlinable public func approximatelyEqual(to other: Self, epsilon: Scalar = .epsilon) -> Bool { - return self.indices.reduce(true) { - return $0 && self[$1].approximatelyEqual(to: other[$1], epsilon: epsilon) - } - } - - /// Returns whether or not all values of `lhs` (individually, sequentially) are less than all values of `rhs` (following the same ordering as `lhs`). - @inlinable public static func < (lhs: Self, rhs: Self) -> Bool { - return all(lhs .< rhs) - } - -} - -extension SIMD4: EquatableEnough, Comparable where Scalar: FloatingPointInitializable & EquatableEnough { - - /// Declares whether or not another `SIMD4` is equal to `self` within a given tolerance (epsilon). - @inlinable public func approximatelyEqual(to other: Self, epsilon: Scalar = .epsilon) -> Bool { - return self.indices.reduce(true) { - return $0 && self[$1].approximatelyEqual(to: other[$1], epsilon: epsilon) - } - } - - /// Returns whether or not all values of `lhs` (individually, sequentially) are less than all values of `rhs` (following the same ordering as `lhs`). - @inlinable public static func < (lhs: Self, rhs: Self) -> Bool { - return all(lhs .< rhs) - } - -} - -extension SIMD8: EquatableEnough, Comparable where Scalar: FloatingPointInitializable & EquatableEnough { - - /// Declares whether or not another `SIMD8` is equal to `self` within a given tolerance (epsilon). - @inlinable public func approximatelyEqual(to other: Self, epsilon: Scalar = .epsilon) -> Bool { - return self.indices.reduce(true) { - return $0 && self[$1].approximatelyEqual(to: other[$1], epsilon: epsilon) - } - } - - /// Returns whether or not all values of `lhs` (individually, sequentially) are less than all values of `rhs` (following the same ordering as `lhs`). - @inlinable public static func < (lhs: Self, rhs: Self) -> Bool { - return all(lhs .< rhs) - } - -} - -extension SIMD16: EquatableEnough, Comparable where Scalar: FloatingPointInitializable & EquatableEnough { - - /// Declares whether or not another `SIMD16` is equal to `self` within a given tolerance (epsilon). - @inlinable public func approximatelyEqual(to other: Self, epsilon: Scalar = .epsilon) -> Bool { - return self.indices.reduce(true) { - return $0 && self[$1].approximatelyEqual(to: other[$1], epsilon: epsilon) +extension SupportedSIMD where Self: EquatableEnough & Comparable, Scalar: SupportedScalar { + + @inlinable public func isApproximatelyEqual(to other: Self, epsilon: Scalar = .epsilon) -> Bool { + for i in 0.. Bool { - return all(lhs .< rhs) - } - -} - -extension SIMD32: EquatableEnough, Comparable where Scalar: FloatingPointInitializable & EquatableEnough { - - /// Declares whether or not another `SIMD32` is equal to `self` within a given tolerance (epsilon). - @inlinable public func approximatelyEqual(to other: Self, epsilon: Scalar = .epsilon) -> Bool { - return self.indices.reduce(true) { - return $0 && self[$1].approximatelyEqual(to: other[$1], epsilon: epsilon) - } - } - - /// Returns whether or not all values of `lhs` (individually, sequentially) are less than all values of `rhs` (following the same ordering as `lhs`). - @inlinable public static func < (lhs: Self, rhs: Self) -> Bool { - return all(lhs .< rhs) - } - -} - -extension SIMD64: EquatableEnough, Comparable where Scalar: FloatingPointInitializable & EquatableEnough { - - /// Declares whether or not another `SIMD64` is equal to `self` within a given tolerance (epsilon). - @inlinable public func approximatelyEqual(to other: Self, epsilon: Scalar = .epsilon) -> Bool { - return self.indices.reduce(true) { - return $0 && self[$1].approximatelyEqual(to: other[$1], epsilon: epsilon) - } + return true } /// Returns whether or not all values of `lhs` (individually, sequentially) are less than all values of `rhs` (following the same ordering as `lhs`). diff --git a/Sources/Motion/Protocols/SIMDRepresentable.swift b/Sources/Motion/Protocols/SIMDRepresentable.swift index 7ae46fe7..361830ba 100644 --- a/Sources/Motion/Protocols/SIMDRepresentable.swift +++ b/Sources/Motion/Protocols/SIMDRepresentable.swift @@ -23,19 +23,20 @@ public protocol SupportedScalar: SIMDScalar, FloatingPointInitializable, Equatab static func sin(_ x: Self) -> Self static func cos(_ x: Self) -> Self static func pow(_ x: Self, _ n: Int) -> Self + static func log(_ x: Self) -> Self } extension Float: SupportedScalar {} extension Double: SupportedScalar {} -extension SIMD2: SupportedSIMD where Scalar: SupportedScalar {} -extension SIMD3: SupportedSIMD where Scalar: SupportedScalar {} -extension SIMD4: SupportedSIMD where Scalar: SupportedScalar {} -extension SIMD8: SupportedSIMD where Scalar: SupportedScalar {} -extension SIMD16: SupportedSIMD where Scalar: SupportedScalar {} -extension SIMD32: SupportedSIMD where Scalar: SupportedScalar {} -extension SIMD64: SupportedSIMD where Scalar: SupportedScalar {} +extension SIMD2: SupportedSIMD, Comparable, EquatableEnough where Scalar: SupportedScalar {} +extension SIMD3: SupportedSIMD, Comparable, EquatableEnough where Scalar: SupportedScalar {} +extension SIMD4: SupportedSIMD, Comparable, EquatableEnough where Scalar: SupportedScalar {} +extension SIMD8: SupportedSIMD, Comparable, EquatableEnough where Scalar: SupportedScalar {} +extension SIMD16: SupportedSIMD, Comparable, EquatableEnough where Scalar: SupportedScalar {} +extension SIMD32: SupportedSIMD, Comparable, EquatableEnough where Scalar: SupportedScalar {} +extension SIMD64: SupportedSIMD, Comparable, EquatableEnough where Scalar: SupportedScalar {} // MARK: - SIMDRepresentable diff --git a/Sources/Motion/Utilities/AnimationDriver.swift b/Sources/Motion/Utilities/AnimationDriver.swift index f29b36cd..841ded1c 100644 --- a/Sources/Motion/Utilities/AnimationDriver.swift +++ b/Sources/Motion/Utilities/AnimationDriver.swift @@ -15,6 +15,10 @@ protocol AnimationDriverObserver: AnyObject { func tick(frame: AnimationFrame) } +#if os(visionOS) +fileprivate let MaxFPSVisionOS = 90 // TODO: Hardcoded until I find a better API to query. +#endif + #if canImport(UIKit) import UIKit @@ -39,20 +43,26 @@ final class CoreAnimationDriver: AnimationDriver { // Find the first connected scene that's a UIWindowScene and is active, then find the highest supported refresh rate. let connectedScenes = UIApplication.shared.connectedScenes let windowScene = connectedScenes.first { ($0 as? UIWindowScene)?.activationState == .foregroundActive } as? UIWindowScene + + #if os(visionOS) + let maxFPS = Float(MaxFPSVisionOS) // TODO: Hardcoded until I find a better API to query. + #else let maxFPS = Float(windowScene?.windows.map { $0.screen.maximumFramesPerSecond }.max() ?? 60) + #endif /** If we've got a high refresh display, we can use 80 as a minimum. https://developer.apple.com/documentation/quartzcore/optimizing_promotion_refresh_rates_for_iphone_13_pro_and_ipad_pro - - Note: We choose 80 as a minimum to be considered high refresh rate, since some devices will erronously report 61fps as a maximum (see: https://github.com/b3ll/Motion/issues/25) + - Note: We choose 80 as a minimum to be considered high refresh rate, since some devices will erronously report 61fps as a maximum (see: https://github.com/b3ll/Motion/issues/25). We also have the guard against the minimum being below the maximum because tvOS can run at less than 60fps. */ - let adjustedMinFPS: Float = maxFPS > 80.0 ? 80.0 : 60.0 - + let baseMinFPS: Float = maxFPS > 80.0 ? 80.0 : 60.0 + let adjustedMinFPS = min(baseMinFPS, maxFPS) + return CAFrameRateRange(minimum: adjustedMinFPS, maximum: maxFPS, preferred: maxFPS) } - init?() { + init?(environment: AnimationEnvironment) { displayLink = CADisplayLink(target: self, selector: #selector(tick)) displayLink.add(to: .main, forMode: .common) if #available(iOS 15.0, tvOS 15.0, *) { @@ -79,7 +89,11 @@ final class CoreAnimationDriver: AnimationDriver { var preferredFramesPerSecond: Int { let fps = displayLink.preferredFramesPerSecond if fps == 0 { + #if os(visionOS) + return MaxFPSVisionOS + #else return UIScreen.main.maximumFramesPerSecond + #endif } else { return fps } @@ -108,10 +122,21 @@ final class CoreVideoDriver: AnimationDriver { private var displaylink: CVDisplayLink! private var nextFrame: Synchronized = .init(data: nil) - init?() { + let preferredFramesPerSecond: Int + + init?(environment: AnimationEnvironment) { + self.preferredFramesPerSecond = environment.preferredFramesPerSecond var displayLinkRef: CVDisplayLink? = nil - var successLink = CVDisplayLinkCreateWithActiveCGDisplays(&displayLinkRef) - + var successLink: CVReturn + + if let displayID = environment.displayID { + successLink = CVDisplayLinkCreateWithCGDisplay(displayID, &displayLinkRef) + } else { + // Should this be a precondition instead? 🤔 + assertionFailure("CoreVideoDriver needs environment to provide a valid CGDirectDisplayID") + successLink = CVDisplayLinkCreateWithActiveCGDisplays(&displayLinkRef) + } + if let displaylink = displayLinkRef { successLink = CVDisplayLinkSetOutputCallback(displaylink, { (displaylink, currentTime, outputTime, _, _, context) -> CVReturn in if let context = context { @@ -148,8 +173,6 @@ final class CoreVideoDriver: AnimationDriver { deinit { isPaused = true } - - let preferredFramesPerSecond: Int = 60 weak var observer: AnimationDriverObserver? diff --git a/Sources/Motion/Utilities/AnimationEnvironmentProxy.swift b/Sources/Motion/Utilities/AnimationEnvironmentProxy.swift new file mode 100644 index 00000000..add6edb0 --- /dev/null +++ b/Sources/Motion/Utilities/AnimationEnvironmentProxy.swift @@ -0,0 +1,99 @@ +#if os(macOS) + +import AppKit + +/** + Protocol adopted by types that implement ``AnimationEnvironment`` by leveraging another type's implementation. + + Motion extends common AppKit interface elements so that they can be used as animation environments: + + - `NSView`/`NSViewController` + - `NSWindow`/`NSWindowController` + - `NSApplication` + + The AppKit types above are listed from most to least specific. + Prefer to use the most specific environment whenever possible to ensure the best results for your animations. + + For example, if you're configuring an animation inside of a view controller, you may use the view controller itself + as the environment parameter for your animation: + + ```swift + override func viewDidLoad() { + super.viewDidLoad() + + let anim = BasicAnimation(environment: self) + // ... + } + ``` + + - Note: These extensions will crash in debug builds when attempting to start an animation before the environment can be resolved, + such as when an animation using an `NSView` as its environment starts before the view is in a window. In the view controller example above, + it wouldn't be safe to start the animation in `viewDidLoad`. Generally, a safe place to start an animation tied to a view or view controller + is any time after `viewDidAppear` and before `viewDidDisappear`, which is when the view is in a window, and its window is on screen. + */ +public protocol AnimationEnvironmentProxy: AnimationEnvironment { + var proxiedAnimationEnvironment: AnimationEnvironment { get } +} + +public extension AnimationEnvironmentProxy { + var displayID: CGDirectDisplayID? { proxiedAnimationEnvironment.displayID } + var preferredFramesPerSecond: Int { proxiedAnimationEnvironment.preferredFramesPerSecond } + var animator: Animator { proxiedAnimationEnvironment.animator } +} + +extension NSWindow: AnimationEnvironmentProxy { + public var proxiedAnimationEnvironment: AnimationEnvironment { + guard let screen else { + assertionFailure("Motion can't run an animation on window \(self) before it's on screen") + return DefaultAnimationEnvironment.shared + } + return screen + } +} + +extension NSView: AnimationEnvironmentProxy { + public var proxiedAnimationEnvironment: AnimationEnvironment { + guard let window else { + assertionFailure("Motion can't run an animation on view \(self) before it's in a window") + return DefaultAnimationEnvironment.shared + } + return window + } +} + +extension NSViewController: AnimationEnvironmentProxy { + public var proxiedAnimationEnvironment: AnimationEnvironment { + guard isViewLoaded else { + assertionFailure("Motion can't run an animation on view controller \(self) before its view has been loaded") + return DefaultAnimationEnvironment.shared + } + return view + } +} + +extension NSWindowController: AnimationEnvironmentProxy { + public var proxiedAnimationEnvironment: AnimationEnvironment { + guard isWindowLoaded, let window else { + assertionFailure("Motion can't run an animation on window controller \(self) before its window is available") + return DefaultAnimationEnvironment.shared + } + return window + } +} + +extension NSApplication: AnimationEnvironmentProxy { + public var proxiedAnimationEnvironment: AnimationEnvironment { + if let keyWindow { + return keyWindow + } else if let mainWindow { + return mainWindow + } else if let visibleWindow = windows.first(where: { $0.isVisible }) { + return visibleWindow + } else { + assertionFailure("Motion can't run an animation on application \(self) without any visible window") + return DefaultAnimationEnvironment.shared + } + } +} + +#endif diff --git a/Sources/Motion/Utilities/Animator.swift b/Sources/Motion/Utilities/Animator.swift index 0b3e305b..ad71dbcf 100644 --- a/Sources/Motion/Utilities/Animator.swift +++ b/Sources/Motion/Utilities/Animator.swift @@ -19,7 +19,7 @@ public class Animator: NSObject, AnimationDriverObserver { if let _animationDriverStore = _animationDriverStore { return _animationDriverStore } - _animationDriverStore = SystemAnimationDriver() + _animationDriverStore = SystemAnimationDriver(environment: environment ?? .default) _animationDriverStore?.observer = self return _animationDriverStore } @@ -28,7 +28,17 @@ public class Animator: NSObject, AnimationDriverObserver { private var _animationDriverStore: AnimationDriver? internal var runningAnimations: NSHashTable = .weakObjects() - #if os(iOS) + #if os(macOS) + private weak var environment: AnimationEnvironment? + #else + private weak var environment: AnimationEnvironment? + #endif + + internal init(environment: AnimationEnvironment) { + self.environment = environment + } + + #if canImport(UIKit) @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) /** The preferred frame rate range for animations being run. @@ -64,15 +74,13 @@ public class Animator: NSObject, AnimationDriverObserver { } } #else - var preferredFramesPerSecond: Int { - let defaultFPS = 60 - - return defaultFPS - } + var preferredFramesPerSecond: Int { environment?.preferredFramesPerSecond ?? DefaultAnimationEnvironment.shared.preferredFramesPerSecond } #endif + #if canImport(UIKit) // The shared animator that runs all of Motion's animations. - public static let shared = Animator() + public static let shared = Animator(environment: .default) + #endif // MARK: - Animations @@ -92,6 +100,7 @@ public class Animator: NSObject, AnimationDriverObserver { internal func unobserve(_ animation: Animation) { runningAnimations.remove(animation) + animationDriver?.isPaused = runningAnimations.count == 0 animation.enabledDidChange = { _ in } } diff --git a/Sources/Motion/Utilities/CAKeyframeAnimationEmittable.swift b/Sources/Motion/Utilities/CAKeyframeAnimationEmittable.swift index 397a5428..f14b3c7c 100644 --- a/Sources/Motion/Utilities/CAKeyframeAnimationEmittable.swift +++ b/Sources/Motion/Utilities/CAKeyframeAnimationEmittable.swift @@ -61,7 +61,7 @@ extension CAKeyframeAnimationEmittable { if let framerate = framerate { dt = 1.0 / TimeInterval(framerate) } else { - dt = 1.0 / TimeInterval(Animator.shared.preferredFramesPerSecond) + dt = 1.0 / TimeInterval(environment?.animator.preferredFramesPerSecond ?? 60) } var values = [AnyObject]() @@ -87,12 +87,13 @@ extension SpringAnimation: CAKeyframeAnimationEmittable where Value: CAKeyframeA public func populateKeyframeAnimationData(dt: TimeInterval, values: inout [AnyObject], keyTimes: inout [NSNumber]) -> TimeInterval { var velocity = _velocity var value = _value + var previousValueDelta: Value.SIMDType? = nil var t = 0.0 var hasResolved = false while !hasResolved { - tickOptimized(Value.SIMDType.Scalar(dt), spring: &spring, value: &value, toValue: &_toValue, velocity: &velocity, clampingRange: &_clampingRange) - let resolvedState = self.hasResolved(value: &value, epsilon: &resolvingEpsilon, toValue: &_toValue, velocity: &velocity) + tickOptimized(Value.SIMDType.Scalar(dt), spring: spring, value: &value, toValue: &_toValue, velocity: &velocity, clampingRange: &_clampingRange) + let resolvedState = self.hasResolved(value: &value, epsilon: &resolvingEpsilon, toValue: &_toValue, velocity: &velocity, previousValueDelta: &previousValueDelta) if resolvesUponReachingToValue { hasResolved = resolvedState.valueResolved @@ -109,6 +110,7 @@ extension SpringAnimation: CAKeyframeAnimationEmittable where Value: CAKeyframeA values.append(toValue.toKeyframeValue()) keyTimes.append(t as NSNumber) + t += dt return t } @@ -127,7 +129,7 @@ extension DecayAnimation: CAKeyframeAnimationEmittable where Value: CAKeyframeAn var t = 0.0 var hasResolved = false while !hasResolved { - tickOptimized(Value.SIMDType.Scalar(dt), decay: &decay, value: &value, velocity: &velocity) + tickOptimized(Value.SIMDType.Scalar(dt), decay: decay, value: &value, velocity: &velocity) hasResolved = self.hasResolved(velocity: &velocity) let nsValue = Value(value).toKeyframeValue() @@ -137,7 +139,9 @@ extension DecayAnimation: CAKeyframeAnimationEmittable where Value: CAKeyframeAn t += dt } - t -= dt + values.append(toValue.toKeyframeValue()) + keyTimes.append(t as NSNumber) + t += dt return t } @@ -156,7 +160,7 @@ extension BasicAnimation: CAKeyframeAnimationEmittable where Value: CAKeyframeAn var t = 0.0 var hasResolved = false while !hasResolved { - tickOptimized(easingFunction: &easingFunction, range: &_range, fraction: Value.SIMDType.Scalar(t / duration), value: &value) + tickOptimized(easingFunction: easingFunction, range: &_range, fraction: Value.SIMDType.Scalar(t / duration), value: &value) hasResolved = self.hasResolved(value: &value, epsilon: &resolvingEpsilon, toValue: &_toValue) let nsValue = Value(value).toKeyframeValue() diff --git a/Sources/Motion/Utilities/NSScreen+AnimationEnvironment.swift b/Sources/Motion/Utilities/NSScreen+AnimationEnvironment.swift new file mode 100644 index 00000000..0fd1832e --- /dev/null +++ b/Sources/Motion/Utilities/NSScreen+AnimationEnvironment.swift @@ -0,0 +1,57 @@ +#if os(macOS) +import AppKit + +public final class DefaultAnimationEnvironment: NSScreen { + public static var shared: AnimationEnvironment { + guard let screen = NSScreen.main ?? NSScreen.screens.first else { + fatalError("Motion can't run because this Mac doesn't have a screen") + } + return screen + } +} + +extension NSScreen: AnimationEnvironment { + private var environmentStorage: AnimationEnvironmentStorage { AnimationEnvironmentStorage.shared } + + public var animator: Animator { environmentStorage.animator(for: self) } + + public var displayID: CGDirectDisplayID? { + guard let number = deviceDescription[.screenNumber] as? NSNumber else { + assertionFailure("NSScreenNumber for \(self) is not an NSNumber") + return nil + } + return number.uint32Value + } + + public var preferredFramesPerSecond: Int { + guard #available(macOS 12.0, *) else { return 60 } + let fps = maximumFramesPerSecond + guard fps > 0 else { return 60 } + return fps + } +} + +private extension NSDeviceDescriptionKey { + static let screenNumber = NSDeviceDescriptionKey("NSScreenNumber") +} + +private final class AnimationEnvironmentStorage { + static let shared = AnimationEnvironmentStorage() + + private var animatorsByDisplayID = [CGDirectDisplayID: Animator]() + + func animator(for screen: NSScreen) -> Animator { + guard let displayID = screen.displayID else { + fatalError("Motion failed to get the CGDirectDisplayID for screen \(screen)") + } + + if let existingAnimator = animatorsByDisplayID[displayID] { + return existingAnimator + } else { + let newAnimator = Animator(environment: screen) + animatorsByDisplayID[displayID] = newAnimator + return newAnimator + } + } +} +#endif diff --git a/Tests/MotionTests/BasicAnimationTests.swift b/Tests/MotionTests/BasicAnimationTests.swift index 2805c917..e3ea0a16 100644 --- a/Tests/MotionTests/BasicAnimationTests.swift +++ b/Tests/MotionTests/BasicAnimationTests.swift @@ -12,10 +12,10 @@ final class BasicAnimationTests: XCTestCase { let range: ClosedRange = 0.0...10.0 let startValue = easeIn.solveInterpolatedValue(range, fraction: 0.0) - XCTAssert(startValue.approximatelyEqual(to: range.lowerBound)) + XCTAssert(startValue.isApproximatelyEqual(to: range.lowerBound)) let endValue = easeIn.solveInterpolatedValue(range, fraction: 1.0) - XCTAssert(endValue.approximatelyEqual(to: range.upperBound)) + XCTAssert(endValue.isApproximatelyEqual(to: range.upperBound)) } func testBasicAnimationStartStop() { @@ -96,7 +96,7 @@ final class BasicAnimationTests: XCTestCase { let timeAccumulatedDeterminedFromValue = basicAnimation.accumulatedTime - XCTAssertTrue(timeAccumulated.approximatelyEqual(to: timeAccumulatedDeterminedFromValue)) + XCTAssertTrue(timeAccumulated.isApproximatelyEqual(to: timeAccumulatedDeterminedFromValue)) let expectBasicAnimationCompletionCalled = XCTestExpectation(description: "Basic animation completed") basicAnimation.completion = { @@ -116,12 +116,12 @@ final class BasicAnimationTests: XCTestCase { basicAnimation.toValue = 10.0 basicAnimation.duration = 1.0 - let keyframeAnimation = basicAnimation.keyframeAnimation() + let keyframeAnimation = basicAnimation.keyframeAnimation(forFramerate: 60) XCTAssertEqual(keyframeAnimation.calculationMode, .discrete) XCTAssertFalse(keyframeAnimation.values?.isEmpty ?? true) XCTAssertFalse(keyframeAnimation.keyTimes?.isEmpty ?? true) - XCTAssertTrue(keyframeAnimation.duration.approximatelyEqual(to: 1.0)) + XCTAssertTrue(keyframeAnimation.duration.isApproximatelyEqual(to: 1.0)) } override class func tearDown() { @@ -130,3 +130,14 @@ final class BasicAnimationTests: XCTestCase { } } + +#if os(macOS) +extension Animator { + static let shared: Animator = { + guard let screen = NSScreen.main ?? NSScreen.screens.first else { + fatalError("Motion's tests can't run because this Mac doesn't have any screens!") + } + return screen.animator + }() +} +#endif diff --git a/Tests/MotionTests/DecayAnimationTests.swift b/Tests/MotionTests/DecayAnimationTests.swift index bc7b51d9..c2dc72d6 100644 --- a/Tests/MotionTests/DecayAnimationTests.swift +++ b/Tests/MotionTests/DecayAnimationTests.swift @@ -78,18 +78,62 @@ final class DecayAnimationTests: XCTestCase { wait(for: [expectCompletionCalled, expectDecayVelocityZero], timeout: 0.0) } + func testDecayToValueCalculation() { + let decay = DecayAnimation() + decay.velocity = 2000.0 + + XCTAssertTrue(decay.toValue.isApproximatelyEqual(to: 998.999)) + XCTAssertTrue(decay._toValue.isApproximatelyEqual(to: SIMD2(998.999, 0.0))) + + let decay2 = DecayAnimation() + decay2.velocity = CGPoint(x: 1000.0, y: 2000.0) + + XCTAssertTrue(decay2.toValue.x.isApproximatelyEqual(to: 499.499) && decay2.toValue.y.isApproximatelyEqual(to: 998.999)) + XCTAssertTrue(decay2._toValue.isApproximatelyEqual(to: SIMD2(499.499, 998.999))) + } + + func testDecayVelocityCalculation() { + let decay = DecayAnimation() + decay.toValue = 998.999 + + XCTAssertTrue(decay.velocity.isApproximatelyEqual(to: 2000.0, epsilon: 0.01)) + XCTAssertTrue(decay._velocity.isApproximatelyEqual(to: SIMD2(2000.0, 0.0), epsilon: 0.01)) + + let decay2 = DecayAnimation() + decay2.toValue = CGPoint(x: 499.499, y: 998.999) + + XCTAssertTrue(decay2.velocity.x.isApproximatelyEqual(to: 1000.0, epsilon: 0.01) && decay2.velocity.y.isApproximatelyEqual(to: 2000.0, epsilon: 0.01)) + XCTAssertTrue(decay2._velocity.isApproximatelyEqual(to: SIMD2(1000.0, 2000.0), epsilon: 0.01)) + } + + func testDecayRoundingFactorApplication() { + let decay = DecayAnimation() + decay.roundingFactor = 1.0 + decay.velocity = 200.0 + + XCTAssertTrue(decay.velocity.isApproximatelyEqual(to: 200.2, epsilon: 0.001)) + XCTAssertTrue(decay.toValue.isApproximatelyEqual(to: 100.0, epsilon: 0.001)) + + let decay2 = DecayAnimation() + decay2.roundingFactor = 1.0 / 3.0 // i.e. 3x device + decay2.velocity = 200.90 + + XCTAssertTrue(decay2.velocity.isApproximatelyEqual(to: 200.867, epsilon: 0.001)) + XCTAssertTrue(decay2.toValue.isApproximatelyEqual(to: 100.333, epsilon: 0.001)) + } + // MARK: - CAKeyframeAnimationEmittable Tests func testCreateCAKeyframeAnimationFromDecayAnimation() { let decay = DecayAnimation() decay.velocity = 2000.0 - let keyframeAnimation = decay.keyframeAnimation() + let keyframeAnimation = decay.keyframeAnimation(forFramerate: 60) XCTAssertEqual(keyframeAnimation.calculationMode, .discrete) XCTAssertFalse(keyframeAnimation.values?.isEmpty ?? true) XCTAssertFalse(keyframeAnimation.keyTimes?.isEmpty ?? true) - XCTAssertTrue(keyframeAnimation.duration.approximatelyEqual(to: 4.133)) + XCTAssertTrue(keyframeAnimation.duration.isApproximatelyEqual(to: 4.166)) } override class func tearDown() { diff --git a/Tests/MotionTests/MotionTests.swift b/Tests/MotionTests/MotionTests.swift index ac4fced1..69be6921 100644 --- a/Tests/MotionTests/MotionTests.swift +++ b/Tests/MotionTests/MotionTests.swift @@ -11,36 +11,36 @@ final class MotionTests: XCTestCase { let equalValue1 = 0.001 let equalValue2 = 0.001 - XCTAssert(equalValue1.approximatelyEqual(to: equalValue2) && (equalValue1 == equalValue2)) + XCTAssert(equalValue1.isApproximatelyEqual(to: equalValue2) && (equalValue1 == equalValue2)) // Mostly Equal let equalishValue3 = 0.0011 - XCTAssert(equalValue1.approximatelyEqual(to: equalishValue3)) + XCTAssert(equalValue1.isApproximatelyEqual(to: equalishValue3)) // Gotta love copy paste. // Would be neat to iterate over all the types, but I'm unsure how to do that. - XCTAssert(SIMD2(repeating: equalValue1).approximatelyEqual(to: SIMD2(repeating: equalishValue3))) - XCTAssert(SIMD3(repeating: equalValue1).approximatelyEqual(to: SIMD3(repeating: equalishValue3))) - XCTAssert(SIMD4(repeating: equalValue1).approximatelyEqual(to: SIMD4(repeating: equalishValue3))) - XCTAssert(SIMD8(repeating: equalValue1).approximatelyEqual(to: SIMD8(repeating: equalishValue3))) - XCTAssert(SIMD16(repeating: equalValue1).approximatelyEqual(to: SIMD16(repeating: equalishValue3))) - XCTAssert(SIMD32(repeating: equalValue1).approximatelyEqual(to: SIMD32(repeating: equalishValue3))) - XCTAssert(SIMD64(repeating: equalValue1).approximatelyEqual(to: SIMD64(repeating: equalishValue3))) + XCTAssert(SIMD2(repeating: equalValue1).isApproximatelyEqual(to: SIMD2(repeating: equalishValue3))) + XCTAssert(SIMD3(repeating: equalValue1).isApproximatelyEqual(to: SIMD3(repeating: equalishValue3))) + XCTAssert(SIMD4(repeating: equalValue1).isApproximatelyEqual(to: SIMD4(repeating: equalishValue3))) + XCTAssert(SIMD8(repeating: equalValue1).isApproximatelyEqual(to: SIMD8(repeating: equalishValue3))) + XCTAssert(SIMD16(repeating: equalValue1).isApproximatelyEqual(to: SIMD16(repeating: equalishValue3))) + XCTAssert(SIMD32(repeating: equalValue1).isApproximatelyEqual(to: SIMD32(repeating: equalishValue3))) + XCTAssert(SIMD64(repeating: equalValue1).isApproximatelyEqual(to: SIMD64(repeating: equalishValue3))) // Not Equal let nonEqualValue1 = 0.001 - let nonEqualValue2 = 0.002 + let nonEqualValue2 = 0.0021 - XCTAssertFalse(nonEqualValue1.approximatelyEqual(to: nonEqualValue2)) + XCTAssertFalse(nonEqualValue1.isApproximatelyEqual(to: nonEqualValue2)) - XCTAssertFalse(SIMD2(repeating: nonEqualValue1).approximatelyEqual(to: SIMD2(repeating: nonEqualValue2))) - XCTAssertFalse(SIMD3(repeating: nonEqualValue1).approximatelyEqual(to: SIMD3(repeating: nonEqualValue2))) - XCTAssertFalse(SIMD4(repeating: nonEqualValue1).approximatelyEqual(to: SIMD4(repeating: nonEqualValue2))) - XCTAssertFalse(SIMD8(repeating: nonEqualValue1).approximatelyEqual(to: SIMD8(repeating: nonEqualValue2))) - XCTAssertFalse(SIMD16(repeating: nonEqualValue1).approximatelyEqual(to: SIMD16(repeating: nonEqualValue2))) - XCTAssertFalse(SIMD32(repeating: nonEqualValue1).approximatelyEqual(to: SIMD32(repeating: nonEqualValue2))) - XCTAssertFalse(SIMD64(repeating: nonEqualValue1).approximatelyEqual(to: SIMD64(repeating: nonEqualValue2))) + XCTAssertFalse(SIMD2(repeating: nonEqualValue1).isApproximatelyEqual(to: SIMD2(repeating: nonEqualValue2))) + XCTAssertFalse(SIMD3(repeating: nonEqualValue1).isApproximatelyEqual(to: SIMD3(repeating: nonEqualValue2))) + XCTAssertFalse(SIMD4(repeating: nonEqualValue1).isApproximatelyEqual(to: SIMD4(repeating: nonEqualValue2))) + XCTAssertFalse(SIMD8(repeating: nonEqualValue1).isApproximatelyEqual(to: SIMD8(repeating: nonEqualValue2))) + XCTAssertFalse(SIMD16(repeating: nonEqualValue1).isApproximatelyEqual(to: SIMD16(repeating: nonEqualValue2))) + XCTAssertFalse(SIMD32(repeating: nonEqualValue1).isApproximatelyEqual(to: SIMD32(repeating: nonEqualValue2))) + XCTAssertFalse(SIMD64(repeating: nonEqualValue1).isApproximatelyEqual(to: SIMD64(repeating: nonEqualValue2))) // Less than XCTAssert(SIMD2(repeating: nonEqualValue1) < SIMD2(repeating: nonEqualValue2)) @@ -56,15 +56,15 @@ final class MotionTests: XCTestCase { let nonEqualValue4 = 0.002 let epsilon = 0.01 - XCTAssertTrue(nonEqualValue3.approximatelyEqual(to: nonEqualValue4, epsilon: epsilon)) + XCTAssertTrue(nonEqualValue3.isApproximatelyEqual(to: nonEqualValue4, epsilon: epsilon)) - XCTAssertTrue(SIMD2(repeating: nonEqualValue3).approximatelyEqual(to: SIMD2(repeating: nonEqualValue4), epsilon: epsilon)) - XCTAssertTrue(SIMD3(repeating: nonEqualValue3).approximatelyEqual(to: SIMD3(repeating: nonEqualValue4), epsilon: epsilon)) - XCTAssertTrue(SIMD4(repeating: nonEqualValue3).approximatelyEqual(to: SIMD4(repeating: nonEqualValue4), epsilon: epsilon)) - XCTAssertTrue(SIMD8(repeating: nonEqualValue3).approximatelyEqual(to: SIMD8(repeating: nonEqualValue4), epsilon: epsilon)) - XCTAssertTrue(SIMD16(repeating: nonEqualValue3).approximatelyEqual(to: SIMD16(repeating: nonEqualValue4), epsilon: epsilon)) - XCTAssertTrue(SIMD32(repeating: nonEqualValue3).approximatelyEqual(to: SIMD32(repeating: nonEqualValue4), epsilon: epsilon)) - XCTAssertTrue(SIMD64(repeating: nonEqualValue3).approximatelyEqual(to: SIMD64(repeating: nonEqualValue4), epsilon: epsilon)) + XCTAssertTrue(SIMD2(repeating: nonEqualValue3).isApproximatelyEqual(to: SIMD2(repeating: nonEqualValue4), epsilon: epsilon)) + XCTAssertTrue(SIMD3(repeating: nonEqualValue3).isApproximatelyEqual(to: SIMD3(repeating: nonEqualValue4), epsilon: epsilon)) + XCTAssertTrue(SIMD4(repeating: nonEqualValue3).isApproximatelyEqual(to: SIMD4(repeating: nonEqualValue4), epsilon: epsilon)) + XCTAssertTrue(SIMD8(repeating: nonEqualValue3).isApproximatelyEqual(to: SIMD8(repeating: nonEqualValue4), epsilon: epsilon)) + XCTAssertTrue(SIMD16(repeating: nonEqualValue3).isApproximatelyEqual(to: SIMD16(repeating: nonEqualValue4), epsilon: epsilon)) + XCTAssertTrue(SIMD32(repeating: nonEqualValue3).isApproximatelyEqual(to: SIMD32(repeating: nonEqualValue4), epsilon: epsilon)) + XCTAssertTrue(SIMD64(repeating: nonEqualValue3).isApproximatelyEqual(to: SIMD64(repeating: nonEqualValue4), epsilon: epsilon)) } // MARK: - SIMDRepresentable Tests @@ -72,42 +72,42 @@ final class MotionTests: XCTestCase { func testSIMDRepresentableScalarTypes() { let a: Float = 10.0 let simdA = a.simdRepresentation() - XCTAssertTrue(a.approximatelyEqual(to: simdA[0])) - XCTAssertTrue(simdA[1].approximatelyEqual(to: 0.0)) + XCTAssertTrue(a.isApproximatelyEqual(to: simdA[0])) + XCTAssertTrue(simdA[1].isApproximatelyEqual(to: 0.0)) let b: Double = 20.0 let simdB = b.simdRepresentation() - XCTAssertTrue(b.approximatelyEqual(to: simdB[0])) - XCTAssertTrue(simdB[1].approximatelyEqual(to: 0.0)) + XCTAssertTrue(b.isApproximatelyEqual(to: simdB[0])) + XCTAssertTrue(simdB[1].isApproximatelyEqual(to: 0.0)) let c: CGFloat = 30.0 let simdC = c.simdRepresentation() - XCTAssertTrue(c.approximatelyEqual(to: CGFloat(simdC[0]))) - XCTAssertTrue(simdC[1].approximatelyEqual(to: 0.0)) + XCTAssertTrue(c.isApproximatelyEqual(to: CGFloat(simdC[0]))) + XCTAssertTrue(simdC[1].isApproximatelyEqual(to: 0.0)) } func testSIMDRepresentableCoreGraphicsTypes() { let point = CGPoint(x: 10.0, y: 20.0) let simdPoint = point.simdRepresentation() - XCTAssertTrue(point.x.approximatelyEqual(to: CGFloat(simdPoint[0]))) - XCTAssertTrue(point.y.approximatelyEqual(to: CGFloat(simdPoint[1]))) + XCTAssertTrue(point.x.isApproximatelyEqual(to: CGFloat(simdPoint[0]))) + XCTAssertTrue(point.y.isApproximatelyEqual(to: CGFloat(simdPoint[1]))) XCTAssertEqual(point, CGPoint(simdPoint)) let size = CGSize(width: 30.0, height: 40.0) let simdSize = size.simdRepresentation() - XCTAssertTrue(size.width.approximatelyEqual(to: CGFloat(simdSize[0]))) - XCTAssertTrue(size.height.approximatelyEqual(to: CGFloat(simdSize[1]))) + XCTAssertTrue(size.width.isApproximatelyEqual(to: CGFloat(simdSize[0]))) + XCTAssertTrue(size.height.isApproximatelyEqual(to: CGFloat(simdSize[1]))) XCTAssertEqual(size, CGSize(simdSize)) let rect = CGRect(origin: point, size: size) let simdRect = rect.simdRepresentation() - XCTAssertTrue(rect.origin.x.approximatelyEqual(to: CGFloat(simdRect[0]))) - XCTAssertTrue(rect.origin.y.approximatelyEqual(to: CGFloat(simdRect[1]))) - XCTAssertTrue(rect.size.width.approximatelyEqual(to: CGFloat(simdRect[2]))) - XCTAssertTrue(rect.size.height.approximatelyEqual(to: CGFloat(simdRect[3]))) + XCTAssertTrue(rect.origin.x.isApproximatelyEqual(to: CGFloat(simdRect[0]))) + XCTAssertTrue(rect.origin.y.isApproximatelyEqual(to: CGFloat(simdRect[1]))) + XCTAssertTrue(rect.size.width.isApproximatelyEqual(to: CGFloat(simdRect[2]))) + XCTAssertTrue(rect.size.height.isApproximatelyEqual(to: CGFloat(simdRect[3]))) XCTAssertEqual(rect, CGRect(simdRect)) } @@ -204,20 +204,20 @@ final class MotionTests: XCTestCase { let nonRubberbandedValue = rubberband(insideValue, boundsSize: boundsSize, contentSize: contentSize) // Is the value within the clamped range. - XCTAssertTrue(nonRubberbandedValue.approximatelyEqual(to: insideValue)) + XCTAssertTrue(nonRubberbandedValue.isApproximatelyEqual(to: insideValue)) // Does it handle values less than the range. let rubberbandedValueNegative = rubberband(outsideValueNegative, boundsSize: boundsSize, contentSize: contentSize) - XCTAssertTrue(rubberbandedValueNegative.approximatelyEqual(to: -5.213)) + XCTAssertTrue(rubberbandedValueNegative.isApproximatelyEqual(to: -5.213)) // Does it handle values more than the range. let rubberbandedValuePositive = rubberband(outsideValuePositive, boundsSize: boundsSize, contentSize: contentSize) - XCTAssertTrue(rubberbandedValuePositive.approximatelyEqual(to: 105.213 /* value + boundsSize ~= 205.213 */)) + XCTAssertTrue(rubberbandedValuePositive.isApproximatelyEqual(to: 105.213 /* value + boundsSize ~= 205.213 */)) // Does it handle both positive and negative values correctly? let deltaNegative = abs(rubberbandedValueNegative) let deltaPositive = abs(rubberbandedValuePositive - boundsSize) - XCTAssertTrue(deltaNegative.approximatelyEqual(to: deltaPositive)) + XCTAssertTrue(deltaNegative.isApproximatelyEqual(to: deltaPositive)) } override class func tearDown() { diff --git a/Tests/MotionTests/SpringAnimationTests.swift b/Tests/MotionTests/SpringAnimationTests.swift index c8906dd2..97940115 100644 --- a/Tests/MotionTests/SpringAnimationTests.swift +++ b/Tests/MotionTests/SpringAnimationTests.swift @@ -133,13 +133,13 @@ final class SpringAnimationTests: XCTestCase { underDampedSpring.toValue = 10.0 underDampedSpring.tick(frame: f) - XCTAssert(underDampedSpring.value.approximatelyEqual(to: 9.223)) + XCTAssert(underDampedSpring.value.isApproximatelyEqual(to: 9.223)) let criticallyDampedSpring = SpringAnimation(response: 1.0, dampingRatio: 1.0) criticallyDampedSpring.toValue = 10.0 criticallyDampedSpring.tick(frame: f) - XCTAssert(criticallyDampedSpring.value.approximatelyEqual(to: 8.210)) + XCTAssert(criticallyDampedSpring.value.isApproximatelyEqual(to: 8.210)) let overDampedSpring = SpringAnimation(stiffness: 2.0, damping: 10.0) overDampedSpring.toValue = 10.0 @@ -205,6 +205,51 @@ final class SpringAnimationTests: XCTestCase { wait(for: [expectValueChangedCalled, expectCompletionCalled], timeout: 0.0) } + func testSpringResolvesUponReachingToValueCGFloat() { + let spring = SpringAnimation(initialValue: 0.0) + spring.toValue = 10.0 + spring.resolvesUponReachingToValue = true + + let expectCompletionCalled = XCTestExpectation(description: "Spring completed") + + spring.onValueChanged { newValue in + if newValue > 10.0 { + XCTFail("Overshot and didn't resolve upon reaching toValue.") + } + } + spring.completion = { + expectCompletionCalled.fulfill() + } + + tickAnimationForDuration(spring, maxDuration: 10.0) + spring.stop(resolveImmediately: true, postValueChanged: true) + + wait(for: [expectCompletionCalled], timeout: 0.0) + } + + func testSpringResolvesUponReachingToValueSIMD64Double() { + let spring = SpringAnimation(initialValue: SIMD64(repeating: 0.0)) + spring.toValue = .init(repeating: 10.0) + spring.resolvesUponReachingToValue = true + + let expectCompletionCalled = XCTestExpectation(description: "Spring completed") + + spring.onValueChanged { newValue in + let check = newValue .> .init(repeating: 10.0) + if any(check) { + XCTFail("Overshot and didn't resolve upon reaching toValue.") + } + } + spring.completion = { + expectCompletionCalled.fulfill() + } + + tickAnimationForDuration(spring, maxDuration: 10.0) + spring.stop(resolveImmediately: true, postValueChanged: true) + + wait(for: [expectCompletionCalled], timeout: 0.0) + } + // MARK: - CAKeyframeAnimationEmittable Tests func testCreateCAKeyframeAnimationFromSpringAnimation() { @@ -212,12 +257,34 @@ final class SpringAnimationTests: XCTestCase { spring.toValue = CGRect(x: 0, y: 0, width: 320, height: 320) spring.configure(response: 1.0, dampingRatio: 1.0) - let keyframeAnimation = spring.keyframeAnimation() + let keyframeAnimation = spring.keyframeAnimation(forFramerate: 60) XCTAssertEqual(keyframeAnimation.calculationMode, .discrete) XCTAssertFalse(keyframeAnimation.values?.isEmpty ?? true) XCTAssertFalse(keyframeAnimation.keyTimes?.isEmpty ?? true) - XCTAssertTrue(keyframeAnimation.duration.approximatelyEqual(to: 2.383)) + XCTAssertTrue(keyframeAnimation.duration.isApproximatelyEqual(to: 2.3999)) + } + + func testSpringToleratesDegenerateFrameDuration() { + // A display link that restarts (e.g. after sleep/wake) can deliver a non-finite or + // non-positive frame duration. Ticking with one must be a no-op rather than pushing the + // spring into a non-finite state (which traps when the value is read back). + let degenerateDurations: [(timestamp: CFTimeInterval, targetTimestamp: CFTimeInterval)] = [ + (0.0, 0.0), + (100.0, 0.0), + (0.0, .infinity), + (0.0, .nan), + ] + + for duration in degenerateDurations { + let spring = SpringAnimation(response: 1.0, dampingRatio: 0.8) + spring.toValue = 10.0 + spring.tick(frame: AnimationFrame(timestamp: duration.timestamp, targetTimestamp: duration.targetTimestamp)) + + XCTAssertTrue(spring.value.isFinite) + XCTAssertTrue(Double(spring._velocity).isFinite) + XCTAssertEqual(spring.value, 0.0) + } } override class func tearDown() { diff --git a/build_xcframework.sh b/build_xcframework.sh new file mode 100755 index 00000000..94533d62 --- /dev/null +++ b/build_xcframework.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +pushd "$( dirname -- "$0"; )" + +rm -rf build/output +rm -rf Motion.zip + +mkdir -p build + +git clone https://github.com/giginet/Scipio.git build/scipio + +pushd build/scipio + +swift run -c release scipio create ../../ --enable-library-evolution --support-simulators --only-use-versions-from-resolved-file --output ../output --verbose + +zip -r ../../Motion.zip ../output/Motion.xcframework + +popd +popd