diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 326b231..96d4ecc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,6 @@ jobs: steps: - uses: actions/checkout@v3 - name: Build - run: xcodebuild build -scheme 'AnimationPlanner' -destination 'platform=iOS Simulator,name=iPhone 13' + run: xcodebuild build -scheme 'AnimationPlanner' -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14 Pro' - name: Run tests - run: xcodebuild test -scheme 'AnimationPlanner' -destination 'platform=iOS Simulator,name=iPhone 13' + run: xcodebuild test -scheme 'AnimationPlanner' -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14 Pro' diff --git a/Sample App/AnimationPlanner-Sample.xcodeproj/project.pbxproj b/Sample App/AnimationPlanner-Sample.xcodeproj/project.pbxproj index 217fdd8..00fd57a 100644 --- a/Sample App/AnimationPlanner-Sample.xcodeproj/project.pbxproj +++ b/Sample App/AnimationPlanner-Sample.xcodeproj/project.pbxproj @@ -17,7 +17,6 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 3E5C6DA62848C27B00720FE8 /* CAMediaTimingFunction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CAMediaTimingFunction.swift; sourceTree = ""; }; 3EF52D592848AEC7000F8222 /* AnimationPlanner-Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "AnimationPlanner-Sample.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 3EF52D5C2848AEC7000F8222 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 3EF52D5E2848AEC7000F8222 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -72,7 +71,6 @@ 3EF52D5C2848AEC7000F8222 /* AppDelegate.swift */, 3EF52D5E2848AEC7000F8222 /* SceneDelegate.swift */, 3EF52D602848AEC7000F8222 /* ViewController.swift */, - 3E5C6DA62848C27B00720FE8 /* CAMediaTimingFunction.swift */, 3EF52D622848AEC7000F8222 /* Main.storyboard */, 3EF52D652848AEC8000F8222 /* Assets.xcassets */, 3EF52D672848AEC8000F8222 /* LaunchScreen.storyboard */, @@ -120,7 +118,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1340; - LastUpgradeCheck = 1340; + LastUpgradeCheck = 1520; TargetAttributes = { 3EF52D582848AEC7000F8222 = { CreatedOnToolsVersion = 13.4; @@ -227,6 +225,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -287,6 +286,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; diff --git a/Sample App/AnimationPlanner-Sample/ViewController.swift b/Sample App/AnimationPlanner-Sample/ViewController.swift index 146199b..f79d2e2 100644 --- a/Sample App/AnimationPlanner-Sample/ViewController.swift +++ b/Sample App/AnimationPlanner-Sample/ViewController.swift @@ -9,48 +9,64 @@ import UIKit import AnimationPlanner class ViewController: UIViewController { - + lazy var subview: UIView = { let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) view.layer.cornerCurve = .continuous return view }() - override func viewDidLoad() { - super.viewDidLoad() - view.addSubview(subview) - } - + lazy var stopButton = newStopButton() + lazy var resetButton = newResetButton() + + // Sequence currently performing animations + var runningSequence: RunningSequence? + + let testStopping: Bool = true // Set to true to display buttons to stop and reset animations + let performComplexAnimation: Bool = false // Set to true to run a more complex animation - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) + + func performAnimations() { + resetButton.isEnabled = false if performComplexAnimation { - runComplexBulderAnimation() + runComplexBuilderAnimation() } else { runSimpleBuilderAnimation() } } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + performAnimations() + } + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(subview) + + guard testStopping else { + return + } + view.addSubview(stopButton) + view.addSubview(resetButton) + stopButton.translatesAutoresizingMaskIntoConstraints = false + resetButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stopButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + stopButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + stopButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor, constant: -8), + resetButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor, constant: 8), + resetButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + resetButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) + ]) + } } extension ViewController { - func setInitialSubviewState() -> UIView { - subview.alpha = 0 - subview.transform = .identity - subview.frame.size = CGSize(width: 100, height: 100) - subview.center.x = view.bounds.midX - subview.frame.origin.y = view.bounds.minY - subview.backgroundColor = .systemOrange - subview.layer.cornerRadius = 16 - subview.layer.borderWidth = 0 - subview.layer.borderColor = nil - return subview - } - func runSimpleBuilderAnimation() { let view = setInitialSubviewState() - AnimationPlanner.plan { + runningSequence = AnimationPlanner.plan { Wait(0.35) // A delay waits for the given amount of seconds to start the next step Animate(duration: 0.32, timingFunction: .quintOut) { view.alpha = 1 @@ -75,16 +91,18 @@ extension ViewController { view.frame.origin.y = self.view.bounds.maxY }.timingFunction(.circIn) }.onComplete { finished in - // Just to keep the flow going, let‘s run the animation again - self.runSimpleBuilderAnimation() + if finished { + // Just to keep the flow going, let‘s run the animation again + self.runSimpleBuilderAnimation() + } } } - func runComplexBulderAnimation() { + func runComplexBuilderAnimation() { var sneakyCopy: UIView! // Don‘t worry, you‘ll see later - AnimationPlanner.plan { + runningSequence = AnimationPlanner.plan { let quarterHeight = view.bounds.height / 4 let view = setInitialSubviewState() @@ -112,9 +130,9 @@ extension ViewController { let loopCount = 4 - // Adding mulitple steps can be done through the `Loop` struct - // or by adding `.animateLoop { }` to any sequence - Loop.through(1...loopCount) { index in + // Adding multiple steps can be done with a for-in statement + // or by adding `.mapSequence { }` or `.mapGroup { }` to any sequence + for index in 1...loopCount { let offset = CGFloat(index) / CGFloat(loopCount) let reversed = 1 - offset Animate(duration: 0.32) { @@ -183,16 +201,33 @@ extension ViewController { sneakyCopy?.transform = view.transform.translatedBy(x: 0, y: quarterHeight) } }.onComplete { finished in - self.runComplexBulderAnimation() + if finished { + sneakyCopy.removeFromSuperview() + self.runComplexBuilderAnimation() + } } } } extension ViewController { + + func setInitialSubviewState() -> UIView { + subview.alpha = 0 + subview.transform = .identity + subview.frame.size = CGSize(width: 100, height: 100) + subview.center.x = view.bounds.midX + subview.frame.origin.y = view.bounds.minY + subview.backgroundColor = .systemOrange + subview.layer.cornerRadius = 16 + subview.layer.borderWidth = 0 + subview.layer.borderColor = nil + return subview + } + /// Adds a custom shake animation sequence on the provided view /// - Parameter view: View to which the transform should be applied /// - Returns: Animations to be added to the sequence - @AnimationBuilder + @SequenceBuilder func addShakeSequence(shaking view: UIView) -> [SequenceAnimatable] { var baseTransform: CGAffineTransform = .identity @@ -201,7 +236,7 @@ extension ViewController { let values = (0.. UIButton { + var configuration = UIButton.Configuration.filled() + configuration.buttonSize = .large + configuration.baseBackgroundColor = .systemRed + configuration.cornerStyle = .large + configuration.title = "Stop" + return UIButton(configuration: configuration, primaryAction: UIAction { [unowned self] _ in + runningSequence?.stopAnimations() + resetButton.isEnabled = true + }) + } + + func newResetButton() -> UIButton { + var configuration = UIButton.Configuration.filled() + configuration.buttonSize = .large + configuration.baseBackgroundColor = .systemGreen + configuration.cornerStyle = .large + configuration.title = "Reset" + let button = UIButton(configuration: configuration, primaryAction: UIAction { [unowned self] _ in + performAnimations() + }) + button.isEnabled = false + return button + } +} + extension UIView { func sneakyCopy() -> Self? { // 🫣🫣🫣 @@ -223,7 +286,7 @@ extension UIView { let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) unarchiver.requiresSecureCoding = false - + guard let view = unarchiver.decodeObject() as? Self else { return nil } @@ -237,4 +300,3 @@ extension UIView { } } } - diff --git a/Sources/AnimationPlanner/Animations/Animate.swift b/Sources/AnimationPlanner/Animations/Animate.swift index 9d3d739..f22d354 100644 --- a/Sources/AnimationPlanner/Animations/Animate.swift +++ b/Sources/AnimationPlanner/Animations/Animate.swift @@ -9,6 +9,9 @@ public struct Animate: Animation, SequenceAnimatable, GroupAnimatable { public internal(set) var options: UIView.AnimationOptions? public internal(set) var timingFunction: CAMediaTimingFunction? + /// Class that holds stopped state + internal let stopper: Stopper + /// Creates a new animation, animating the properties updated in the ``changes`` closure /// /// Only the `duration` parameter is required, all other properties can be added or modified using ``AnimationModifiers``. @@ -25,9 +28,25 @@ public struct Animate: Animation, SequenceAnimatable, GroupAnimatable { timingFunction: CAMediaTimingFunction? = nil, changes: @escaping () -> Void = {} ) { + let stopper = Stopper() self.duration = duration self.timingFunction = timingFunction - self.changes = changes + self.changes = { [weak stopper] in + guard stopper?.isStopped == false else { + return + } + stopper?.isRunning = true + changes() + } + self.stopper = stopper + } +} + +extension Animate { + internal class Stopper { + var isRunning: Bool = false + var isStopped: Bool = false + var stopHandler: (() -> Void)? } } @@ -56,4 +75,21 @@ extension Animate: PerformsAnimations { createAnimations(completion) } } + + public func stop() { + if stopper.isRunning { + UIView.animate( + withDuration: 0, + delay: 0, + options: [ + .beginFromCurrentState, + .overrideInheritedDuration, + .overrideInheritedOptions + ], + animations: changes + ) + stopper.stopHandler?() + } + stopper.isStopped = true + } } diff --git a/Sources/AnimationPlanner/Animations/AnimateDelayed.swift b/Sources/AnimationPlanner/Animations/AnimateDelayed.swift index 0bf739c..b9997ea 100644 --- a/Sources/AnimationPlanner/Animations/AnimateDelayed.swift +++ b/Sources/AnimationPlanner/Animations/AnimateDelayed.swift @@ -53,4 +53,8 @@ extension AnimateDelayed: PerformsAnimations where Contained: PerformsAnimations public func animate(delay leadingDelay: TimeInterval, completion: ((Bool) -> Void)?) { animation.animate(delay: delay + leadingDelay, completion: completion) } + + public func stop() { + animation.stop() + } } diff --git a/Sources/AnimationPlanner/Animations/AnimateSpring.swift b/Sources/AnimationPlanner/Animations/AnimateSpring.swift index 7fb3cb0..ab3250f 100644 --- a/Sources/AnimationPlanner/Animations/AnimateSpring.swift +++ b/Sources/AnimationPlanner/Animations/AnimateSpring.swift @@ -53,4 +53,8 @@ extension AnimateSpring: PerformsAnimations { completion: completion ) } + + public func stop() { + animation.stop() + } } diff --git a/Sources/AnimationPlanner/Animations/Extra.swift b/Sources/AnimationPlanner/Animations/Extra.swift index 7a7da00..4540e80 100644 --- a/Sources/AnimationPlanner/Animations/Extra.swift +++ b/Sources/AnimationPlanner/Animations/Extra.swift @@ -5,9 +5,11 @@ import UIKit public struct Extra: SequenceAnimatable, GroupAnimatable { public let duration: TimeInterval = 0 - public var perform: () -> Void + /// Work item used for actually executing the closure + private let workItem: DispatchWorkItem + public init(perform: @escaping () -> Void) { - self.perform = perform + workItem = DispatchWorkItem(block: perform) } } @@ -15,16 +17,21 @@ extension Extra: PerformsAnimations { public func animate(delay leadingDelay: TimeInterval, completion: ((Bool) -> Void)?) { let timing = timingParameters(leadingDelay: leadingDelay) - let animation: () -> Void = { - self.perform() - completion?(true) - } guard timing.delay > 0 else { - animation() + workItem.perform() + completion?(true) return } - DispatchQueue.main.asyncAfter(deadline: .now() + timing.delay) { - animation() + + workItem.notify(queue: .main) { [weak workItem] in + let isFinished = workItem?.isCancelled != true + completion?(isFinished) } + + DispatchQueue.main.asyncAfter(deadline: .now() + timing.delay, execute: workItem) + } + + public func stop() { + workItem.cancel() } } diff --git a/Sources/AnimationPlanner/Animations/Group.swift b/Sources/AnimationPlanner/Animations/Group.swift index 8b99c71..812c5a0 100644 --- a/Sources/AnimationPlanner/Animations/Group.swift +++ b/Sources/AnimationPlanner/Animations/Group.swift @@ -44,4 +44,10 @@ extension Group: PerformsAnimations { } longestAnimation.animate(delay: delay, completion: completion) } + + public func stop() { + for animation in animations.compactMap({ $0 as? PerformsAnimations }) { + animation.stop() + } + } } diff --git a/Sources/AnimationPlanner/Animations/Sequence.swift b/Sources/AnimationPlanner/Animations/Sequence.swift index 7973936..b419077 100644 --- a/Sources/AnimationPlanner/Animations/Sequence.swift +++ b/Sources/AnimationPlanner/Animations/Sequence.swift @@ -33,4 +33,8 @@ extension Sequence: PerformsAnimations { } .animate(delay: delay) } + + public func stop() { + runningSequence.stopAnimations() + } } diff --git a/Sources/AnimationPlanner/Protocols/AnimationContainer.swift b/Sources/AnimationPlanner/Protocols/AnimationContainer.swift index b18b5ea..bf4bbe7 100644 --- a/Sources/AnimationPlanner/Protocols/AnimationContainer.swift +++ b/Sources/AnimationPlanner/Protocols/AnimationContainer.swift @@ -1,6 +1,6 @@ import UIKit -/// Adds custom behaviour on top of any contained animation. Forwards all required ``Animation`` properties +/// Adds custom behavior on top of any contained animation. Forwards all required ``Animation`` properties /// to the contained animation when necessary. public protocol AnimationContainer { /// Animation type contained by ``AnimationContainer`` diff --git a/Sources/AnimationPlanner/Protocols/AnimationModifiers.swift b/Sources/AnimationPlanner/Protocols/AnimationModifiers.swift index bb7683c..4c66a79 100644 --- a/Sources/AnimationPlanner/Protocols/AnimationModifiers.swift +++ b/Sources/AnimationPlanner/Protocols/AnimationModifiers.swift @@ -13,6 +13,9 @@ public protocol AnimationModifiers: Animation { /// - Note: Using `.repeats` will break expected behavior when used in a sequence func options(_ options: UIView.AnimationOptions) -> Self + /// Enables interaction on your parent views while this animation is running + func allowUserInteraction() -> Self + /// Sets a `CAMediaTimingFunction` for the animation. Overwrites possible previously set functions. /// /// Overrides any animation curves previously set with ``timingFunction(_:)`` @@ -22,13 +25,20 @@ public protocol AnimationModifiers: Animation { /// /// - Important: Timing functions are ignored when applied to an animation using spring interpolation (``AnimateSpring``) /// - /// - Parameter function: Custom CAMediaTimingFunction or any of the avaialble static extensions + /// - Parameter function: Custom CAMediaTimingFunction or any of the available static extensions func timingFunction(_ function: CAMediaTimingFunction) -> Self /// Sets the ``Animation/changes`` to be performed for your animation. Could be used when it‘s convenient to add your animation changes at a later state, e.g., after applying other modifiers to your ``Animate`` struct. /// - Parameter changes: Change properties to animate in this closure /// - Note: This replaces any previous animation changes set func changes(_ changes: @escaping () -> Void) -> Self + + /// Adds a handler to be called when the animation is stopped. This handler is only called for the animations that are currently being run. + /// + /// Calling `view.layer.removeAllAnimations()` immediately stops animations for all views updated with this animation, + /// - Parameter stopHandler: Closure called when animation is stopped + /// - Note: This method is only useful for long-running or repeating animations, as these are usually not stopped by stopping the running sequence. + func onStopped(_ stopHandler: @escaping () -> Void) -> Self } extension Animate: AnimationModifiers { @@ -36,12 +46,18 @@ extension Animate: AnimationModifiers { // Update options by creating a union of existing options mutate { $0.options = $0.options?.union(options) ?? options } } + public func allowUserInteraction() -> Animate { + mutate { $0.options = ($0.options ?? []).union(.allowUserInteraction) } + } public func timingFunction(_ function: CAMediaTimingFunction) -> Self { mutate { $0.timingFunction = function} } public func changes(_ changes: @escaping () -> Void) -> Animate { mutate { $0.changes = changes } } + public func onStopped(_ stopHandler: @escaping () -> Void) -> Animate { + mutate { $0.stopper.stopHandler = stopHandler } + } } // MARK: - Spring modifiers @@ -117,21 +133,25 @@ extension Mutable { } extension Animate: Mutable { } +extension Extra: Mutable { } extension AnimateSpring: Mutable { } extension AnimateSpring: AnimationModifiers where Contained: AnimationModifiers { - func modifyAnimation(_ handler: (AnimationModifiers) -> Contained) -> Self { - mutate { $0.animation = handler(animation) } - } public func options(_ options: UIView.AnimationOptions) -> Self { mutate { $0.animation = animation.options(options) } } + public func allowUserInteraction() -> AnimateSpring { + mutate { $0.animation = animation.allowUserInteraction() } + } public func timingFunction(_ function: CAMediaTimingFunction) -> Self { mutate { $0.animation = animation.timingFunction(function) } } public func changes(_ changes: @escaping () -> Void) -> Self { mutate { $0.animation = animation.changes(changes) } } + public func onStopped(_ stopHandler: @escaping () -> Void) -> AnimateSpring { + mutate { $0.animation = animation.onStopped(stopHandler) } + } } extension AnimateDelayed: Mutable { } @@ -139,10 +159,16 @@ extension AnimateDelayed: AnimationModifiers where Contained: Animation & Animat public func options(_ options: UIView.AnimationOptions) -> Self { mutate { $0.animation = animation.options(options) } } + public func allowUserInteraction() -> AnimateDelayed { + mutate { $0.animation = animation.allowUserInteraction() } + } public func timingFunction(_ function: CAMediaTimingFunction) -> Self { mutate { $0.animation = animation.timingFunction(function) } } public func changes(_ changes: @escaping () -> Void) -> Self { mutate { $0.animation = animation.changes(changes) } } + public func onStopped(_ stopHandler: @escaping () -> Void) -> AnimateDelayed { + mutate { $0.animation = animation.onStopped(stopHandler) } + } } diff --git a/Sources/AnimationPlanner/Protocols/PerformsAnimations.swift b/Sources/AnimationPlanner/Protocols/PerformsAnimations.swift index 328114a..d4ff229 100644 --- a/Sources/AnimationPlanner/Protocols/PerformsAnimations.swift +++ b/Sources/AnimationPlanner/Protocols/PerformsAnimations.swift @@ -10,6 +10,9 @@ public protocol PerformsAnimations { /// - completion: Optional closure called when animation completes func animate(delay leadingDelay: TimeInterval, completion: ((_ finished: Bool) -> Void)?) + /// Cancels any currently running animations + func stop() + /// Queries the animation and possible contained animations to find the correct timing values to use to create an actual animation /// - Parameter leadingDelay: Delay to add before performing animation /// - Returns: Tuple containing a delay and duration in seconds diff --git a/Sources/AnimationPlanner/RunningSequence.swift b/Sources/AnimationPlanner/RunningSequence.swift index 25c2b1e..4dff09e 100644 --- a/Sources/AnimationPlanner/RunningSequence.swift +++ b/Sources/AnimationPlanner/RunningSequence.swift @@ -3,13 +3,13 @@ import UIKit /// Maintains state about running animations and provides ways to add a completion handler or stop the animations public class RunningSequence { - public enum State { + public enum State: Equatable { /// Sequence is ready but not yet running animations case ready /// Sequence is performing animations case running /// Sequence has completed animations have completed - /// - Parameter finished: Wether animations have properly finished + /// - Parameter finished: Whether animations have properly finished case completed(finished: Bool) /// Sequence has been manually stopped case stopped @@ -24,7 +24,7 @@ public class RunningSequence { public private(set) var state: State = .ready private(set) var remainingAnimations: [Animatable] = [] - private(set) var currentAnimation: Animatable? + private(set) var currentAnimation: PerformsAnimations? private(set) var completionHandlers: [(Bool) -> Void] = [] @@ -57,19 +57,15 @@ public extension RunningSequence { public extension RunningSequence { /// Stops the currently running animation and cancels any upcoming animations func stopAnimations() { - guard case .running = state else { + guard state == .ready || state == .running else { // Only running animations can be stopped return } state = .stopped - if let animation = currentAnimation as? Animation { - // Perform animation’s changes again to stop animation - animation.changes() - } - - remainingAnimations.removeAll() + currentAnimation?.stop() currentAnimation = nil + remainingAnimations.removeAll() complete(finished: false) } @@ -79,7 +75,7 @@ extension RunningSequence { @discardableResult func animate(delay: TimeInterval = 0) -> Self { - guard case .ready = state else { + guard state == .ready else { // Don’t start animating a sequence with running, completed or stopped animations return self } @@ -102,10 +98,9 @@ extension RunningSequence { return false } - currentAnimation = impendingAnimations.first - guard let animation = currentAnimation as? PerformsAnimations else { + guard let animation = impendingAnimations.first as? PerformsAnimations else { guard leadingDelay == 0 else { - // Wait out the remaing delay until calling completion closure + // Wait out the remaining delay until calling completion closure DispatchQueue.main.asyncAfter(deadline: .now() + leadingDelay) { self.complete(finished: true) } @@ -116,36 +111,48 @@ extension RunningSequence { } remainingAnimations = Array(impendingAnimations.dropFirst()) - let startTime = CACurrentMediaTime() + let duration = (animation as? Animatable)?.duration ?? 0 + let completionDuration = duration + leadingDelay + let startTime = CACurrentMediaTime() animation.animate(delay: leadingDelay) { finished in guard finished else { self.complete(finished: finished) return } - if let duration = (animation as? Animatable)?.duration { - let actualDuration = CACurrentMediaTime() - startTime - let difference = (duration + leadingDelay) - actualDuration - let oneFrameDifference: TimeInterval = 1/60 - - if difference > 0.1 && actualDuration < oneFrameDifference { - // UIView animation probably wasn‘t executed because no actual animatable - // properties were changed in animation closure. Just wait out remaining time - // before moving over to the next step. - let waitTime = max(0, difference - oneFrameDifference) // reduce a frame to be safe - DispatchQueue.main.asyncAfter(deadline: .now() + waitTime) { - self.animateNextAnimation() - } - return + guard completionDuration > 0 else { + // Skip duration checking when animation should immediately complete + self.animateNextAnimation() + return + } + + let actualDuration = CACurrentMediaTime() - startTime + let difference = (duration + leadingDelay) - actualDuration + let oneFrameDifference: TimeInterval = 1/60 + + if difference <= 0.1 || actualDuration >= oneFrameDifference { + self.animateNextAnimation() + } else { + // UIView animation probably wasn‘t executed because no actual animatable + // properties were changed in animation closure. Just wait out remaining time + // before moving over to the next step. + let waitTime = max(0, difference - oneFrameDifference) // reduce a frame to be safe + DispatchQueue.main.asyncAfter(deadline: .now() + waitTime) { + self.animateNextAnimation() } } - self.animateNextAnimation() + } + if completionDuration > 0 { + // Only set current animation when its completion can‘t immediately fire, + // causing a newer animation to be set as `currentAnimation` right before + // this line is executed + currentAnimation = animation } } func complete(finished: Bool) { - if case .running = state { + if state == .running { state = .completed(finished: finished) } completionHandlers.forEach { $0(finished) } diff --git a/Tests/AnimationPlannerTests/AnimationPlannerTests.swift b/Tests/AnimationPlannerTests/AnimationPlannerTests.swift index 24847dc..b6e7314 100644 --- a/Tests/AnimationPlannerTests/AnimationPlannerTests.swift +++ b/Tests/AnimationPlannerTests/AnimationPlannerTests.swift @@ -9,7 +9,7 @@ class AnimationPlannerTests: XCTestCase { override func setUp() { window = UIWindow(frame: UIScreen.main.bounds) - view = UIView(frame: window.bounds.insetBy(dx: 100, dy: 100)) + view = newView() window.addSubview(view) } @@ -20,10 +20,11 @@ class AnimationPlannerTests: XCTestCase { view = nil } - /// Runs your animation logic, waits for completion and fails when expected duration varies from provided duration (allowing for precision). Adds the completion handler to the returned `RunningSequence` object when only using `AnimationPlanner.plan` or `.group`. Othewise use `runAnimationTest` + /// Runs your animation logic, waits for completion and fails when expected duration varies from provided duration (allowing for precision). Adds a default completion handler to the returned `RunningSequence`. /// - Parameters: - /// - duration: Duration of animimation, or total duration of all animation steps, defaults to random duration + /// - duration: Duration of animation, or total duration of all animation steps, defaults to random duration /// - precision: Precision to use when comparing expected duration and time to complete animations + /// - expectFinished: Whether the animation are expected to be properly finished /// - animations: Closure where animations should be performed with completion closure to call when completed /// - completion: Closure to call when animations have completed /// - usedDuration: Duration for animation, use this argument when no specific duration is provided @@ -31,20 +32,31 @@ class AnimationPlannerTests: XCTestCase { func runAnimationBuilderTest( duration: TimeInterval = randomDuration, precision: TimeInterval = durationPrecision, + expectFinished: Bool = true, _ animations: @escaping ( _ usedDuration: TimeInterval, _ usedPrecision: TimeInterval) -> RunningSequence? ) { - runAnimationTest(duration: duration, precision: precision) { completion, usedDuration, usedPrecision in + runAnimationTest(duration: duration, precision: precision, expectFinished: expectFinished) { completion, usedDuration, usedPrecision in let runningSequence = animations(duration, precision) XCTAssertNotNil(runningSequence) runningSequence?.onComplete(completion) } } + /// Runs your animation logic, waits for completion and fails when expected duration varies from provided duration (allowing for precision). Add the completion handler to the returned `RunningSequence` object when only using `AnimationPlanner.plan` or `.group`. Otherwise use `runAnimationTest` + /// - Parameters: + /// - duration: Duration of animation, or total duration of all animation steps, defaults to random duration + /// - precision: Precision to use when comparing expected duration and time to complete animations + /// - expectFinished: Whether the animation are expected to be properly finished + /// - animations: Closure where animations should be performed with completion closure to call when completed + /// - completion: Closure to call when animations have completed + /// - usedDuration: Duration for animation, use this argument when no specific duration is provided + /// - usedPrecision: Precision for duration check, use this argument when no specific precision is provided func runAnimationTest( duration: TimeInterval = randomDuration, precision: TimeInterval = durationPrecision, + expectFinished: Bool = true, _ animations: @escaping ( _ completion: @escaping (Bool) -> Void, _ usedDuration: TimeInterval, @@ -54,14 +66,20 @@ class AnimationPlannerTests: XCTestCase { let startTime = CACurrentMediaTime() let completion: (Bool) -> Void = { finished in - XCTAssert(finished, "Animation not finished") + if finished != expectFinished { + if expectFinished { + XCTFail("Animations should complete finished") + } else { + XCTFail("Animations should complete interrupted") + } + } assertDifference(startTime: startTime, duration: duration, precision: precision) finishedExpectation.fulfill() } animations(completion, duration, precision) - waitForExpectations(timeout: duration + precision * 2) + wait(for: [finishedExpectation], timeout: duration + precision * 2) } } @@ -110,6 +128,7 @@ extension AnimationPlannerTests { let duration: TimeInterval var totalDuration: TimeInterval { delay + duration } } + func randomDelayedAnimations(amount: Int) -> [RandomAnimation] { zip( randomDurations(amount: amount), @@ -122,7 +141,12 @@ extension AnimationPlannerTests { } func newView() -> UIView { - let view = UIView() + let view = UIView(frame: CGRect( + x: .large, + y: .large, + width: .large, + height: .large + )) window.addSubview(view) return view } diff --git a/Tests/AnimationPlannerTests/BuilderTests.swift b/Tests/AnimationPlannerTests/BuilderTests.swift index 2ceeba1..83d75c0 100644 --- a/Tests/AnimationPlannerTests/BuilderTests.swift +++ b/Tests/AnimationPlannerTests/BuilderTests.swift @@ -14,6 +14,10 @@ class BuilderTests: AnimationPlannerTests { let simplerSpring = AnimateSpring(duration: 1, dampingRatio: 2) XCTAssertEqual(spring.dampingRatio, simplerSpring.dampingRatio) XCTAssertEqual(spring.duration, simplerSpring.duration) + let springOptions = spring.options ?? [] + XCTAssertFalse(springOptions.contains(.allowUserInteraction)) + let editedOptions = spring.allowUserInteraction().options ?? [] + XCTAssertTrue(editedOptions.contains(.allowUserInteraction)) let delay = spring.delayed(3) XCTAssertEqual(delay.duration, spring.duration + delay.delay) diff --git a/Tests/AnimationPlannerTests/ComplexAnimationTests.swift b/Tests/AnimationPlannerTests/ComplexAnimationTests.swift index 2cc8602..ef5a407 100644 --- a/Tests/AnimationPlannerTests/ComplexAnimationTests.swift +++ b/Tests/AnimationPlannerTests/ComplexAnimationTests.swift @@ -4,7 +4,7 @@ import XCTest class ComplexAnimationTest: AnimationPlannerTests { - /// Creates a pretty complex animation with mutliple groups each containing multiple sequences + /// Creates a pretty complex animation with multiple groups each containing multiple sequences /// Groups can contain sequences that perform their animations in sequence, but each sequence /// is running at the same time in each group func testSequenceGroup() { diff --git a/Tests/AnimationPlannerTests/GroupTests.swift b/Tests/AnimationPlannerTests/GroupTests.swift index a70b4f8..b6d814d 100644 --- a/Tests/AnimationPlannerTests/GroupTests.swift +++ b/Tests/AnimationPlannerTests/GroupTests.swift @@ -180,4 +180,63 @@ class GroupTests: AnimationPlannerTests { } } + + /// Animates multiple sequences simultaneously, each with an increasing offset in their contained animations + func testGroupsWithOffsetSequenceAnimations() { + let numberOfGroups: Int = 4 + let animations = randomDurations(amount: 2) + let delayMultiplier: Double = 0.2 + + let totalDelay: TimeInterval = Double(numberOfGroups - 1) * delayMultiplier + let totalDuration: TimeInterval = totalDelay + animations.reduce(0, { $0 + $1 }) + + let views = (0..