diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25679b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,85 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +.DS_Store + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +Packages/ +Package.pins +Package.resolved +*.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb2ad61 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Mike Robinson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..eb474c3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Animations", + platforms: [.iOS(.v10)], + products: [ + .library( + name: "Animations", + targets: ["Animations"]), + ], + targets: [ + .target( + name: "Animations", + dependencies: []), + ], + swiftLanguageVersions: [ + .v5 + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..00dc914 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Animation + +`Animation` is a wrapper around `UIView.animate` that provides an interface to chain animations. + +The `AnimationCoordinator` provides a interface to coordinate multiple `UIViewPropertyAnimator`s alongside each other. diff --git a/Sources/Animations/Animation.swift b/Sources/Animations/Animation.swift new file mode 100644 index 0000000..f56a5e9 --- /dev/null +++ b/Sources/Animations/Animation.swift @@ -0,0 +1,52 @@ +import UIKit + +public struct Animation { + indirect enum Operation { + case none + case animation(Animation) + } + fileprivate let duration: TimeInterval + fileprivate let delay: TimeInterval + fileprivate let options: UIView.AnimationOptions + fileprivate let animations: () -> Void + fileprivate var previousAnimation: Operation + + public static func make(duration: TimeInterval, + delay: TimeInterval = 0.0, + options: UIView.AnimationOptions = [], + animations: @escaping () -> Void) -> Self { + + return Animation(duration: duration, + delay: delay, + options: options, + animations: animations, + previousAnimation: .none) + } + + public func then(duration: TimeInterval, + delay: TimeInterval = 0.0, + options: UIView.AnimationOptions = [], + animations: @escaping () -> Void) -> Self { + return Animation(duration: duration, + delay: delay, + options: options, + animations: animations, + previousAnimation: .animation(self)) + } + + public func start(completion: ((Bool) -> Void)? = nil) { + let executeAnimation = { + UIView.animate(withDuration: self.duration, + delay: self.delay, + options: self.options, + animations: self.animations, + completion: completion) + } + + if case let .animation(animation) = self.previousAnimation { + animation.start(completion: { _ in executeAnimation() }) + } else { + executeAnimation() + } + } +} diff --git a/Sources/Animations/AnimationCoordinator.swift b/Sources/Animations/AnimationCoordinator.swift new file mode 100644 index 0000000..87c2fce --- /dev/null +++ b/Sources/Animations/AnimationCoordinator.swift @@ -0,0 +1,61 @@ +import UIKit + +public typealias PropertyAnimationBlock = () -> Void +public typealias PostAnimationBlock = () -> Void + +public class AnimationCoordinator { + public enum Pace { + case linear + case cubic(CGFloat, CGFloat, CGFloat, CGFloat) + var timingCurveProvider: UITimingCurveProvider { + switch self { + case .linear: return UICubicTimingParameters(animationCurve: .linear) + case let .cubic(x1, y1, x2, y2): return UICubicTimingParameters(controlPoint1: CGPoint(x: x1, y: y1), controlPoint2: CGPoint(x: x2, y: y2)) + } + } + } + private struct Animation { + let time: TimeInterval + let duration: TimeInterval + let pace: UITimingCurveProvider + let animationBlock: PropertyAnimationBlock + let completionBlock: PostAnimationBlock? + var complete: Bool = false + } + private var animations = [Animation]() + public private(set) var duration: TimeInterval = 0.0 + public var complete: Bool { animations.map { $0.complete }.reduce(true, { $0 && $1 }) } + private var completion: (() -> Void )? + + private func tryToComplete() { if complete { completion?() } } + + required init() { } + + public static var new: Self { Self() } + + public func addPropertyAnimation(at time: TimeInterval = 0.0, + for duration: TimeInterval, + pace: Pace = .linear, + animations: @escaping PropertyAnimationBlock, + completion: PostAnimationBlock? = nil) -> Self { + self.animations.append(Animation(time: time, duration: duration, pace: pace.timingCurveProvider, animationBlock: animations, completionBlock: completion)) + self.duration = max(self.duration, time + duration) + return self + } + + public func start(completion: (() -> Void)? = nil) { + self.completion = completion + guard duration > 0 else { return } + + for index in animations.indices { + let propAnimator = UIViewPropertyAnimator(duration: animations[index].duration, timingParameters: animations[index].pace) + propAnimator.addAnimations(animations[index].animationBlock) + propAnimator.addCompletion { _ in + self.animations[index].completionBlock?() + self.animations[index].complete = true + self.tryToComplete() + } + propAnimator.startAnimation(afterDelay: animations[index].time) + } + } +}