Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proper stopping #21

Merged
merged 17 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 3 additions & 3 deletions Sample App/AnimationPlanner-Sample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
3E5C6DA62848C27B00720FE8 /* CAMediaTimingFunction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CAMediaTimingFunction.swift; sourceTree = "<group>"; };
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 = "<group>"; };
3EF52D5E2848AEC7000F8222 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -120,7 +118,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1340;
LastUpgradeCheck = 1340;
LastUpgradeCheck = 1520;
TargetAttributes = {
3EF52D582848AEC7000F8222 = {
CreatedOnToolsVersion = 13.4;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
134 changes: 98 additions & 36 deletions Sample App/AnimationPlanner-Sample/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Expand All @@ -201,7 +236,7 @@ extension ViewController {
let values = (0..<count).map { CGFloat($0) / CGFloat(count) }.map { $0 * maxRadius }

Extra { baseTransform = view.transform }
Loop.through(values) { radius in
for radius in values {
Animate(duration: 0.015) {
view.transform = baseTransform
.translatedBy(
Expand All @@ -213,6 +248,34 @@ extension ViewController {
}
}

private extension ViewController {

func newStopButton() -> 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? {
// 🫣🫣🫣
Expand All @@ -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
}
Expand All @@ -237,4 +300,3 @@ extension UIView {
}
}
}

38 changes: 37 additions & 1 deletion Sources/AnimationPlanner/Animations/Animate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand All @@ -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)?
}
}

Expand Down Expand Up @@ -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
}
}
4 changes: 4 additions & 0 deletions Sources/AnimationPlanner/Animations/AnimateDelayed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
4 changes: 4 additions & 0 deletions Sources/AnimationPlanner/Animations/AnimateSpring.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,8 @@ extension AnimateSpring: PerformsAnimations {
completion: completion
)
}

public func stop() {
animation.stop()
}
}
25 changes: 16 additions & 9 deletions Sources/AnimationPlanner/Animations/Extra.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,33 @@ 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)
}
}

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()
}
}
6 changes: 6 additions & 0 deletions Sources/AnimationPlanner/Animations/Group.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Loading
Loading