diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3ef169c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,32 @@
+# OS X
+.DS_Store
+
+# Xcode
+build/
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+xcuserdata/
+*.xccheckout
+profile
+*.moved-aside
+DerivedData
+*.hmap
+*.ipa
+
+# Bundler
+.bundle
+
+# Carthage
+Carthage/Build
+
+# CocoaPods
+Pods/
+
+# Swift Playgrounds
+timeline.xctimeline
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..e3ce0db
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,8 @@
+osx_image: xcode10.3
+language: objective-c
+install:
+ - bundle install --gemfile=Example/Gemfile
+ - bundle exec --gemfile=Example/Gemfile pod install --project-directory=Example
+script:
+ - set -o pipefail && xcodebuild test -enableCodeCoverage YES -workspace Example/Stagehand.xcworkspace -scheme "Stagehand Demo App" -sdk iphonesimulator12.4 ONLY_ACTIVE_ARCH=NO | xcpretty
+ - bundle exec --gemfile=Example/Gemfile pod lib lint
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..738268a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,23 @@
+### Sign the CLA
+
+All contributors to your PR must sign our [Individual Contributor License Agreement (CLA)](https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1). The CLA is a short form that ensures that you are eligible to contribute.
+
+### One Issue per Pull Request
+
+Keep your Pull Requests small. Small PRs are easier to reason about which makes them significantly more likely to get merged.
+
+### Issues Before Features
+
+If you want to add a feature, please file an [Issue](https://github.com/CashApp/Stagehand/issues) first. An Issue gives us the opportunity to discuss the requirements and implications of a feature with you before you start writing code.
+
+### Backwards Compatibility
+
+Respect the minimum deployment target. If you are adding code that uses new APIs, make sure to prevent older clients from crashing or misbehaving. Our CI runs against our minimum deployment targets, so you will not get a green build unless your code is backwards compatible.
+
+### Forwards Compatibility
+
+Please do not write new code using deprecated APIs.
+
+### Keep the Demo App and Documentation Updated
+
+When adding new features or making changes to existing features, make sure to update the included demo app and tutorial playground so new users can understand what's available.
diff --git a/Example/Gemfile b/Example/Gemfile
new file mode 100644
index 0000000..ed48036
--- /dev/null
+++ b/Example/Gemfile
@@ -0,0 +1,3 @@
+source 'https://rubygems.org' do
+ gem 'cocoapods', '~> 1.8'
+end
diff --git a/Example/Gemfile.lock b/Example/Gemfile.lock
new file mode 100644
index 0000000..cc3c122
--- /dev/null
+++ b/Example/Gemfile.lock
@@ -0,0 +1,83 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ CFPropertyList (3.0.1)
+ activesupport (4.2.11.1)
+ i18n (~> 0.7)
+ minitest (~> 5.1)
+ thread_safe (~> 0.3, >= 0.3.4)
+ tzinfo (~> 1.1)
+ algoliasearch (1.27.1)
+ httpclient (~> 2.8, >= 2.8.3)
+ json (>= 1.5.1)
+ atomos (0.1.3)
+ claide (1.0.3)
+ cocoapods (1.8.4)
+ activesupport (>= 4.0.2, < 5)
+ claide (>= 1.0.2, < 2.0)
+ cocoapods-core (= 1.8.4)
+ cocoapods-deintegrate (>= 1.0.3, < 2.0)
+ cocoapods-downloader (>= 1.2.2, < 2.0)
+ cocoapods-plugins (>= 1.0.0, < 2.0)
+ cocoapods-search (>= 1.0.0, < 2.0)
+ cocoapods-stats (>= 1.0.0, < 2.0)
+ cocoapods-trunk (>= 1.4.0, < 2.0)
+ cocoapods-try (>= 1.1.0, < 2.0)
+ colored2 (~> 3.1)
+ escape (~> 0.0.4)
+ fourflusher (>= 2.3.0, < 3.0)
+ gh_inspector (~> 1.0)
+ molinillo (~> 0.6.6)
+ nap (~> 1.0)
+ ruby-macho (~> 1.4)
+ xcodeproj (>= 1.11.1, < 2.0)
+ cocoapods-core (1.8.4)
+ activesupport (>= 4.0.2, < 6)
+ algoliasearch (~> 1.0)
+ concurrent-ruby (~> 1.1)
+ fuzzy_match (~> 2.0.4)
+ nap (~> 1.0)
+ cocoapods-deintegrate (1.0.4)
+ cocoapods-downloader (1.2.2)
+ cocoapods-plugins (1.0.0)
+ nap
+ cocoapods-search (1.0.0)
+ cocoapods-stats (1.1.0)
+ cocoapods-trunk (1.4.1)
+ nap (>= 0.8, < 2.0)
+ netrc (~> 0.11)
+ cocoapods-try (1.1.0)
+ colored2 (3.1.2)
+ concurrent-ruby (1.1.5)
+ escape (0.0.4)
+ fourflusher (2.3.1)
+ fuzzy_match (2.0.4)
+ gh_inspector (1.1.3)
+ httpclient (2.8.3)
+ i18n (0.9.5)
+ concurrent-ruby (~> 1.0)
+ json (2.2.0)
+ minitest (5.12.2)
+ molinillo (0.6.6)
+ nanaimo (0.2.6)
+ nap (1.1.0)
+ netrc (0.11.0)
+ ruby-macho (1.4.0)
+ thread_safe (0.3.6)
+ tzinfo (1.2.5)
+ thread_safe (~> 0.1)
+ xcodeproj (1.13.0)
+ CFPropertyList (>= 2.3.3, < 4.0)
+ atomos (~> 0.1.3)
+ claide (>= 1.0.2, < 2.0)
+ colored2 (~> 3.1)
+ nanaimo (~> 0.2.6)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ cocoapods (~> 1.8)!
+
+BUNDLED WITH
+ 1.17.3
diff --git a/Example/Performance Tests/AnimationCurvePerformanceTests.swift b/Example/Performance Tests/AnimationCurvePerformanceTests.swift
new file mode 100644
index 0000000..6cc930e
--- /dev/null
+++ b/Example/Performance Tests/AnimationCurvePerformanceTests.swift
@@ -0,0 +1,68 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import XCTest
+
+final class AnimationCurvePerformanceTests: XCTestCase {
+
+ func testParabolicEaseInPerformance() {
+ let curve = ParabolicEaseInAnimationCurve()
+ measure {
+ for _ in 0...100 {
+ for i in 0...10000 {
+ let progress = Double(i) / 10000
+ _ = curve.adjustedProgress(for: progress)
+ }
+ }
+ }
+ }
+
+ func testParabolicEaseOutPerformance() {
+ let curve = ParabolicEaseOutAnimationCurve()
+ measure {
+ for _ in 0...100 {
+ for i in 0...10000 {
+ let progress = Double(i) / 10000
+ _ = curve.adjustedProgress(for: progress)
+ }
+ }
+ }
+ }
+
+ func testSinusoidalEaseInEaseOutPerformance() {
+ let curve = SinusoidalEaseInEaseOutAnimationCurve()
+ measure {
+ for _ in 0...100 {
+ for i in 0...10000 {
+ let progress = Double(i) / 10000
+ _ = curve.adjustedProgress(for: progress)
+ }
+ }
+ }
+ }
+
+ func testCubicBezierEaseInPerformance() {
+ let curve = CubicBezierAnimationCurve.easeIn
+ measure {
+ for i in 0...10000 {
+ let progress = Double(i) / 10000
+ _ = curve.adjustedProgress(for: progress)
+ }
+ }
+ }
+
+}
diff --git a/Example/Performance Tests/Info.plist b/Example/Performance Tests/Info.plist
new file mode 100644
index 0000000..6c40a6c
--- /dev/null
+++ b/Example/Performance Tests/Info.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ BNDL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+
+
diff --git a/Example/Podfile b/Example/Podfile
new file mode 100644
index 0000000..b9c02b9
--- /dev/null
+++ b/Example/Podfile
@@ -0,0 +1,21 @@
+use_frameworks!
+
+platform :ios, '10.0'
+
+target 'Stagehand_Example' do
+ pod 'Stagehand', :path => '../'
+
+ target 'Stagehand-UnitTests' do
+ inherit! :search_paths
+
+ pod 'StagehandTesting', :path => '../'
+ end
+
+ target 'Stagehand-PerformanceTests' do
+ inherit! :search_paths
+
+ pod 'Stagehand', :path => '../'
+ end
+end
+
+install! 'cocoapods', disable_input_output_paths: true
diff --git a/Example/Podfile.lock b/Example/Podfile.lock
new file mode 100644
index 0000000..8b8b9b5
--- /dev/null
+++ b/Example/Podfile.lock
@@ -0,0 +1,33 @@
+PODS:
+ - iOSSnapshotTestCase (6.2.0):
+ - iOSSnapshotTestCase/SwiftSupport (= 6.2.0)
+ - iOSSnapshotTestCase/Core (6.2.0)
+ - iOSSnapshotTestCase/SwiftSupport (6.2.0):
+ - iOSSnapshotTestCase/Core
+ - Stagehand (1.0)
+ - StagehandTesting (1.0):
+ - iOSSnapshotTestCase (~> 6.1)
+ - Stagehand (= 1.0)
+
+DEPENDENCIES:
+ - Stagehand (from `../`)
+ - StagehandTesting (from `../`)
+
+SPEC REPOS:
+ trunk:
+ - iOSSnapshotTestCase
+
+EXTERNAL SOURCES:
+ Stagehand:
+ :path: "../"
+ StagehandTesting:
+ :path: "../"
+
+SPEC CHECKSUMS:
+ iOSSnapshotTestCase: 9ab44cb5aa62b84d31847f40680112e15ec579a6
+ Stagehand: 98d49307100b8f506e18a101d87d3a7079f8e80b
+ StagehandTesting: 62f623e597e5935c91bd78b735b60351bf607f64
+
+PODFILE CHECKSUM: e2058eb578e4d6fb432b015c43b4e221c1b70e87
+
+COCOAPODS: 1.8.4
diff --git a/Example/Stagehand Tutorial.playground/Pages/Advanced Execution of Animations.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Advanced Execution of Animations.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..3389e17
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Advanced Execution of Animations.xcplaygroundpage/Contents.swift
@@ -0,0 +1,105 @@
+//: [Previous](@previous)
+
+import PlaygroundSupport
+import Stagehand
+import UIKit
+
+/*:
+
+ # Advanced Execution of Animations
+
+ We've already seen the basics of executing an animation, using the `perform(on:delay:completion:)` method.
+
+ */
+
+let animation = AnimationFactory.makeBasicViewAnimation()
+
+let view = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100))
+view.backgroundColor = .red
+PlaygroundPage.current.liveView = WrapperView(wrappedView: view)
+
+animation.perform(
+ on: view,
+ completion: { success in
+ if success {
+ print("Our animation has successfully completed.")
+ } else {
+ print("Our animation has stopped running, but didn't finish.")
+ }
+ }
+)
+
+/*:
+
+ ## Cancelling Our Animation
+
+ When an animation is being performed, it holds onto the element it is animating weakly and will cancel itself if that
+ element is deallocated. What if we need to stop the animation before that point though?
+
+ The `perform(on:delay:completion:)` method returns an `AnimationInstance` that can be used to track and control the
+ animation. We can use this animation instance to cancel the animation.
+
+ */
+
+let animationInstance = animation.perform(on: view)
+
+animationInstance.cancel()
+
+/*:
+
+ There are a few different behaviors you can use when cancelling an animation. The default is to halt the animation - to
+ leave it in its current state when it was canceled. You can also revert the animation back to the starting point or
+ jump to the final state. Check out the "Animation Cancellation" screen in the demo app to see how each of these work.
+
+ Using the animation instance, we can also check the status of our animation at any point.
+
+ */
+
+switch animationInstance.status {
+case .pending:
+ print("Our animation hasn't started yet.")
+
+case let .animating(progress: progress):
+ print("Our animation is \(progress * 100)% complete.")
+
+case .complete:
+ print("Our animation completed successfully.")
+
+case let .canceled(behavior: behavior):
+ print("Our animation was canceled using the \(behavior) behavior.")
+}
+
+/*:
+
+ ## Adding a Delay
+
+ What if we aren't ready to start our animation immediately? The `perform(on:delay:completion:)` method makes it easy to
+ add a delay before our animation starts executing.
+
+ */
+
+animation.perform(on: view, delay: 2)
+
+/*:
+
+ One of the superpowers Stagehand has over `UIView` animations is the separation of construction and execution. Since we
+ can build up our `Animation` and execute it at a later time, we don't need to know when (or even if) it will be
+ performed when we build it.
+
+
+ ## Queueing Animations
+
+ Sometimes we want to run animations in sequence. Stagehand provides the `AnimationQueue` as a way to do this. We create
+ our animation queue targeting a specific element.
+
+ When we enqueue the first animation, it will begin executing immediately. The next animation we enqueue will begin
+ executing when the first one completes, or immediately if the first one hasn't already completed. `enqueue(animation:)`
+ also returns an `AnimationInstance` so we can track and control each instance in the queue separately.
+
+ */
+
+let animationQueue = AnimationQueue(element: view)
+
+animationQueue.enqueue(animation: animation)
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/All About Keyframes.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/All About Keyframes.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..dac4f06
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/All About Keyframes.xcplaygroundpage/Contents.swift
@@ -0,0 +1,94 @@
+//: [Previous](@previous)
+
+import Stagehand
+
+/*:
+
+ ## Defining Keyframes
+
+ Stagehands uses keyframes in the traditional sense of the term in the animation world. That is, a keyframe represents
+ the value for a specific property at a specific point in time. For each frame in between the keyframes, the value for
+ that property will be interpolated between the values defined by the keyframes. Frames before the first keyframe will
+ use the value of that keyframe; likewise with frames after the last keyframe.
+
+ Since our `Animation` is generic over the type of element we are animating, we can use the power of Swift key paths to
+ animate any property that can be interpolated. One of the most common use cases for this is adding keyframes for
+ properties of subviews on a custom `UIView` subclass.
+
+ */
+
+final class MySpecialView: UIView {
+
+ let leftView: UIView = .init()
+
+ let rightView: UIView = .init()
+
+}
+
+var fadeFromLeftToRightAnimation = Animation()
+
+// Over the first half of the animation, fade out the left view.
+fadeFromLeftToRightAnimation.addKeyframe(for: \.leftView.alpha, at: 0.0, value: 1)
+fadeFromLeftToRightAnimation.addKeyframe(for: \.leftView.alpha, at: 0.5, value: 0)
+
+// Over the second half of the animation, fade in the right view.
+fadeFromLeftToRightAnimation.addKeyframe(for: \.rightView.alpha, at: 0.5, value: 0)
+fadeFromLeftToRightAnimation.addKeyframe(for: \.rightView.alpha, at: 1.0, value: 1)
+
+/*:
+
+ We've now created an animation where the left view fades out (from an alpha of 1 to 0) over the first half of our
+ animation. Since the last keyframe for the left view has a value of `0`, the alpha will remain there through the rest
+ of the animation. Since the first keyframe for the right view has a value of `0`, it will start there. Then over the
+ second half of the animation it will fade in.
+
+ With only two subviews, this is easy to read - but what happens when we start adding in more subviews? Or more
+ properties to animate? Check out the [Composing Animations](Composing%20Animations) page to see how we can make
+ multi-part animations like this one easier to reason about.
+
+ */
+
+/*:
+
+ ## Making Keyframes Relative to the Initial Value
+
+ So far we've seen keyframes define fixed values at each timestamp. Sometimes, to make our animations more reusable, we
+ need to define the keyframes relative to the value of the property at the beginning of the animation. To do this, we
+ can use relative keyframes, which take a closure that transforms the initial value of the property into the value for
+ the property at that keyframe.
+
+ */
+
+var rotateAnimation = Animation()
+
+// Rotate the view 90 degrees from its current position.
+rotateAnimation.addKeyframe(for: \.transform, at: 0, relativeValue: { $0 })
+rotateAnimation.addKeyframe(for: \.transform, at: 1, relativeValue: { $0.rotated(by: .pi / 4) })
+
+/*:
+
+ Keyframes with static and relative values can be mixed together, even for the same property.
+
+ A common use case for this is having a property start the animation at its current value, before being animated to a
+ new (fixed) value.
+
+ */
+
+var fadeOutAnimation = Animation()
+
+// Fade the view from its current alpha down to 0.
+fadeOutAnimation.addKeyframe(for: \.alpha, at: 0, relativeValue: { $0 })
+fadeOutAnimation.addKeyframe(for: \.alpha, at: 1, value: 0)
+
+/*:
+
+ ## Animating Different Types of Properties
+
+ Stagehand ships with support for animating properties of a variety of common types. This includes floating point types,
+ many of the CoreGraphics geometric types (`CGPoint`, `CGSize`, etc.), colors, etc. These aren't the only types you can
+ add keyframes for, however - check out the [Animating Custom Properties](Animating%20Custom%20Properties) page for more
+ on how to support animating properties of custom types.
+
+ */
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Animating Custom Properties.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Animating Custom Properties.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..38b25c0
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Animating Custom Properties.xcplaygroundpage/Contents.swift
@@ -0,0 +1,128 @@
+//: [Previous](@previous)
+
+/*:
+
+ # Animating Custom Properties
+
+ Stagehand comes with the ability to animate a wide variety of common property types out of the box. Any property of one
+ of these types can be animated using keyframes. Sometimes we need to animate custom types though.
+
+ As an example, let's define a `Border` value type that wraps the border-related properties of a view:
+
+ */
+
+import UIKit
+
+struct Border {
+
+ var width: CGFloat
+
+ var color: UIColor?
+
+ static let none: Border = .init(width: 0, color: nil)
+
+}
+
+extension UIView {
+
+ var border: Border {
+ get {
+ return .init(
+ width: layer.borderWidth,
+ color: layer.borderColor.map(UIColor.init)
+ )
+ }
+ set {
+ layer.borderWidth = newValue.width
+ layer.borderColor = newValue.color?.cgColor
+ }
+ }
+
+}
+
+/*:
+
+ In its current form, we can't add keyframes for the `\UIView.border` property, since we don't know how to interpolate
+ the values. Fortunately, all we need to do is make `Border` conform to the `AnimatableProperty` protocol. This
+ conformance tells Stagehand how to interpolate between two values of the given type.
+
+ Since our `Border` type is made up of properties that we do know how to animate, we can simply create a new `Border`
+ value with each of the properties interpolated.
+
+ */
+
+import Stagehand
+
+extension Border: AnimatableProperty {
+
+ static func value(between initialValue: Border, and finalValue: Border, at progress: Double) -> Border {
+ return .init(
+ width: CGFloat.value(between: initialValue.width, and: finalValue.width, at: progress),
+ color: UIColor.optionalValue(between: initialValue.color, and: finalValue.color, at: progress)
+ )
+ }
+
+}
+
+/*:
+
+ Now that we know how to interpolate our `Border` type, we can add it to an animation.
+
+ */
+
+var animation = Animation()
+
+animation.addKeyframe(for: \.border, at: 0, relativeValue: { $0 })
+animation.addKeyframe(for: \.border, at: 1, value: .none)
+
+/*:
+
+ ## Interpolating Optional Values
+
+ This works great when our property is non-optional. But what happens when the value could be `nil`? The expected
+ behavior isn't always obvious, so Stagehand disables this by default. If you want to animate optional values, you can
+ enable this by conforming to the `AnimatableOptionalProperty` protocol.
+
+ As an example, let's add the ability to animate between two different `Border?` values. What's between a `nil` border
+ and a non-`nil` border? When we get a `nil` initial or final value, we will treat it as a zero-width border that is the
+ same color as the other value. This will enable us to animate in/out our border where only the width changes.
+
+ */
+
+extension Border: AnimatableOptionalProperty {
+
+ static func optionalValue(between initialValue: Border?, and finalValue: Border?, at progress: Double) -> Border? {
+ guard progress > 0 else {
+ return initialValue
+ }
+
+ guard progress < 1 else {
+ return finalValue
+ }
+
+ switch (initialValue, finalValue) {
+ case (nil, nil):
+ return nil
+
+ case let (.some(initialValue), .some(finalValue)):
+ return Border.value(between: initialValue, and: finalValue, at: progress)
+
+ case let (nil, .some(finalValue)):
+ return Border.value(
+ between: .init(width: 0, color: finalValue.color),
+ and: finalValue,
+ at: progress
+ )
+
+ case let (.some(initialValue), nil):
+ return Border.value(
+ between: initialValue,
+ and: .init(width: 0, color: initialValue.color),
+ at: progress
+ )
+ }
+ }
+
+}
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Animation Curves.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Animation Curves.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..e405827
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Animation Curves.xcplaygroundpage/Contents.swift
@@ -0,0 +1,94 @@
+//: [Previous](@previous)
+
+import PlaygroundSupport
+import Stagehand
+
+/*:
+
+ # Animation Curves
+
+ One of the easiest ways to make an animation feel more polished is by adding an animation curve. Animation curves
+ control the way that properties will be interpolated.
+
+ For sake of example, let's build an animation with the default (linear) animation curve.
+
+ */
+
+var shakeAnimation = Animation()
+
+shakeAnimation.addKeyframe(for: \.transform, at: 0, value: .identity)
+shakeAnimation.addKeyframe(for: \.transform, at: 0.25, value: .init(translationX: 40, y: 0))
+shakeAnimation.addKeyframe(for: \.transform, at: 0.75, value: .init(translationX: -40, y: 0))
+shakeAnimation.addKeyframe(for: \.transform, at: 1, value: .identity)
+
+let view = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100))
+view.backgroundColor = .red
+PlaygroundPage.current.liveView = WrapperView(wrappedView: view, outset: 50)
+
+// This is the default value, but for the sake of explanation...
+shakeAnimation.curve = LinearAnimationCurve()
+
+let linearInstance = shakeAnimation.perform(on: view, delay: 1)
+
+/*:
+
+ Run the playground up to this point to see what the default animation looks like.
+
+ It gets the point across, but feels very flat and mechanical. Let's add some life to our animation by setting the curve
+ to an ease-in ease-out curve. This means that the animation will start out slowly (ease in), speed up in the middle,
+ then slow down at the end (ease out).
+
+ */
+
+linearInstance.cancel()
+
+shakeAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
+
+shakeAnimation.perform(on: view, delay: 1)
+
+/*:
+
+ Run the playground up to this point to see the difference.
+
+ With the simple addition of an animation curve, our animation now feels much more fluid. Check out the "Animation
+ Curves" screen in the demo app to see what each of the provided curves looks like.
+
+ */
+
+/*:
+
+ UIKit animations let you apply a curve to the whole animation. With the composibility Stagehand provides, you can apply
+ different curves to different parts of an animation.
+
+ */
+
+var childAnimation = Animation()
+
+childAnimation.addKeyframe(for: \.transform, at: 0, value: .identity)
+childAnimation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: 200, y: 0))
+
+var raceAnimation = Animation()
+
+// Animate the top view using a linear curve.
+raceAnimation.addChild(childAnimation, for: \.topView, startingAt: 0, relativeDuration: 1)
+
+// Apply the same animation to the bottom view, but using an ease in curve.
+childAnimation.curve = CubicBezierAnimationCurve.easeIn
+raceAnimation.addChild(childAnimation, for: \.bottomView, startingAt: 0, relativeDuration: 1)
+
+let raceCarView = RaceCarView(frame: .init(x: 0, y: 0, width: 300, height: 200))
+PlaygroundPage.current.liveView = raceCarView
+
+raceAnimation.perform(on: raceCarView, delay: 1)
+
+/*:
+
+ ## Creating Custom Animation Curves
+
+ Stagehand comes with a variety of animation curves built in, including the commonly used Cubic Bézier curve (with
+ support for two control points). If you need a specific animation curve for your use case, you can define a new curve
+ by conforming to the `AnimationCurve` protocol.
+
+ */
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Animation Groups.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Animation Groups.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..77b11e5
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Animation Groups.xcplaygroundpage/Contents.swift
@@ -0,0 +1,49 @@
+//: [Previous](@previous)
+
+import Stagehand
+
+/*:
+
+ # Animation Groups
+
+ Animation groups allow multiple elements to be animated together, even if they can't be accessed via key paths from a
+ single parent element. Constructing an animation group is similar to composing an animation hierarchy, except using
+ references to instances of elements rather than key paths.
+
+ */
+
+var firstAnimation = Animation()
+var firstElement = UIView()
+
+var secondAnimation = Animation()
+var secondElement = CALayer()
+
+var animationGroup = AnimationGroup()
+animationGroup.addAnimation(firstAnimation, for: firstElement, startingAt: 0, relativeDuration: 1)
+animationGroup.addAnimation(secondAnimation, for: secondElement, startingAt: 0, relativeDuration: 1)
+
+/*:
+
+ Like normal `Animation`s, we can change the `duration`, `curve`, and `repeatStyle` of our animation group as a whole.
+
+ When we're ready to perform the animation, we call the `perform(delay:completion:)` method.
+
+ */
+
+animationGroup.perform()
+
+/*:
+
+ Since animation groups need to know about the instance during construction, they break the concept of separating the
+ construction and execution of animations.
+
+ Animation groups also hold a strong reference to each of the elements they animate. It is up to consumers to cancel
+ the returned animation instance if the elements need to be deallocated before the animation completes naturally, as
+ this will not happen automatically.
+
+ For these reasons, it is generally preferrable to use an `Animation` when each of the subelements can be accessed via
+ key paths.
+
+ */
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Assigning Properties During Animations.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Assigning Properties During Animations.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..7ec1bc3
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Assigning Properties During Animations.xcplaygroundpage/Contents.swift
@@ -0,0 +1,53 @@
+//: [Previous](@previous)
+
+import PlaygroundSupport
+
+/*:
+
+ # Assigning Properties During Animations
+
+ A special case of executing code during an animation involves assigning a value to a property. As an example, let's say
+ we want to change the value of a view's `clipsToBounds` property at the half way point of our animation. Using
+ execution blocks, our animation might look something like this:
+
+ */
+
+import Stagehand
+
+var executionBlockAnimation = Animation()
+
+executionBlockAnimation.addExecution(
+ onForward: {
+ $0.clipsToBounds = true
+ },
+ at: 0.5
+)
+
+/*:
+
+ This works fine if our animation only runs in the forward direction, but what if we need to handle the reverse case?
+ Luckily, this is already handled by property assignments.
+
+ */
+
+var propertyAssignmentAnimation = Animation()
+
+propertyAssignmentAnimation.addAssignment(for: \.clipsToBounds, at: 0.5, value: true)
+
+/*:
+
+ Like the execution block above, this will set the value of `clipsToBounds` to be `true` half way through the animation.
+ When run in reverse, the property assignment will restore `clipsToBounds` to its original value for when the property
+ was assigned.
+
+ */
+
+propertyAssignmentAnimation.repeatStyle = .infinitlyRepeating(autoreversing: true)
+
+let view = ExpandedBoundsView(frame: .init(x: 0, y: 0, width: 100, height: 100))
+PlaygroundPage.current.liveView = WrapperView(wrappedView: view)
+PlaygroundPage.current.needsIndefiniteExecution = true
+
+propertyAssignmentAnimation.perform(on: view)
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Assigning Properties During Animations.xcplaygroundpage/Sources/ExpandedBoundsView.swift b/Example/Stagehand Tutorial.playground/Pages/Assigning Properties During Animations.xcplaygroundpage/Sources/ExpandedBoundsView.swift
new file mode 100644
index 0000000..619f8db
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Assigning Properties During Animations.xcplaygroundpage/Sources/ExpandedBoundsView.swift
@@ -0,0 +1,32 @@
+import UIKit
+
+public final class ExpandedBoundsView: UIView {
+
+ // MARK: - Life Cycle
+
+ public override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ backgroundColor = .red
+
+ bigSubview.backgroundColor = .green
+ addSubview(bigSubview)
+ }
+
+ @available(*, unavailable)
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Private Properties
+
+ private let bigSubview: UIView = .init()
+
+ // MARK: - UIView
+
+ public override func layoutSubviews() {
+ bigSubview.bounds.size = bounds.insetBy(dx: -20, dy: 10).size
+ bigSubview.center = .init(x: bounds.midX, y: bounds.midY)
+ }
+
+}
diff --git a/Example/Stagehand Tutorial.playground/Pages/Composing Animations.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Composing Animations.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..699888c
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Composing Animations.xcplaygroundpage/Contents.swift
@@ -0,0 +1,121 @@
+//: [Previous](@previous)
+
+import PlaygroundSupport
+import Stagehand
+
+/*:
+
+ # Composing Animations
+
+ One of the most powerful concepts that Stagehand introduces is the ability to compose animations. Small animations
+ (such as those that affect only a few properties of a single view) are easy to reason about as a whole, but complex
+ multi-part animations can be much more difficult.
+
+ Stagehand allows for building a hierarchy of smaller animations that can be easily reasoned about, and composing them
+ into a large animation that is executed as a single unit. This hierarchy is built up by adding child animations.
+
+ For example, say we have a view with two subviews, each of which has a series of animations going on.
+
+ */
+
+func makeFlatAnimation() -> Animation {
+ var animation = Animation()
+
+ animation.addKeyframe(for: \.topView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.topView.transform, at: 1, value: .init(translationX: 200, y: 0))
+
+ animation.addKeyframe(for: \.topView.backgroundColor, at: 0, value: .red)
+ animation.addKeyframe(for: \.topView.backgroundColor, at: 0.25, value: UIColor.red.withAlphaComponent(0.8))
+ animation.addKeyframe(for: \.topView.backgroundColor, at: 0.5, value: .red)
+ animation.addKeyframe(for: \.topView.backgroundColor, at: 0.75, value: UIColor.red.withAlphaComponent(0.8))
+ animation.addKeyframe(for: \.topView.backgroundColor, at: 1, value: .red)
+
+ animation.addKeyframe(for: \.bottomView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.bottomView.transform, at: 1, value: .init(translationX: 200, y: 0))
+
+ animation.addKeyframe(for: \.bottomView.backgroundColor, at: 0, value: .yellow)
+ animation.addKeyframe(for: \.bottomView.backgroundColor, at: 0.25, value: UIColor.yellow.withAlphaComponent(0.8))
+ animation.addKeyframe(for: \.bottomView.backgroundColor, at: 0.5, value: .yellow)
+ animation.addKeyframe(for: \.bottomView.backgroundColor, at: 0.75, value: UIColor.yellow.withAlphaComponent(0.8))
+ animation.addKeyframe(for: \.bottomView.backgroundColor, at: 1, value: .yellow)
+
+ animation.duration = 3
+
+ return animation
+}
+
+let raceCarView = RaceCarView(frame: .init(x: 0, y: 0, width: 300, height: 200))
+PlaygroundPage.current.liveView = raceCarView
+
+let flatInstance = makeFlatAnimation().perform(on: raceCarView)
+
+/*:
+
+ Run the playground up to this point to see our animation in action.
+
+ */
+
+flatInstance.cancel()
+
+/*:
+
+ Now let's write the same animation, except using the `addChild(_:)` method to compose our animation for separate
+ animations for each subview.
+
+ First, we'll make the animation for a single subview. In order to accomodate the differences in background color, we'll
+ use relative keyframes. Using concepts like relative keyframes helps to make our animations more reusable in general.
+
+ */
+
+func makeCarAnimation() -> Animation {
+ var animation = Animation()
+
+ animation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: 200, y: 0))
+
+ animation.addKeyframe(for: \.backgroundColor, at: 0, relativeValue: { $0 })
+ animation.addKeyframe(for: \.backgroundColor, at: 0.25, relativeValue: { $0?.withAlphaComponent(0.8) })
+ animation.addKeyframe(for: \.backgroundColor, at: 0.5, relativeValue: { $0 })
+ animation.addKeyframe(for: \.backgroundColor, at: 0.75, relativeValue: { $0?.withAlphaComponent(0.8) })
+ animation.addKeyframe(for: \.backgroundColor, at: 1, relativeValue: { $0 })
+
+ return animation
+}
+
+/*:
+
+ Now that we have the animation for each subview, we can compose them into the final animation.
+
+ */
+
+func makeHierarchicalAnimation() -> Animation {
+ var animation = Animation()
+
+ animation.addChild(makeCarAnimation(), for: \.topView, startingAt: 0, relativeDuration: 1)
+ animation.addChild(makeCarAnimation(), for: \.bottomView, startingAt: 0, relativeDuration: 1)
+
+ animation.duration = 3
+
+ return animation
+}
+
+let hierarchicalInstance = makeHierarchicalAnimation().perform(on: raceCarView)
+
+/*:
+
+ Run the playground up to this point to see our new animation in action. It should look exactly the same as the previous
+ (non-hierarchical) one.
+
+ What if we want to have the top view win the race to the right? We can simply modify the `relativeDuration` of the
+ first child animation to be shorter than 1, and all of the keyframes in that animation will be adjusted. No need to
+ manual change each keyframe.
+
+ We can also mix child animations with other content (like keyframes and execution blocks) in the parent animation.
+ Any values defined in the parent will override values defined in child animations.
+
+ By composing animation together, we can build complex, multi-part animations that are made of small reusable components
+ that are easy to reason about.
+
+ */
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Creating and Executing an Animation.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Creating and Executing an Animation.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..b490230
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Creating and Executing an Animation.xcplaygroundpage/Contents.swift
@@ -0,0 +1,88 @@
+//: [Previous](@previous)
+
+import PlaygroundSupport
+
+/*:
+
+ # Creating a Basic Animation in Stagehand
+
+ Constructing an animation begins by building an `Animation` - a value type that is generic over the type of element
+ that will be animated. For our example, we'll be animating a `UIView`. Our `Animation` struct holds all of the
+ information about what our animation will do.
+
+ To get started, let's create an animation and set its duration to 2 seconds.
+
+ */
+
+import Stagehand
+
+var basicAnimation = Animation()
+
+basicAnimation.duration = 2
+
+/*:
+
+ ## Using Keyframes
+
+ The easiest way to add content to our animation is by adding keyframes. Keyframes let us interpolate properties between
+ specified values over the course of the animation.
+
+ */
+
+// Start out at full opacity, then fade to 50% at the halfway point, then back to full opacity by the end.
+basicAnimation.addKeyframe(for: \.alpha, at: 0.0, value: 1.0)
+basicAnimation.addKeyframe(for: \.alpha, at: 0.5, value: 0.5)
+basicAnimation.addKeyframe(for: \.alpha, at: 1.0, value: 1.0)
+
+/*:
+
+ We can add keyframes for as many properties as we want to our animation. The order we add the keyframes doesn't matter.
+
+ */
+
+// Start and end at the original size.
+basicAnimation.addKeyframe(for: \.transform, at: 0.0, value: .identity)
+basicAnimation.addKeyframe(for: \.transform, at: 1.0, value: .identity)
+
+// At the midpoint, increase the scale by 10%.
+basicAnimation.addKeyframe(for: \.transform, at: 0.5, value: .init(scaleX: 1.1, y: 1.1))
+
+/*:
+
+ Keyframes have a lot of power (read the [All About Keyframes](All%20About%20Keyframes) page to get a taste of what all
+ they can do), but there are also other things our animation can control. Check out the
+ [Executing Code During Animations](Executing%20Code%20During%20Animations) to find out more.
+
+ */
+
+/*:
+
+ ## Executing Our Animation
+
+ Now that we've constructed our animation, it's time to execute it on a view. Note that we haven't actually created our
+ view yet! Stagehand allows for a separation of construction and execution, so we don't need to know what instance we'll
+ be animating, only what the type will be.
+
+ */
+
+let view = UIView(frame: .init(x: 0, y: 0, width: 100, height: 100))
+view.backgroundColor = .red
+PlaygroundPage.current.liveView = WrapperView(wrappedView: view)
+
+/*:
+
+ Now that we have our view ready to go, we can execute the animation. The simplest way to do this is using the
+ `perform(on:delay:completion:)` method.
+
+ */
+
+basicAnimation.perform(on: view)
+
+/*:
+
+ Read on to the [Advanced Execution of Animations](Advanced%20Execution%20of%20Animations) page to read more about the
+ power of separating construction and execution.
+
+ */
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Executing Code During Animations.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Executing Code During Animations.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..d4c10ae
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Executing Code During Animations.xcplaygroundpage/Contents.swift
@@ -0,0 +1,169 @@
+import PlaygroundSupport
+
+/*:
+
+ # Executing Code During Animations
+
+ Sometimes our animations include more than just iterpolating properties between different values. The reasons for doing
+ this are widely varied - from setting properties immediately, to adding in sounds and haptics - but the mechanism is
+ the same: we need to execute a block of code at a specific point in the animation.
+
+ */
+
+/*:
+
+ ## Applying Models
+
+ A common pattern in iOS development involves the use of view models - a representation of the data shown in a view that
+ is generated and subsequently applied to a view. We've defined a `ModelDrivenView` class in the playground sources as a
+ simple view to demonstrate how this works. The important part is that it defines a nested `Model` struct containing the
+ relevant properties and an `apply(model:)` method to apply that model.
+
+ For an example, let's say our design calls for animating a model change by fading out the view, applying the new model,
+ then fading the view back in; with a total duration of 2 seconds.
+
+ In traditional iOS animations, we might use a chain of UIView animation blocks, with the model application in the
+ completion of the first animation block. We'll end up with something like this:
+
+ */
+
+let view = ModelDrivenView(frame: .init(x: 0, y: 0, width: 100, height: 100))
+PlaygroundPage.current.liveView = view
+
+let model = ModelDrivenView.Model(backgroundColor: .green)
+
+UIView.animate(
+ withDuration: 1,
+ animations: {
+ // Start by fading out the view.
+ view.alpha = 0
+ },
+ completion: { finished in
+ guard finished else {
+ // We didn't finish the animation, so don't apply the model.
+ return
+ }
+
+ // Once the view has faded out, apply the model.
+ view.apply(model: model)
+
+ // Then begin the second half of the animation, to fade the view back in.
+ UIView.animate(
+ withDuration: 1,
+ animations: {
+ view.alpha = 1
+ }
+ )
+ }
+)
+
+/*:
+
+ Run the playground up to this point to see our animation in action.
+
+ */
+
+/*:
+
+ Beyond the usual problems with UIKit animations, chaining animations like this opens up potential for a lot of weird
+ edge cases (like the animation being cancelled in the first half, handled by the guard in the first completion).
+
+ Stagehand makes it easy to execute code during an animation, which allows us to build up the entire animation as a
+ single unit, rather than chaining animations. Our animation may be composed of smaller children, but the entire unit
+ itself will be executed together.
+
+ */
+
+import Stagehand
+
+func makeAnimation(model: ModelDrivenView.Model) -> Animation {
+ var animation = Animation()
+
+ // Fade the view out over the first half of the animation.
+ animation.addKeyframe(for: \.alpha, at: 0, value: 1)
+ animation.addKeyframe(for: \.alpha, at: 0.5, value: 0)
+
+ // At the halfway point, apply the new model.
+ animation.addExecution(
+ onForward: { view in
+ view.apply(model: model)
+ },
+ at: 0.5
+ )
+
+ // Fade the view back in over the second half of the animation.
+ animation.addKeyframe(for: \.alpha, at: 1, value: 1)
+
+ // Make the total duration of the animation 2 seconds.
+ animation.duration = 2
+
+ return animation
+}
+
+let view2 = ModelDrivenView(frame: .init(x: 0, y: 0, width: 100, height: 100))
+PlaygroundPage.current.liveView = view2
+
+let animation = makeAnimation(model: model)
+animation.perform(on: view2)
+
+/*:
+
+ Run the playground up to this point to see our animation in action.
+
+ */
+
+/*:
+
+ ## Handling Reversing Animations
+
+ Our animation works great for simple animations, but what if our animation needs to reverse? Say we want to apply the
+ model only temporarily, then return it to the previous state.
+
+ By default, execution blocks only require a block to execute when running in the forward direction (notice the
+ `onForward` label on the closure parameter). When run in reverse, an execution block with no explicitly defined reverse
+ block will no-op. In order to specify the reverse behavior, we need to specify an `onReverse` closure.
+
+ For our example, we want the original model of the view so we can re-apply it. Our `ModelDrivenView` is simple enough
+ that we have defined a computed `currentModel` property, but this could also be a stored property that's updated when a
+ new model is applied. Since our `currentModel` will be the __new__ model when the execution block is run in reverse,
+ we'll pull the current model when we create the animation.
+
+ */
+
+func makeAnimation(modelToFlash: ModelDrivenView.Model, modelToRestore: ModelDrivenView.Model) -> Animation {
+ var animation = Animation()
+
+ animation.addKeyframe(for: \.alpha, at: 0, value: 1)
+ animation.addKeyframe(for: \.alpha, at: 0.5, value: 0)
+ animation.addKeyframe(for: \.alpha, at: 1, value: 1)
+
+ // At the halfway point, apply the new model.
+ animation.addExecution(
+ onForward: { view in
+ view.apply(model: modelToFlash)
+ },
+ onReverse: { view in
+ view.apply(model: modelToRestore)
+ },
+ at: 0.5
+ )
+
+ animation.duration = 2
+ animation.repeatStyle = .repeating(count: 2, autoreversing: true)
+
+ return animation
+}
+
+let view3 = ModelDrivenView(frame: .init(x: 0, y: 0, width: 100, height: 100))
+PlaygroundPage.current.liveView = view3
+
+let reversingAnimation = makeAnimation(modelToFlash: model, modelToRestore: view3.currentModel)
+reversingAnimation.perform(on: view3)
+
+/*:
+
+ Run the playground up to this point to see our animation in action.
+
+ */
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Executing Code Every Frame.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Executing Code Every Frame.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..7373ec7
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Executing Code Every Frame.xcplaygroundpage/Contents.swift
@@ -0,0 +1,85 @@
+//: [Previous](@previous)
+
+/*:
+
+ # Executing Code Every Frame
+
+ Keyframes make it easy to interpolate properties over the course of our animation, execution blocks let us add actions
+ at specific points in the animation, and property assignments make changing properties to discrete values easy. But
+ sometimes we need something more manual, requiring us to run code every frame.
+
+ */
+
+/*:
+
+ ## The Traditional Approach
+
+ Traditionally, executing code during every render cycle can be done via a display link.
+
+ */
+
+import QuartzCore
+
+final class DisplayLinkAnimator {
+
+ // MARK: - Life Cycle
+
+ init() {
+ self.startTime = CFAbsoluteTime()
+ self.displayLink = CADisplayLink(target: self, selector: #selector(renderFrame))
+ }
+
+ // MARK: - Private Properties
+
+ private let displayLink: CADisplayLink!
+
+ private let startTime: CFTimeInterval
+
+ // MARK: - Public Methods
+
+ func start() {
+ displayLink.add(to: .current, forMode: .common)
+ }
+
+ @objc func renderFrame() {
+ // Do some stuff here.
+ }
+
+}
+
+/*:
+
+ This comes with some standard boilerplate, but is managable. The bigger problem comes when we try to mix this with
+ other animation code. Usually we end up having to get rid of other animations and render each frame manually, in order
+ to make sure everything stays in sync.
+
+
+ ## Adding Per-Frame Execution Blocks
+
+ Stagehand makes mixing traditional animation techniques (like keyframes and execution blocks) and per-frame render code
+ easy. The `Animation` struct has an `addPerFrameExecution(_:)` method to add a new block to be executed each frame.
+
+ This block is called with a context that contains a variety of data, like the element being animated and the current
+ progress into the animation.
+
+ */
+
+import Stagehand
+
+var animation = Animation()
+
+animation.addPerFrameExecution { context in
+ // The `context` contains the important things we need during our animation. Our view (the element we're animating)
+ // is available via the `context.element`:
+ _ = context.element
+}
+
+/*:
+
+ Per-frame execution blocks will be executed _after_ all other parts of the animation have been applied (interpolating
+ between keyframes, running any execution blocks, etc.). This means you can have logic in your per-frame execution block
+ that calculates values using properties that are interpolated.
+
+ */
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Repeating Animations.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Repeating Animations.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..91a531a
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Repeating Animations.xcplaygroundpage/Contents.swift
@@ -0,0 +1,61 @@
+//: [Previous](@previous)
+
+import Stagehand
+
+/*:
+
+ # Repeating Animations
+
+ By default, animations will run once before completing. Sometimes, though, we want our animation to loop through
+ multiple times, sometimes even indefinitely.
+
+ Using the `Animation.repeatStyle` property, we can control how our animation repeats.
+
+ */
+
+var animation = Animation()
+
+// The default style is to not repeat.
+animation.repeatStyle = .none
+
+/*:
+
+ To have our animation repeat one more time after it completes, we can set the repeat style to `.repeating` with a count
+ of 2 (for two total cycles of the animation).
+
+ */
+
+animation.repeatStyle = .repeating(count: 2, autoreversing: false)
+
+/*:
+
+ We can also set our animation to repeat indefinitely. With this set, our animation will only stop when it is cancelled
+ (either by calling `cancel(behavior:)` on the instance, or if the element it is animating is deallocated).
+
+ */
+
+animation.repeatStyle = .infinitelyRepeating(autoreversing: false)
+
+/*:
+
+ The `autoreversing` parameter determines whether alternating cycles are run in opposite directions. Typically,
+ animations that start and end in the same state (e.g. a spinner) will use `false`. Other animations may not reverse,
+ but many will.
+
+ */
+
+/*:
+
+ ## Handling Reverse Animations
+
+ When we do use reversing animations, we need to make sure our animation is set up to handle a reverse cycle.
+
+ Keyframes, property assignments, and per-frame execution blocks have support for reverse cycles built in. With standard
+ execution blocks, the default behavior on a reverse cycle is to no-op. If this is not the expected behavior, we need to
+ provide a closure to run on the reverse cycles.
+
+ Read on to the [Executing Code During Animations](Executing%20Code%20During%20Animations) for more details.
+
+ */
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Pages/Snapshot Testing Animations.xcplaygroundpage/Contents.swift b/Example/Stagehand Tutorial.playground/Pages/Snapshot Testing Animations.xcplaygroundpage/Contents.swift
new file mode 100644
index 0000000..4c73a61
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Pages/Snapshot Testing Animations.xcplaygroundpage/Contents.swift
@@ -0,0 +1,70 @@
+//: [Previous](@previous)
+
+import Stagehand
+import StagehandTesting
+import UIKit
+
+/*:
+
+ # Snapshot Testing Animations
+
+ Once we've written our animation, it's important to ensure it doesn't regress as other changes are made. To accomplish
+ this, Stagehand ships with a second framework, StagehandTesting, to enable writing snapshot tests for animations.
+
+ Animation snapshot tests are built on top of the iOSSnapshotTestCase framework, so all of the same setup and behaviors
+ will apply. To write an animation snapshot test, simply set up your animation and view, and call one of the snapshot
+ verification methods.
+
+ */
+
+/*:
+
+ To snapshot an animation at a single frame, use `SnapshotVerify(animation:on:at:)`:
+
+ */
+
+var animation = Animation()
+
+var view = UIView()
+
+// Snapshot the animation at the midpoint.
+SnapshotVerify(
+ animation: animation,
+ view: view,
+ at: 0.5
+)
+
+/*:
+
+ A similar method exists for snapshotting animation groups. In this case, you must also provide a view with which the
+ animation can be verified, since the group doesn't know what the parent view of its elements is.
+
+ */
+
+var animationGroup = AnimationGroup()
+
+SnapshotVerify(
+ animationGroup: animationGroup,
+ using: view,
+ at: 0.5
+)
+
+/*:
+
+ Beyond static snapshots of a specific frame, StagehandTesting can output animated GIFs of the entire animation.
+
+ */
+
+SnapshotVerify(
+ animation: animation,
+ on: view
+)
+
+/*:
+
+ This methods has a few other parameters, such as `fps` and `bookendFrameDuration`, that can be used to tailor the
+ output to your needs. Check out the header docs for that method for more details.
+
+ */
+
+//: [Next](@next)
diff --git a/Example/Stagehand Tutorial.playground/Sources/AnimationFactory.swift b/Example/Stagehand Tutorial.playground/Sources/AnimationFactory.swift
new file mode 100644
index 0000000..ac779aa
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Sources/AnimationFactory.swift
@@ -0,0 +1,19 @@
+import Stagehand
+
+public enum AnimationFactory {
+
+ public static func makeBasicViewAnimation() -> Animation {
+ var animation = Animation()
+
+ animation.addKeyframe(for: \.alpha, at: 0, value: 1)
+ animation.addKeyframe(for: \.alpha, at: 0.5, value: 0.5)
+ animation.addKeyframe(for: \.alpha, at: 1, value: 1)
+
+ animation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.transform, at: 0.5, value: .init(scaleX: 1.1, y: 1.1))
+ animation.addKeyframe(for: \.transform, at: 1, value: .identity)
+
+ return animation
+ }
+
+}
diff --git a/Example/Stagehand Tutorial.playground/Sources/ModelDrivenView.swift b/Example/Stagehand Tutorial.playground/Sources/ModelDrivenView.swift
new file mode 100644
index 0000000..b6d99c5
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Sources/ModelDrivenView.swift
@@ -0,0 +1,50 @@
+import UIKit
+
+public final class ModelDrivenView: UIView {
+
+ // MARK: - Life Cycle
+
+ public override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ backgroundColor = .red
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Types
+
+ public struct Model {
+
+ // MARK: - Life Cycle
+
+ public init(
+ backgroundColor: UIColor?
+ ) {
+ self.backgroundColor = backgroundColor
+ }
+
+ // MARK: - Public Properties
+
+ public var backgroundColor: UIColor?
+
+ }
+
+ // MARK: - Public Properties
+
+ public var currentModel: Model {
+ return .init(
+ backgroundColor: backgroundColor
+ )
+ }
+
+ // MARK: - Public Methods
+
+ public func apply(model: Model) {
+ backgroundColor = model.backgroundColor
+ }
+
+}
diff --git a/Example/Stagehand Tutorial.playground/Sources/RaceCarView.swift b/Example/Stagehand Tutorial.playground/Sources/RaceCarView.swift
new file mode 100644
index 0000000..9bf213b
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Sources/RaceCarView.swift
@@ -0,0 +1,45 @@
+import UIKit
+
+public final class RaceCarView: UIView {
+
+ // MARK: - Life Cycle
+
+ public override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ topView.backgroundColor = .red
+ addSubview(topView)
+
+ bottomView.backgroundColor = .yellow
+ addSubview(bottomView)
+ }
+
+ @available(*, unavailable)
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ public var topView: UIView = .init()
+
+ public var bottomView: UIView = .init()
+
+ // MARK: - UIView
+
+ public override func layoutSubviews() {
+ let subviewSize = bounds.height / 4
+
+ topView.bounds.size = .init(width: subviewSize, height: subviewSize)
+ topView.center = .init(
+ x: bounds.minX + subviewSize,
+ y: bounds.height / 3
+ )
+
+ bottomView.bounds.size = .init(width: subviewSize, height: subviewSize)
+ bottomView.center = .init(
+ x: bounds.minX + subviewSize,
+ y: bounds.height * 2 / 3
+ )
+ }
+}
diff --git a/Example/Stagehand Tutorial.playground/Sources/WrapperView.swift b/Example/Stagehand Tutorial.playground/Sources/WrapperView.swift
new file mode 100644
index 0000000..1c52fe2
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/Sources/WrapperView.swift
@@ -0,0 +1,37 @@
+import UIKit
+
+public final class WrapperView: UIView {
+
+ // MARK: - Life Cycle
+
+ public init(wrappedView: UIView, outset: CGFloat = 20) {
+ self.wrappedView = wrappedView
+
+ super.init(
+ frame: .init(
+ x: 0,
+ y: 0,
+ width: wrappedView.bounds.width + 2 * outset,
+ height: wrappedView.bounds.height + 2 * outset
+ )
+ )
+
+ addSubview(wrappedView)
+ }
+
+ @available(*, unavailable)
+ public required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Private Properties
+
+ private let wrappedView: UIView
+
+ // MARK: - UIView
+
+ public override func layoutSubviews() {
+ wrappedView.center = .init(x: bounds.midX, y: bounds.midY)
+ }
+
+}
diff --git a/Example/Stagehand Tutorial.playground/contents.xcplayground b/Example/Stagehand Tutorial.playground/contents.xcplayground
new file mode 100644
index 0000000..3ee7f7c
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/contents.xcplayground
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Example/Stagehand Tutorial.playground/playground.xcworkspace/contents.xcworkspacedata b/Example/Stagehand Tutorial.playground/playground.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/playground.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Example/Stagehand Tutorial.playground/playground.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/Stagehand Tutorial.playground/playground.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/Example/Stagehand Tutorial.playground/playground.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/Example/Stagehand.xcodeproj/project.pbxproj b/Example/Stagehand.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..2825af7
--- /dev/null
+++ b/Example/Stagehand.xcodeproj/project.pbxproj
@@ -0,0 +1,885 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 46;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 3D1D304A22F007B7003E392C /* ChildAnimationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1D304922F007B7003E392C /* ChildAnimationsViewController.swift */; };
+ 3D1D304C22F01136003E392C /* AnimationCurveViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1D304B22F01136003E392C /* AnimationCurveViewController.swift */; };
+ 3D2712EE236A17C5001D3B4B /* AnimationQueueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2712ED236A17C5001D3B4B /* AnimationQueueViewController.swift */; };
+ 3D32EF4623404F98001144B3 /* AnimationCurvePerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D32EF362340449B001144B3 /* AnimationCurvePerformanceTests.swift */; };
+ 3D32EF4B234058C7001144B3 /* AnimationOptimizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D32EF49234058C7001144B3 /* AnimationOptimizationTests.swift */; };
+ 3D75A7BD228FED9900743166 /* AnimationFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D75A7BC228FED9900743166 /* AnimationFactory.swift */; };
+ 3D75A7C3229284C600743166 /* DemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D75A7C2229284C600743166 /* DemoViewController.swift */; };
+ 3D75A7C62292892B00743166 /* SimpleAnimationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D75A7C52292892B00743166 /* SimpleAnimationsViewController.swift */; };
+ 3D75A7C82292910B00743166 /* RelativeAnimationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D75A7C72292910B00743166 /* RelativeAnimationsViewController.swift */; };
+ 3D7D500023011D4400A468A7 /* AnimationInstanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7D4FFE23011C4600A468A7 /* AnimationInstanceTests.swift */; };
+ 3D7E79AB239FC1F8008BF8FF /* DisplayLinkDriverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7E79AA239FC1F8008BF8FF /* DisplayLinkDriverTests.swift */; };
+ 3D875F002323542500670803 /* PerformanceBenchmarkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D875EFF2323542500670803 /* PerformanceBenchmarkViewController.swift */; };
+ 3D89F1E222FDEAAC008AC33E /* PropertyAssignmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D89F1E122FDEAAC008AC33E /* PropertyAssignmentViewController.swift */; };
+ 3D8E3D8022F16C1B00D70FCB /* ChildAnimationsWithCurvesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8E3D7F22F16C1B00D70FCB /* ChildAnimationsWithCurvesViewController.swift */; };
+ 3D8E3D8222F17D3B00D70FCB /* AnimationCancelationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8E3D8122F17D3B00D70FCB /* AnimationCancelationViewController.swift */; };
+ 3D8E3D8422F2BC9300D70FCB /* RepeatingAnimationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8E3D8322F2BC9300D70FCB /* RepeatingAnimationsViewController.swift */; };
+ 3DA3997C230FCFAC00DE41A0 /* ExecutionBlockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA3997B230FCFAC00DE41A0 /* ExecutionBlockViewController.swift */; };
+ 3DA5EB7C2318E64A001DF944 /* CollectionKeyframesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA5EB7B2318E64A001DF944 /* CollectionKeyframesViewController.swift */; };
+ 3DB3927D23249D680009E8B3 /* ColorAnimationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB3927C23249D680009E8B3 /* ColorAnimationsViewController.swift */; };
+ 3DC75494232D82FD00402BD9 /* ChildAnimationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC75492232D819700402BD9 /* ChildAnimationTests.swift */; };
+ 3DC75497232F6D5500402BD9 /* TestDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC75496232F6D5500402BD9 /* TestDriver.swift */; };
+ 3DEE440A231331DD0057D796 /* AnimationGroupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DEE4409231331DD0057D796 /* AnimationGroupViewController.swift */; };
+ 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; };
+ 607FACD81AFB9204008FA782 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD71AFB9204008FA782 /* RootViewController.swift */; };
+ 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; };
+ 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; };
+ 607FACEC1AFB9204008FA782 /* AnimationSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACEB1AFB9204008FA782 /* AnimationSnapshotTests.swift */; };
+ 7BF94D6BF3E507D571EE88FE /* Pods_Stagehand_PerformanceTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CCC78EBA87C54F6184E964D /* Pods_Stagehand_PerformanceTests.framework */; };
+ 92777BD5AB23DAFC15E297A3 /* Pods_Stagehand_UnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F058A67C239ED37BC34BE76C /* Pods_Stagehand_UnitTests.framework */; };
+ AEFF0D89739204C18F94EF22 /* Pods_Stagehand_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1575429BE6BAD827FC19AE22 /* Pods_Stagehand_Example.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 3D32EF4123404F6A001144B3 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 607FACC81AFB9204008FA782 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 607FACCF1AFB9204008FA782;
+ remoteInfo = Stagehand_Example;
+ };
+ 607FACE61AFB9204008FA782 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 607FACC81AFB9204008FA782 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 607FACCF1AFB9204008FA782;
+ remoteInfo = Stagehand;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+ 1575429BE6BAD827FC19AE22 /* Pods_Stagehand_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Stagehand_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 1CCC78EBA87C54F6184E964D /* Pods_Stagehand_PerformanceTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Stagehand_PerformanceTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 37FA0E6FE8F29161EE1380A2 /* Pods-Stagehand_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stagehand_Tests.release.xcconfig"; path = "Target Support Files/Pods-Stagehand_Tests/Pods-Stagehand_Tests.release.xcconfig"; sourceTree = ""; };
+ 3D1D304922F007B7003E392C /* ChildAnimationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildAnimationsViewController.swift; sourceTree = ""; };
+ 3D1D304B22F01136003E392C /* AnimationCurveViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationCurveViewController.swift; sourceTree = ""; };
+ 3D2712ED236A17C5001D3B4B /* AnimationQueueViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationQueueViewController.swift; sourceTree = ""; };
+ 3D32EF362340449B001144B3 /* AnimationCurvePerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationCurvePerformanceTests.swift; sourceTree = ""; };
+ 3D32EF3C23404F6A001144B3 /* Stagehand-PerformanceTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Stagehand-PerformanceTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3D32EF4023404F6A001144B3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 3D32EF49234058C7001144B3 /* AnimationOptimizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationOptimizationTests.swift; sourceTree = ""; };
+ 3D75A7BC228FED9900743166 /* AnimationFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationFactory.swift; sourceTree = ""; };
+ 3D75A7C2229284C600743166 /* DemoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoViewController.swift; sourceTree = ""; };
+ 3D75A7C52292892B00743166 /* SimpleAnimationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleAnimationsViewController.swift; sourceTree = ""; };
+ 3D75A7C72292910B00743166 /* RelativeAnimationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelativeAnimationsViewController.swift; sourceTree = ""; };
+ 3D7D4FFE23011C4600A468A7 /* AnimationInstanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationInstanceTests.swift; sourceTree = ""; };
+ 3D7E79AA239FC1F8008BF8FF /* DisplayLinkDriverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLinkDriverTests.swift; sourceTree = ""; };
+ 3D875EFF2323542500670803 /* PerformanceBenchmarkViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceBenchmarkViewController.swift; sourceTree = ""; };
+ 3D89F1E122FDEAAC008AC33E /* PropertyAssignmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyAssignmentViewController.swift; sourceTree = ""; };
+ 3D8E3D7F22F16C1B00D70FCB /* ChildAnimationsWithCurvesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildAnimationsWithCurvesViewController.swift; sourceTree = ""; };
+ 3D8E3D8122F17D3B00D70FCB /* AnimationCancelationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationCancelationViewController.swift; sourceTree = ""; };
+ 3D8E3D8322F2BC9300D70FCB /* RepeatingAnimationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingAnimationsViewController.swift; sourceTree = ""; };
+ 3DA3997B230FCFAC00DE41A0 /* ExecutionBlockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionBlockViewController.swift; sourceTree = ""; };
+ 3DA5EB7B2318E64A001DF944 /* CollectionKeyframesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionKeyframesViewController.swift; sourceTree = ""; };
+ 3DB3927C23249D680009E8B3 /* ColorAnimationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorAnimationsViewController.swift; sourceTree = ""; };
+ 3DC75492232D819700402BD9 /* ChildAnimationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildAnimationTests.swift; sourceTree = ""; };
+ 3DC75496232F6D5500402BD9 /* TestDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDriver.swift; sourceTree = ""; };
+ 3DEE4409231331DD0057D796 /* AnimationGroupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationGroupViewController.swift; sourceTree = ""; };
+ 3EEB62F0CC3F471BC6454D87 /* Pods-Stagehand_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stagehand_Example.debug.xcconfig"; path = "Target Support Files/Pods-Stagehand_Example/Pods-Stagehand_Example.debug.xcconfig"; sourceTree = ""; };
+ 472E62FC731AD89405017C59 /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = ""; };
+ 4B84A3658CDA0BD762380354 /* Pods-Stagehand_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stagehand_Tests.debug.xcconfig"; path = "Target Support Files/Pods-Stagehand_Tests/Pods-Stagehand_Tests.debug.xcconfig"; sourceTree = ""; };
+ 607FACD01AFB9204008FA782 /* Stagehand_Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Stagehand_Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 607FACD41AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 607FACD51AFB9204008FA782 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 607FACD71AFB9204008FA782 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = ""; };
+ 607FACDC1AFB9204008FA782 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; };
+ 607FACDF1AFB9204008FA782 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; };
+ 607FACE51AFB9204008FA782 /* Stagehand-UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Stagehand-UnitTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 607FACEB1AFB9204008FA782 /* AnimationSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationSnapshotTests.swift; sourceTree = ""; };
+ 672AA25F84AC512FBA540287 /* Pods_Stagehand_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Stagehand_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 8A7CDB5C6219B625BB9E7F41 /* Stagehand.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = Stagehand.podspec; path = ../Stagehand.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; };
+ A9CF2FF630BA8BCCB209601B /* Pods-Stagehand-UnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stagehand-UnitTests.debug.xcconfig"; path = "Target Support Files/Pods-Stagehand-UnitTests/Pods-Stagehand-UnitTests.debug.xcconfig"; sourceTree = ""; };
+ AD24733DD14E922142707F28 /* Pods-Stagehand-PerformanceTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stagehand-PerformanceTests.release.xcconfig"; path = "Target Support Files/Pods-Stagehand-PerformanceTests/Pods-Stagehand-PerformanceTests.release.xcconfig"; sourceTree = ""; };
+ B5D8077CC460626597AB790F /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; };
+ B64913526970D210595894B7 /* Pods-Stagehand-PerformanceTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stagehand-PerformanceTests.debug.xcconfig"; path = "Target Support Files/Pods-Stagehand-PerformanceTests/Pods-Stagehand-PerformanceTests.debug.xcconfig"; sourceTree = ""; };
+ B657CC2F9B1F03D85F9BFF96 /* Pods-Stagehand_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stagehand_Example.release.xcconfig"; path = "Target Support Files/Pods-Stagehand_Example/Pods-Stagehand_Example.release.xcconfig"; sourceTree = ""; };
+ F058A67C239ED37BC34BE76C /* Pods_Stagehand_UnitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Stagehand_UnitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ FC42083017E18C3B5F030B47 /* Pods-Stagehand-UnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stagehand-UnitTests.release.xcconfig"; path = "Target Support Files/Pods-Stagehand-UnitTests/Pods-Stagehand-UnitTests.release.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 3D32EF3923404F6A001144B3 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 7BF94D6BF3E507D571EE88FE /* Pods_Stagehand_PerformanceTests.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 607FACCD1AFB9204008FA782 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ AEFF0D89739204C18F94EF22 /* Pods_Stagehand_Example.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 607FACE21AFB9204008FA782 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 92777BD5AB23DAFC15E297A3 /* Pods_Stagehand_UnitTests.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 3D1BA3B2233EC7FE00FC8DB3 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ 607FACDC1AFB9204008FA782 /* Images.xcassets */,
+ 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */,
+ );
+ name = Resources;
+ sourceTree = "";
+ };
+ 3D32EF3D23404F6A001144B3 /* Performance Tests */ = {
+ isa = PBXGroup;
+ children = (
+ 3D32EF362340449B001144B3 /* AnimationCurvePerformanceTests.swift */,
+ 3D32EF4023404F6A001144B3 /* Info.plist */,
+ );
+ path = "Performance Tests";
+ sourceTree = "";
+ };
+ 3D75A7C42292891100743166 /* Demo View Controllers */ = {
+ isa = PBXGroup;
+ children = (
+ 3D75A7C52292892B00743166 /* SimpleAnimationsViewController.swift */,
+ 3D75A7C72292910B00743166 /* RelativeAnimationsViewController.swift */,
+ 3DB3927C23249D680009E8B3 /* ColorAnimationsViewController.swift */,
+ 3D1D304922F007B7003E392C /* ChildAnimationsViewController.swift */,
+ 3D1D304B22F01136003E392C /* AnimationCurveViewController.swift */,
+ 3D8E3D7F22F16C1B00D70FCB /* ChildAnimationsWithCurvesViewController.swift */,
+ 3D8E3D8122F17D3B00D70FCB /* AnimationCancelationViewController.swift */,
+ 3D89F1E122FDEAAC008AC33E /* PropertyAssignmentViewController.swift */,
+ 3D8E3D8322F2BC9300D70FCB /* RepeatingAnimationsViewController.swift */,
+ 3DA3997B230FCFAC00DE41A0 /* ExecutionBlockViewController.swift */,
+ 3DEE4409231331DD0057D796 /* AnimationGroupViewController.swift */,
+ 3DA5EB7B2318E64A001DF944 /* CollectionKeyframesViewController.swift */,
+ 3D875EFF2323542500670803 /* PerformanceBenchmarkViewController.swift */,
+ 3D2712ED236A17C5001D3B4B /* AnimationQueueViewController.swift */,
+ );
+ name = "Demo View Controllers";
+ sourceTree = "";
+ };
+ 3DC75495232F6D3800402BD9 /* Utilities */ = {
+ isa = PBXGroup;
+ children = (
+ 3DC75496232F6D5500402BD9 /* TestDriver.swift */,
+ );
+ name = Utilities;
+ sourceTree = "";
+ };
+ 551ED7281AD3CB63D20C62B2 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 3EEB62F0CC3F471BC6454D87 /* Pods-Stagehand_Example.debug.xcconfig */,
+ B657CC2F9B1F03D85F9BFF96 /* Pods-Stagehand_Example.release.xcconfig */,
+ 4B84A3658CDA0BD762380354 /* Pods-Stagehand_Tests.debug.xcconfig */,
+ 37FA0E6FE8F29161EE1380A2 /* Pods-Stagehand_Tests.release.xcconfig */,
+ B64913526970D210595894B7 /* Pods-Stagehand-PerformanceTests.debug.xcconfig */,
+ AD24733DD14E922142707F28 /* Pods-Stagehand-PerformanceTests.release.xcconfig */,
+ A9CF2FF630BA8BCCB209601B /* Pods-Stagehand-UnitTests.debug.xcconfig */,
+ FC42083017E18C3B5F030B47 /* Pods-Stagehand-UnitTests.release.xcconfig */,
+ );
+ path = Pods;
+ sourceTree = "";
+ };
+ 5FC52ACEA562125A65E21CFB /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 1575429BE6BAD827FC19AE22 /* Pods_Stagehand_Example.framework */,
+ 672AA25F84AC512FBA540287 /* Pods_Stagehand_Tests.framework */,
+ 1CCC78EBA87C54F6184E964D /* Pods_Stagehand_PerformanceTests.framework */,
+ F058A67C239ED37BC34BE76C /* Pods_Stagehand_UnitTests.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 607FACC71AFB9204008FA782 = {
+ isa = PBXGroup;
+ children = (
+ 607FACD21AFB9204008FA782 /* Stagehand Demo */,
+ 607FACE81AFB9204008FA782 /* Unit Tests */,
+ 3D32EF3D23404F6A001144B3 /* Performance Tests */,
+ 607FACF51AFB993E008FA782 /* Podspec Metadata */,
+ 607FACD11AFB9204008FA782 /* Products */,
+ 551ED7281AD3CB63D20C62B2 /* Pods */,
+ 5FC52ACEA562125A65E21CFB /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 607FACD11AFB9204008FA782 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 607FACD01AFB9204008FA782 /* Stagehand_Example.app */,
+ 607FACE51AFB9204008FA782 /* Stagehand-UnitTests.xctest */,
+ 3D32EF3C23404F6A001144B3 /* Stagehand-PerformanceTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 607FACD21AFB9204008FA782 /* Stagehand Demo */ = {
+ isa = PBXGroup;
+ children = (
+ 607FACD51AFB9204008FA782 /* AppDelegate.swift */,
+ 607FACD71AFB9204008FA782 /* RootViewController.swift */,
+ 3D75A7C2229284C600743166 /* DemoViewController.swift */,
+ 3D75A7BC228FED9900743166 /* AnimationFactory.swift */,
+ 3D75A7C42292891100743166 /* Demo View Controllers */,
+ 3D1BA3B2233EC7FE00FC8DB3 /* Resources */,
+ 607FACD31AFB9204008FA782 /* Supporting Files */,
+ );
+ name = "Stagehand Demo";
+ path = Stagehand;
+ sourceTree = "";
+ };
+ 607FACD31AFB9204008FA782 /* Supporting Files */ = {
+ isa = PBXGroup;
+ children = (
+ 607FACD41AFB9204008FA782 /* Info.plist */,
+ );
+ name = "Supporting Files";
+ sourceTree = "";
+ };
+ 607FACE81AFB9204008FA782 /* Unit Tests */ = {
+ isa = PBXGroup;
+ children = (
+ 3D7D4FFE23011C4600A468A7 /* AnimationInstanceTests.swift */,
+ 3D32EF49234058C7001144B3 /* AnimationOptimizationTests.swift */,
+ 607FACEB1AFB9204008FA782 /* AnimationSnapshotTests.swift */,
+ 3DC75492232D819700402BD9 /* ChildAnimationTests.swift */,
+ 3D7E79AA239FC1F8008BF8FF /* DisplayLinkDriverTests.swift */,
+ 3DC75495232F6D3800402BD9 /* Utilities */,
+ 607FACE91AFB9204008FA782 /* Supporting Files */,
+ );
+ path = "Unit Tests";
+ sourceTree = "";
+ };
+ 607FACE91AFB9204008FA782 /* Supporting Files */ = {
+ isa = PBXGroup;
+ children = (
+ 607FACEA1AFB9204008FA782 /* Info.plist */,
+ );
+ name = "Supporting Files";
+ sourceTree = "";
+ };
+ 607FACF51AFB993E008FA782 /* Podspec Metadata */ = {
+ isa = PBXGroup;
+ children = (
+ 8A7CDB5C6219B625BB9E7F41 /* Stagehand.podspec */,
+ B5D8077CC460626597AB790F /* README.md */,
+ 472E62FC731AD89405017C59 /* LICENSE */,
+ );
+ name = "Podspec Metadata";
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 3D32EF3B23404F6A001144B3 /* Stagehand-PerformanceTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 3D32EF4323404F6A001144B3 /* Build configuration list for PBXNativeTarget "Stagehand-PerformanceTests" */;
+ buildPhases = (
+ 18A4C2CD04922F25308BFB62 /* [CP] Check Pods Manifest.lock */,
+ 3D32EF3823404F6A001144B3 /* Sources */,
+ 3D32EF3923404F6A001144B3 /* Frameworks */,
+ 3D32EF3A23404F6A001144B3 /* Resources */,
+ 041E99D2130DECAED3FDD5DE /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 3D32EF4223404F6A001144B3 /* PBXTargetDependency */,
+ );
+ name = "Stagehand-PerformanceTests";
+ productName = "Stagehand-PerformanceTests";
+ productReference = 3D32EF3C23404F6A001144B3 /* Stagehand-PerformanceTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 607FACCF1AFB9204008FA782 /* Stagehand_Example */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 607FACEF1AFB9204008FA782 /* Build configuration list for PBXNativeTarget "Stagehand_Example" */;
+ buildPhases = (
+ B6293AAED6C3D8F888209F12 /* [CP] Check Pods Manifest.lock */,
+ 607FACCC1AFB9204008FA782 /* Sources */,
+ 607FACCD1AFB9204008FA782 /* Frameworks */,
+ 607FACCE1AFB9204008FA782 /* Resources */,
+ 7AC1DD361DEB9F88378EFA0A /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Stagehand_Example;
+ productName = Stagehand;
+ productReference = 607FACD01AFB9204008FA782 /* Stagehand_Example.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 607FACE41AFB9204008FA782 /* Stagehand-UnitTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 607FACF21AFB9204008FA782 /* Build configuration list for PBXNativeTarget "Stagehand-UnitTests" */;
+ buildPhases = (
+ 99684048AB33959895A6198A /* [CP] Check Pods Manifest.lock */,
+ 607FACE11AFB9204008FA782 /* Sources */,
+ 607FACE21AFB9204008FA782 /* Frameworks */,
+ 607FACE31AFB9204008FA782 /* Resources */,
+ 4CDA689D698CE5D94E164DD5 /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 607FACE71AFB9204008FA782 /* PBXTargetDependency */,
+ );
+ name = "Stagehand-UnitTests";
+ productName = Tests;
+ productReference = 607FACE51AFB9204008FA782 /* Stagehand-UnitTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 607FACC81AFB9204008FA782 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ LastSwiftUpdateCheck = 1020;
+ LastUpgradeCheck = 1020;
+ ORGANIZATIONNAME = CocoaPods;
+ TargetAttributes = {
+ 3D32EF3B23404F6A001144B3 = {
+ CreatedOnToolsVersion = 10.2.1;
+ ProvisioningStyle = Automatic;
+ TestTargetID = 607FACCF1AFB9204008FA782;
+ };
+ 607FACCF1AFB9204008FA782 = {
+ CreatedOnToolsVersion = 6.3.1;
+ DevelopmentTeam = 6385SJ58J2;
+ LastSwiftMigration = 1020;
+ };
+ 607FACE41AFB9204008FA782 = {
+ CreatedOnToolsVersion = 6.3.1;
+ DevelopmentTeam = 6385SJ58J2;
+ LastSwiftMigration = 1020;
+ TestTargetID = 607FACCF1AFB9204008FA782;
+ };
+ };
+ };
+ buildConfigurationList = 607FACCB1AFB9204008FA782 /* Build configuration list for PBXProject "Stagehand" */;
+ compatibilityVersion = "Xcode 3.2";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 607FACC71AFB9204008FA782;
+ productRefGroup = 607FACD11AFB9204008FA782 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 607FACCF1AFB9204008FA782 /* Stagehand_Example */,
+ 607FACE41AFB9204008FA782 /* Stagehand-UnitTests */,
+ 3D32EF3B23404F6A001144B3 /* Stagehand-PerformanceTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 3D32EF3A23404F6A001144B3 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 607FACCE1AFB9204008FA782 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */,
+ 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 607FACE31AFB9204008FA782 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 041E99D2130DECAED3FDD5DE /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Stagehand-PerformanceTests/Pods-Stagehand-PerformanceTests-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 18A4C2CD04922F25308BFB62 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Stagehand-PerformanceTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 4CDA689D698CE5D94E164DD5 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Stagehand-UnitTests/Pods-Stagehand-UnitTests-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 7AC1DD361DEB9F88378EFA0A /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Stagehand_Example/Pods-Stagehand_Example-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 99684048AB33959895A6198A /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Stagehand-UnitTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ B6293AAED6C3D8F888209F12 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Stagehand_Example-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 3D32EF3823404F6A001144B3 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3D32EF4623404F98001144B3 /* AnimationCurvePerformanceTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 607FACCC1AFB9204008FA782 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3D75A7C62292892B00743166 /* SimpleAnimationsViewController.swift in Sources */,
+ 3D89F1E222FDEAAC008AC33E /* PropertyAssignmentViewController.swift in Sources */,
+ 607FACD81AFB9204008FA782 /* RootViewController.swift in Sources */,
+ 3DA5EB7C2318E64A001DF944 /* CollectionKeyframesViewController.swift in Sources */,
+ 3DA3997C230FCFAC00DE41A0 /* ExecutionBlockViewController.swift in Sources */,
+ 3D2712EE236A17C5001D3B4B /* AnimationQueueViewController.swift in Sources */,
+ 3D8E3D8222F17D3B00D70FCB /* AnimationCancelationViewController.swift in Sources */,
+ 3DEE440A231331DD0057D796 /* AnimationGroupViewController.swift in Sources */,
+ 3D1D304A22F007B7003E392C /* ChildAnimationsViewController.swift in Sources */,
+ 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */,
+ 3D8E3D8422F2BC9300D70FCB /* RepeatingAnimationsViewController.swift in Sources */,
+ 3D8E3D8022F16C1B00D70FCB /* ChildAnimationsWithCurvesViewController.swift in Sources */,
+ 3DB3927D23249D680009E8B3 /* ColorAnimationsViewController.swift in Sources */,
+ 3D875F002323542500670803 /* PerformanceBenchmarkViewController.swift in Sources */,
+ 3D75A7BD228FED9900743166 /* AnimationFactory.swift in Sources */,
+ 3D1D304C22F01136003E392C /* AnimationCurveViewController.swift in Sources */,
+ 3D75A7C3229284C600743166 /* DemoViewController.swift in Sources */,
+ 3D75A7C82292910B00743166 /* RelativeAnimationsViewController.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 607FACE11AFB9204008FA782 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 3DC75497232F6D5500402BD9 /* TestDriver.swift in Sources */,
+ 607FACEC1AFB9204008FA782 /* AnimationSnapshotTests.swift in Sources */,
+ 3D7D500023011D4400A468A7 /* AnimationInstanceTests.swift in Sources */,
+ 3DC75494232D82FD00402BD9 /* ChildAnimationTests.swift in Sources */,
+ 3D7E79AB239FC1F8008BF8FF /* DisplayLinkDriverTests.swift in Sources */,
+ 3D32EF4B234058C7001144B3 /* AnimationOptimizationTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 3D32EF4223404F6A001144B3 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 607FACCF1AFB9204008FA782 /* Stagehand_Example */;
+ targetProxy = 3D32EF4123404F6A001144B3 /* PBXContainerItemProxy */;
+ };
+ 607FACE71AFB9204008FA782 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 607FACCF1AFB9204008FA782 /* Stagehand_Example */;
+ targetProxy = 607FACE61AFB9204008FA782 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 607FACDF1AFB9204008FA782 /* Base */,
+ );
+ name = LaunchScreen.xib;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 3D32EF4423404F6A001144B3 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = B64913526970D210595894B7 /* Pods-Stagehand-PerformanceTests.debug.xcconfig */;
+ buildSettings = {
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ CODE_SIGN_STYLE = Automatic;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ INFOPLIST_FILE = "Performance Tests/Info.plist";
+ IPHONEOS_DEPLOYMENT_TARGET = 12.2;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.Stagehand-PerformanceTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Stagehand_Example.app/Stagehand_Example";
+ };
+ name = Debug;
+ };
+ 3D32EF4523404F6A001144B3 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = AD24733DD14E922142707F28 /* Pods-Stagehand-PerformanceTests.release.xcconfig */;
+ buildSettings = {
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ CODE_SIGN_STYLE = Automatic;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ INFOPLIST_FILE = "Performance Tests/Info.plist";
+ IPHONEOS_DEPLOYMENT_TARGET = 12.2;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.squareup.Stagehand-PerformanceTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Stagehand_Example.app/Stagehand_Example";
+ };
+ name = Release;
+ };
+ 607FACED1AFB9204008FA782 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_SYMBOLS_PRIVATE_EXTERN = NO;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 10.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 607FACEE1AFB9204008FA782 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 10.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 607FACF01AFB9204008FA782 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 3EEB62F0CC3F471BC6454D87 /* Pods-Stagehand_Example.debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ DEVELOPMENT_TEAM = 6385SJ58J2;
+ INFOPLIST_FILE = Stagehand/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 10.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ MODULE_NAME = ExampleApp;
+ PRODUCT_BUNDLE_IDENTIFIER = com.squareup.StagehandDemo;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 607FACF11AFB9204008FA782 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = B657CC2F9B1F03D85F9BFF96 /* Pods-Stagehand_Example.release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ DEVELOPMENT_TEAM = 6385SJ58J2;
+ INFOPLIST_FILE = Stagehand/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 10.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ MODULE_NAME = ExampleApp;
+ PRODUCT_BUNDLE_IDENTIFIER = com.squareup.StagehandDemo;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ 607FACF31AFB9204008FA782 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = A9CF2FF630BA8BCCB209601B /* Pods-Stagehand-UnitTests.debug.xcconfig */;
+ buildSettings = {
+ DEVELOPMENT_TEAM = 6385SJ58J2;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(SDKROOT)/Developer/Library/Frameworks",
+ "$(inherited)",
+ );
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ INFOPLIST_FILE = "Unit Tests/Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.$(PRODUCT_NAME:rfc1034identifier)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Stagehand_Example.app/Stagehand_Example";
+ };
+ name = Debug;
+ };
+ 607FACF41AFB9204008FA782 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = FC42083017E18C3B5F030B47 /* Pods-Stagehand-UnitTests.release.xcconfig */;
+ buildSettings = {
+ DEVELOPMENT_TEAM = 6385SJ58J2;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(SDKROOT)/Developer/Library/Frameworks",
+ "$(inherited)",
+ );
+ INFOPLIST_FILE = "Unit Tests/Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.$(PRODUCT_NAME:rfc1034identifier)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Stagehand_Example.app/Stagehand_Example";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 3D32EF4323404F6A001144B3 /* Build configuration list for PBXNativeTarget "Stagehand-PerformanceTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 3D32EF4423404F6A001144B3 /* Debug */,
+ 3D32EF4523404F6A001144B3 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 607FACCB1AFB9204008FA782 /* Build configuration list for PBXProject "Stagehand" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 607FACED1AFB9204008FA782 /* Debug */,
+ 607FACEE1AFB9204008FA782 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 607FACEF1AFB9204008FA782 /* Build configuration list for PBXNativeTarget "Stagehand_Example" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 607FACF01AFB9204008FA782 /* Debug */,
+ 607FACF11AFB9204008FA782 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 607FACF21AFB9204008FA782 /* Build configuration list for PBXNativeTarget "Stagehand-UnitTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 607FACF31AFB9204008FA782 /* Debug */,
+ 607FACF41AFB9204008FA782 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 607FACC81AFB9204008FA782 /* Project object */;
+}
diff --git a/Example/Stagehand.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/Stagehand.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..39e6ce6
--- /dev/null
+++ b/Example/Stagehand.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Example/Stagehand.xcodeproj/xcshareddata/xcbaselines/3D32EF3B23404F6A001144B3.xcbaseline/4347C282-2559-4EFF-A169-EC7DCEFA739B.plist b/Example/Stagehand.xcodeproj/xcshareddata/xcbaselines/3D32EF3B23404F6A001144B3.xcbaseline/4347C282-2559-4EFF-A169-EC7DCEFA739B.plist
new file mode 100644
index 0000000..169b1a8
--- /dev/null
+++ b/Example/Stagehand.xcodeproj/xcshareddata/xcbaselines/3D32EF3B23404F6A001144B3.xcbaseline/4347C282-2559-4EFF-A169-EC7DCEFA739B.plist
@@ -0,0 +1,52 @@
+
+
+
+
+ classNames
+
+ AnimationCurvePerformanceTests
+
+ testCubicBezierEaseInPerformance()
+
+ com.apple.XCTPerformanceMetric_WallClockTime
+
+ baselineAverage
+ 0.026842
+ baselineIntegrationDisplayName
+ Sep 28, 2019 at 7:59:07 PM
+
+
+ testParabolicEaseInPerformance()
+
+ com.apple.XCTPerformanceMetric_WallClockTime
+
+ baselineAverage
+ 0.04231
+ baselineIntegrationDisplayName
+ Sep 28, 2019 at 8:08:35 PM
+
+
+ testParabolicEaseOutPerformance()
+
+ com.apple.XCTPerformanceMetric_WallClockTime
+
+ baselineAverage
+ 0.043876
+ baselineIntegrationDisplayName
+ Sep 28, 2019 at 8:08:35 PM
+
+
+ testSinusoidalEaseInEaseOutPerformance()
+
+ com.apple.XCTPerformanceMetric_WallClockTime
+
+ baselineAverage
+ 0.020915
+ baselineIntegrationDisplayName
+ Sep 28, 2019 at 8:08:35 PM
+
+
+
+
+
+
diff --git a/Example/Stagehand.xcodeproj/xcshareddata/xcbaselines/3D32EF3B23404F6A001144B3.xcbaseline/Info.plist b/Example/Stagehand.xcodeproj/xcshareddata/xcbaselines/3D32EF3B23404F6A001144B3.xcbaseline/Info.plist
new file mode 100644
index 0000000..b86b54e
--- /dev/null
+++ b/Example/Stagehand.xcodeproj/xcshareddata/xcbaselines/3D32EF3B23404F6A001144B3.xcbaseline/Info.plist
@@ -0,0 +1,40 @@
+
+
+
+
+ runDestinationsByUUID
+
+ 4347C282-2559-4EFF-A169-EC7DCEFA739B
+
+ localComputer
+
+ busSpeedInMHz
+ 100
+ cpuCount
+ 1
+ cpuKind
+ Intel Core i7
+ cpuSpeedInMHz
+ 2800
+ logicalCPUCoresPerPackage
+ 8
+ modelCode
+ MacBookPro14,3
+ physicalCPUCoresPerPackage
+ 4
+ platformIdentifier
+ com.apple.platform.macosx
+
+ targetArchitecture
+ x86_64
+ targetDevice
+
+ modelCode
+ iPhone11,2
+ platformIdentifier
+ com.apple.platform.iphonesimulator
+
+
+
+
+
diff --git a/Example/Stagehand.xcodeproj/xcshareddata/xcschemes/Stagehand Demo App.xcscheme b/Example/Stagehand.xcodeproj/xcshareddata/xcschemes/Stagehand Demo App.xcscheme
new file mode 100644
index 0000000..09b9bde
--- /dev/null
+++ b/Example/Stagehand.xcodeproj/xcshareddata/xcschemes/Stagehand Demo App.xcscheme
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Example/Stagehand.xcworkspace/contents.xcworkspacedata b/Example/Stagehand.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..6371190
--- /dev/null
+++ b/Example/Stagehand.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/Example/Stagehand.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/Stagehand.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/Example/Stagehand.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/Example/Stagehand/AnimationCancelationViewController.swift b/Example/Stagehand/AnimationCancelationViewController.swift
new file mode 100644
index 0000000..9a0283f
--- /dev/null
+++ b/Example/Stagehand/AnimationCancelationViewController.swift
@@ -0,0 +1,103 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import UIKit
+
+final class AnimationCancelationViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = mainView
+
+ animationRows = [
+ ("Animate", { [unowned self] in
+ // Cancel any existing animation
+ self.animationInstance?.cancel()
+
+ let animation = self.makeAnimation()
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Cancel (Revert)", { [unowned self] in
+ self.animationInstance?.cancel(behavior: .revert)
+ }),
+ ("Cancel (Halt)", { [unowned self] in
+ self.animationInstance?.cancel(behavior: .halt)
+ }),
+ ("Cancel (Complete)", { [unowned self] in
+ self.animationInstance?.cancel(behavior: .complete)
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ private var animationInstance: AnimationInstance?
+
+ // MARK: - Private Methods
+
+ private func makeAnimation() -> Animation {
+ var animation = Animation()
+ animation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: mainView.bounds.width - 100, y: 0))
+ animation.duration = 2
+ return animation
+ }
+
+}
+
+// MARK: -
+
+extension AnimationCancelationViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableView.backgroundColor = .red
+ addSubview(animatableView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let animatableView: UIView = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ animatableView.bounds.size = .init(width: 50, height: 50)
+ animatableView.center = .init(
+ x: bounds.minX + 50,
+ y: bounds.height / 2
+ )
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/AnimationCurveViewController.swift b/Example/Stagehand/AnimationCurveViewController.swift
new file mode 100644
index 0000000..2e6df46
--- /dev/null
+++ b/Example/Stagehand/AnimationCurveViewController.swift
@@ -0,0 +1,251 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import UIKit
+
+final class AnimationCurveViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = mainView
+
+ contentHeight = 350
+
+ animationRows = [
+ ("Reset", { [unowned self] in
+ self.reset(clearOldCurves: true)
+ }),
+ ("Linear", { [unowned self] in
+ self.reset()
+
+ var animation = self.makeAnimation()
+ animation.curve = LinearAnimationCurve()
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Ease In (Parabolic)", { [unowned self] in
+ self.reset()
+
+ var animation = self.makeAnimation()
+ animation.curve = ParabolicEaseInAnimationCurve()
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Ease Out (Parabolic)", { [unowned self] in
+ self.reset()
+
+ var animation = self.makeAnimation()
+ animation.curve = ParabolicEaseOutAnimationCurve()
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Ease In Ease Out (Sinusoidal)", { [unowned self] in
+ self.reset()
+
+ var animation = self.makeAnimation()
+ animation.curve = SinusoidalEaseInEaseOutAnimationCurve()
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Cubic Bezier Curve (0.42, 0.58)", { [unowned self] in
+ self.reset()
+
+ var animation = self.makeAnimation()
+ animation.curve = CubicBezierAnimationCurve(controlPoints: (0.42, 0.0), (0.58, 1.0))
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Cubic Bezier Curve (0.42, 1.0)", { [unowned self] in
+ self.reset()
+
+ var animation = self.makeAnimation()
+ animation.curve = CubicBezierAnimationCurve(controlPoints: (0.42, 0.0), (1.0, 1.0))
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Cubic Bezier Curve (0.0, 0.58)", { [unowned self] in
+ self.reset()
+
+ var animation = self.makeAnimation()
+ animation.curve = CubicBezierAnimationCurve(controlPoints: (0.0, 0.0), (0.58, 1.0))
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Cubic Bezier Curve (Overshoot)", { [unowned self] in
+ self.reset()
+
+ var animation = self.makeAnimation()
+
+ // The animation will clamp to the values specified by the keyframes. To allow for the overshoot, we'll
+ // add a keyframe past the end of the animation so that it can interpolate between the final value (at
+ // a timestamp of 1) and a value past the end.
+ animation.addKeyframe(
+ for: \.transform,
+ at: 2,
+ value: .init(translationX: 2 * self.mainView.bounds.width - 200, y: 0)
+ )
+
+ // Specify an control point with a y value greater than 1 to have the curve overshoot at the end.
+ animation.curve = CubicBezierAnimationCurve(controlPoints: (0.5, 0.0), (0.5, 1.3))
+
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ private var animationInstance: AnimationInstance?
+
+ private var curvePath: UIBezierPath = .init()
+
+ // MARK: - Private Methods
+
+ private func makeAnimation() -> Animation {
+ var animation = Animation()
+ animation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: mainView.bounds.width - 100, y: 0))
+ animation.duration = 2
+
+ animation.addPerFrameExecution { [weak self] context in
+ guard let self = self else { return }
+
+ let shapeLayerSize = Double(self.mainView.shapeLayer.bounds.width)
+
+ self.curvePath.addLine(
+ to: CGPoint(
+ x: context.uncurvedProgress * shapeLayerSize,
+ y: shapeLayerSize - context.progress * shapeLayerSize
+ )
+ )
+ self.mainView.shapeLayer.path = self.curvePath.cgPath
+ }
+
+ return animation
+ }
+
+ private func reset(clearOldCurves: Bool = false) {
+ animationInstance?.cancel()
+ animationInstance = nil
+
+ mainView.animatableView.transform = .identity
+
+ if clearOldCurves {
+ mainView.oldShapeLayer.path = nil
+
+ } else {
+ let oldPath = mainView.oldShapeLayer.path?.mutableCopy() ?? CGMutablePath()
+ oldPath.addPath(curvePath.cgPath)
+ mainView.oldShapeLayer.path = oldPath
+ }
+
+ curvePath = UIBezierPath()
+ curvePath.move(to: CGPoint(x: 0, y: mainView.shapeLayer.bounds.height))
+ mainView.shapeLayer.path = curvePath.cgPath
+ }
+
+}
+
+// MARK: -
+
+extension AnimationCurveViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableView.backgroundColor = .red
+ addSubview(animatableView)
+
+ gridLayer.fillColor = nil
+ gridLayer.lineWidth = 1
+ gridLayer.strokeColor = UIColor(white: 0.9, alpha: 1).cgColor
+ layer.addSublayer(gridLayer)
+
+ oldShapeLayer.fillColor = nil
+ oldShapeLayer.lineWidth = 2
+ oldShapeLayer.strokeColor = UIColor(white: 0.8, alpha: 1).cgColor
+ layer.addSublayer(oldShapeLayer)
+
+ shapeLayer.fillColor = nil
+ shapeLayer.lineWidth = 2
+ shapeLayer.strokeColor = UIColor.black.cgColor
+ layer.addSublayer(shapeLayer)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let animatableView: UIView = .init()
+
+ let gridLayer: CAShapeLayer = .init()
+
+ let shapeLayer: CAShapeLayer = .init()
+
+ let oldShapeLayer: CAShapeLayer = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ animatableView.bounds.size = .init(width: 50, height: 50)
+ animatableView.center = .init(
+ x: bounds.minX + 50,
+ y: bounds.minY + 60
+ )
+
+ shapeLayer.frame = .init(
+ x: bounds.midX - 100,
+ y: animatableView.frame.maxY + 30,
+ width: 200,
+ height: 200
+ )
+
+ gridLayer.frame = shapeLayer.frame
+ updateGrid()
+
+ oldShapeLayer.frame = shapeLayer.frame
+ }
+
+ // MARK: - Private Methods
+
+ private func updateGrid() {
+ let gridPath = UIBezierPath()
+
+ let divisions = 10
+ let cellSize = gridLayer.bounds.height / CGFloat(divisions)
+
+ for row in 0...divisions {
+ gridPath.move(to: CGPoint(x: 0, y: cellSize * CGFloat(row)))
+ gridPath.addLine(to: CGPoint(x: gridLayer.bounds.width, y: cellSize * CGFloat(row)))
+ }
+
+ for column in 0...divisions {
+ gridPath.move(to: CGPoint(x: cellSize * CGFloat(column), y: 0))
+ gridPath.addLine(to: CGPoint(x: cellSize * CGFloat(column), y: gridLayer.bounds.width))
+ }
+
+ gridLayer.path = gridPath.cgPath
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/AnimationFactory.swift b/Example/Stagehand/AnimationFactory.swift
new file mode 100644
index 0000000..297729d
--- /dev/null
+++ b/Example/Stagehand/AnimationFactory.swift
@@ -0,0 +1,73 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+
+enum AnimationFactory {
+
+ static func makeFadeOutAnimation() -> Animation {
+ var fadeOutAnimation = Animation()
+ fadeOutAnimation.addKeyframe(for: \.alpha, at: 0, value: 1)
+ fadeOutAnimation.addKeyframe(for: \.alpha, at: 1, value: 0)
+ fadeOutAnimation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ fadeOutAnimation.addKeyframe(for: \.transform, at: 1, value: .init(scaleX: 1.1, y: 1.1))
+ return fadeOutAnimation
+ }
+
+ static func makeFadeInAnimation() -> Animation {
+ var fadeInAnimation = Animation()
+ fadeInAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ fadeInAnimation.addKeyframe(for: \.alpha, at: 1, value: 1)
+ fadeInAnimation.addKeyframe(for: \.transform, at: 0, value: .init(scaleX: 1.1, y: 1.1))
+ fadeInAnimation.addKeyframe(for: \.transform, at: 1, value: .identity)
+ return fadeInAnimation
+ }
+
+ static func makeResetTransformAnimation() -> Animation {
+ var resetAnimation = Animation()
+ resetAnimation.addKeyframe(for: \.transform, at: 0, relativeValue: { $0 })
+ resetAnimation.addKeyframe(for: \.transform, at: 1, value: .identity)
+ return resetAnimation
+ }
+
+ static func makeRotateAnimation() -> Animation {
+ var rotateAnimation = Animation()
+ rotateAnimation.addKeyframe(for: \.transform, at: 0, relativeValue: { $0 })
+ rotateAnimation.addKeyframe(for: \.transform, at: 1, relativeValue: { $0.rotated(by: .pi / 4) })
+ return rotateAnimation
+ }
+
+ static func makePopAnimation() -> Animation {
+ var popAnimation = Animation()
+ popAnimation.addKeyframe(for: \.transform, at: 0, relativeValue: { $0 })
+ popAnimation.addKeyframe(for: \.transform, at: 0.1, relativeValue: { $0.scaledBy(x: 0.9, y: 0.9) })
+ popAnimation.addKeyframe(for: \.transform, at: 0.5, relativeValue: { $0.scaledBy(x: 1.5, y: 1.5) })
+ popAnimation.addKeyframe(for: \.transform, at: 0.9, relativeValue: { $0.scaledBy(x: 0.9, y: 0.9) })
+ popAnimation.addKeyframe(for: \.transform, at: 1, relativeValue: { $0 })
+ return popAnimation
+ }
+
+ static func makeGhostAnimation() -> Animation {
+ var ghostAnimation = Animation()
+ ghostAnimation.addKeyframe(for: \.alpha, at: 0, relativeValue: { $0 })
+ ghostAnimation.addKeyframe(for: \.alpha, at: 0.25, value: 0)
+ ghostAnimation.addKeyframe(for: \.alpha, at: 0.5, relativeValue: { $0 })
+ ghostAnimation.addKeyframe(for: \.alpha, at: 0.75, value: 0)
+ ghostAnimation.addKeyframe(for: \.alpha, at: 0, relativeValue: { $0 })
+ return ghostAnimation
+ }
+
+}
diff --git a/Example/Stagehand/AnimationGroupViewController.swift b/Example/Stagehand/AnimationGroupViewController.swift
new file mode 100644
index 0000000..5f2dbe6
--- /dev/null
+++ b/Example/Stagehand/AnimationGroupViewController.swift
@@ -0,0 +1,113 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import UIKit
+
+final class AnimationGroupViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = View(topView: topView, bottomView: bottomView)
+
+ animationRows = [
+ ("Move Both Views", { [unowned self] in
+ var animationGroup = AnimationGroup()
+ animationGroup.duration = 2
+
+ let topAnimation = self.makeAnimation()
+ animationGroup.addAnimation(topAnimation, for: self.topView, startingAt: 0, relativeDuration: 0.75)
+
+ let bottomAnimation = self.makeAnimation()
+ animationGroup.addAnimation(bottomAnimation, for: self.bottomView, startingAt: 0.25, relativeDuration: 0.75)
+
+ animationGroup.perform()
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let topView: UIView = .init()
+
+ private let bottomView: UIView = .init()
+
+ // MARK: - Private Methods
+
+ private func makeAnimation() -> Animation {
+ var animation = Animation()
+ animation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: contentView.bounds.width - 100, y: 0))
+ animation.duration = 2
+ return animation
+ }
+
+}
+
+// MARK: -
+
+extension AnimationGroupViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ init(topView: UIView, bottomView: UIView) {
+ self.topView = topView
+ self.bottomView = bottomView
+
+ super.init(frame: .zero)
+
+ topView.backgroundColor = .red
+ addSubview(topView)
+
+ bottomView.backgroundColor = .red
+ addSubview(bottomView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let topView: UIView
+
+ let bottomView: UIView
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ topView.bounds.size = .init(width: 50, height: 50)
+ topView.center = .init(
+ x: bounds.minX + 50,
+ y: bounds.height / 3
+ )
+
+ bottomView.bounds.size = .init(width: 50, height: 50)
+ bottomView.center = .init(
+ x: bounds.minX + 50,
+ y: bounds.height * 2 / 3
+ )
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/AnimationQueueViewController.swift b/Example/Stagehand/AnimationQueueViewController.swift
new file mode 100644
index 0000000..c9e6b63
--- /dev/null
+++ b/Example/Stagehand/AnimationQueueViewController.swift
@@ -0,0 +1,115 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import UIKit
+
+final class AnimationQueueViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ self.animationQueue = AnimationQueue(element: mainView)
+
+ super.init()
+
+ contentView = mainView
+ contentHeight = 300
+
+ animationRows = [
+ ("Cancel Pending Animations", { [unowned self] in
+ self.animationQueue.cancelPendingAnimations()
+ }),
+ ("Enqueue Move to Center", { [unowned self] in
+ let animation = self.makeTranslationAnimation(x: 0, y: 0)
+ self.animationQueue.enqueue(animation: animation)
+ }),
+ ("Enqueue Move to Top Left", { [unowned self] in
+ let animation = self.makeTranslationAnimation(x: -100, y: -100)
+ self.animationQueue.enqueue(animation: animation)
+ }),
+ ("Enqueue Move to Top Right", { [unowned self] in
+ let animation = self.makeTranslationAnimation(x: 100, y: -100)
+ self.animationQueue.enqueue(animation: animation)
+ }),
+ ("Enqueue Move to Bottom Left", { [unowned self] in
+ let animation = self.makeTranslationAnimation(x: -100, y: 100)
+ self.animationQueue.enqueue(animation: animation)
+ }),
+ ("Enqueue Move to Bottom Right", { [unowned self] in
+ let animation = self.makeTranslationAnimation(x: 100, y: 100)
+ self.animationQueue.enqueue(animation: animation)
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ private let animationQueue: AnimationQueue
+
+ // MARK: - Private Methods
+
+ private func makeTranslationAnimation(x: CGFloat, y: CGFloat) -> Animation {
+ var animation = Animation()
+ animation.duration = 2
+
+ animation.addKeyframe(for: \.animatableView.transform, at: 0, relativeValue: { $0 })
+ animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: x, y: y))
+
+ return animation
+ }
+
+}
+
+// MARK: -
+
+extension AnimationQueueViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableView.bounds.size = .init(width: 40, height: 40)
+ animatableView.backgroundColor = .red
+ addSubview(animatableView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let animatableView: UIView = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ animatableView.center = .init(
+ x: (bounds.maxX - bounds.minX) / 2,
+ y: (bounds.maxY - bounds.minY) / 2
+ )
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/AppDelegate.swift b/Example/Stagehand/AppDelegate.swift
new file mode 100644
index 0000000..ce2ee0f
--- /dev/null
+++ b/Example/Stagehand/AppDelegate.swift
@@ -0,0 +1,42 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import UIKit
+
+@UIApplicationMain
+final class AppDelegate: UIResponder, UIApplicationDelegate {
+
+ var window: UIWindow?
+
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ let window = UIWindow(frame: UIScreen.main.bounds)
+ self.window = window
+
+ let rootViewController = RootViewController()
+
+ let navigationController = UINavigationController(rootViewController: rootViewController)
+ window.rootViewController = navigationController
+
+ window.makeKeyAndVisible()
+
+ return true
+ }
+
+}
+
diff --git a/Example/Stagehand/Base.lproj/LaunchScreen.xib b/Example/Stagehand/Base.lproj/LaunchScreen.xib
new file mode 100644
index 0000000..2a2ec94
--- /dev/null
+++ b/Example/Stagehand/Base.lproj/LaunchScreen.xib
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Example/Stagehand/ChildAnimationsViewController.swift b/Example/Stagehand/ChildAnimationsViewController.swift
new file mode 100644
index 0000000..0efd1dd
--- /dev/null
+++ b/Example/Stagehand/ChildAnimationsViewController.swift
@@ -0,0 +1,186 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import UIKit
+
+final class ChildAnimationsViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = mainView
+
+ animationRows = [
+ ("Left to Right", { [unowned self] in
+ var animation = Animation()
+ animation.addChild(
+ AnimationFactory.makeFadeOutAnimation(),
+ for: \View.leftView,
+ startingAt: 0,
+ relativeDuration: 0.5
+ )
+ animation.addChild(
+ AnimationFactory.makeFadeInAnimation(),
+ for: \View.rightView,
+ startingAt: 0.5,
+ relativeDuration: 0.5
+ )
+ animation.perform(on: self.mainView)
+ }),
+ ("Right to Left", { [unowned self] in
+ var animation = Animation()
+ animation.addChild(
+ AnimationFactory.makeFadeOutAnimation(),
+ for: \View.rightView,
+ startingAt: 0,
+ relativeDuration: 0.5
+ )
+ animation.addChild(
+ AnimationFactory.makeFadeInAnimation(),
+ for: \View.leftView,
+ startingAt: 0.5,
+ relativeDuration: 0.5
+ )
+ animation.perform(on: self.mainView)
+ }),
+ ("Swap", { [unowned self] in
+ var animation = Animation()
+
+ var invertAlphaAnimation = Animation()
+ invertAlphaAnimation.addKeyframe(for: \.alpha, at: 0, relativeValue: { $0 })
+ invertAlphaAnimation.addKeyframe(for: \.alpha, at: 1, relativeValue: { 1 - $0 })
+
+ let startWithLeftView = self.mainView.leftView.alpha > self.mainView.rightView.alpha
+
+ animation.addChild(
+ invertAlphaAnimation,
+ for: startWithLeftView ? \View.leftView : \View.rightView,
+ startingAt: 0,
+ relativeDuration: 0.75
+ )
+ animation.addChild(
+ invertAlphaAnimation,
+ for: startWithLeftView ? \View.rightView : \View.leftView,
+ startingAt: 0.25,
+ relativeDuration: 0.75
+ )
+ animation.perform(on: self.mainView)
+ }),
+ ("Rotate and Fade", { [unowned self] in
+ var childAnimation = Animation()
+
+ var rotateAnimation = Animation()
+ rotateAnimation.addKeyframe(for: \.transform, at: 0.00, value: .identity)
+ rotateAnimation.addKeyframe(for: \.transform, at: 0.25, value: .init(rotationAngle: .pi / 2))
+ rotateAnimation.addKeyframe(for: \.transform, at: 0.50, value: .init(rotationAngle: .pi))
+ rotateAnimation.addKeyframe(for: \.transform, at: 0.75, value: .init(rotationAngle: 3 * .pi / 2))
+ rotateAnimation.addKeyframe(for: \.transform, at: 1.00, value: .init(rotationAngle: 2 * .pi))
+
+ var alphaAnimation = Animation()
+ alphaAnimation.addKeyframe(for: \.alpha, at: 0, relativeValue: { $0 })
+ alphaAnimation.addKeyframe(for: \.alpha, at: 0.5, value: 1.0)
+ alphaAnimation.addKeyframe(for: \.alpha, at: 1, relativeValue: { $0 })
+
+ childAnimation.addChild(rotateAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+ childAnimation.addChild(alphaAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ var animation = Animation()
+ animation.addChild(childAnimation, for: \.leftView, startingAt: 0, relativeDuration: 1)
+ animation.addChild(childAnimation, for: \.rightView, startingAt: 0, relativeDuration: 1)
+
+ animation.perform(on: self.mainView)
+ }),
+ ("Fade Out, then Fade In", { [unowned self] in
+ var fadeOutAnimation = Animation()
+ fadeOutAnimation.addKeyframe(for: \.alpha, at: 0, value: 1)
+ fadeOutAnimation.addKeyframe(for: \.alpha, at: 1, value: 0.25)
+
+ var fadeInAnimation = Animation()
+ fadeInAnimation.addKeyframe(for: \.alpha, at: 0, value: 0.25)
+ fadeInAnimation.addKeyframe(for: \.alpha, at: 1, value: 1)
+
+ var animation = Animation()
+ animation.addChild(fadeOutAnimation, for: \.self, startingAt: 0, relativeDuration: 0.5)
+ animation.addChild(fadeInAnimation, for: \.self, startingAt: 0.5, relativeDuration: 0.5)
+
+ animation.duration = 2
+
+ animation.perform(on: self.mainView)
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+}
+
+// MARK: -
+
+extension ChildAnimationsViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ leftView.backgroundColor = .red
+ addSubview(leftView)
+
+ rightView.backgroundColor = .blue
+ rightView.alpha = 0
+ addSubview(rightView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ // Due to a limitation in how Swift KeyPaths are appended, these properties need to be mutable since they will
+ // be used for a child animation.
+
+ var leftView: UIView = .init()
+
+ var rightView: UIView = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ leftView.bounds.size = .init(width: 50, height: 50)
+ leftView.center = .init(
+ x: bounds.width / 3,
+ y: bounds.height / 2
+ )
+
+ rightView.bounds.size = .init(width: 50, height: 50)
+ rightView.center = .init(
+ x: bounds.width * 2 / 3,
+ y: bounds.height / 2
+ )
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/ChildAnimationsWithCurvesViewController.swift b/Example/Stagehand/ChildAnimationsWithCurvesViewController.swift
new file mode 100644
index 0000000..6f98551
--- /dev/null
+++ b/Example/Stagehand/ChildAnimationsWithCurvesViewController.swift
@@ -0,0 +1,114 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import UIKit
+
+final class ChildAnimationsWithCurvesViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = mainView
+
+ animationRows = [
+ ("Reset", { [unowned self] in
+ self.mainView.topView.transform = .identity
+ self.mainView.bottomView.transform = .identity
+ }),
+ ("Linear / Ease In Ease Out", { [unowned self] in
+ var animation = Animation()
+ animation.duration = 2
+
+ var topAnimation = self.makeAnimation()
+ topAnimation.curve = LinearAnimationCurve()
+ animation.addChild(topAnimation, for: \View.topView, startingAt: 0, relativeDuration: 1)
+
+ var bottomAnimation = self.makeAnimation()
+ bottomAnimation.curve = SinusoidalEaseInEaseOutAnimationCurve()
+ animation.addChild(bottomAnimation, for: \View.bottomView, startingAt: 0, relativeDuration: 1)
+
+ animation.perform(on: self.mainView)
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ // MARK: - Private Methods
+
+ private func makeAnimation() -> Animation {
+ var animation = Animation()
+ animation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: mainView.bounds.width - 100, y: 0))
+ animation.duration = 2
+ return animation
+ }
+
+}
+
+// MARK: -
+
+extension ChildAnimationsWithCurvesViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ topView.backgroundColor = .red
+ addSubview(topView)
+
+ bottomView.backgroundColor = .red
+ addSubview(bottomView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ var topView: UIView = .init()
+
+ var bottomView: UIView = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ topView.bounds.size = .init(width: 50, height: 50)
+ topView.center = .init(
+ x: bounds.minX + 50,
+ y: bounds.height / 3
+ )
+
+ bottomView.bounds.size = .init(width: 50, height: 50)
+ bottomView.center = .init(
+ x: bounds.minX + 50,
+ y: bounds.height * 2 / 3
+ )
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/CollectionKeyframesViewController.swift b/Example/Stagehand/CollectionKeyframesViewController.swift
new file mode 100644
index 0000000..54b95b9
--- /dev/null
+++ b/Example/Stagehand/CollectionKeyframesViewController.swift
@@ -0,0 +1,203 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import UIKit
+
+// This file is testing the collection keyframes feature of Stagehand, which is not yet ready for release. Use a
+// testable import here so we can call the internal methods.
+@testable import Stagehand
+
+final class CollectionKeyframesViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = mainView
+
+ animationRows = [
+ ("Bounce All", { [unowned self] in
+ self.reset()
+
+ var animation = Animation()
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.animatableViews,
+ at: 0,
+ value: .identity
+ )
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.animatableViews,
+ at: 0.5,
+ value: .init(translationX: 0, y: -self.mainView.bounds.height / 2)
+ )
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.animatableViews,
+ at: 1,
+ value: .identity
+ )
+
+ self.animationInstance = animation.perform(on: self.mainView)
+ }),
+ ("Bounce Odd Views", { [unowned self] in
+ self.reset()
+
+ var animation = Animation()
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.oddViews,
+ at: 0,
+ value: .identity
+ )
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.oddViews,
+ at: 0.5,
+ value: .init(translationX: 0, y: -self.mainView.bounds.height / 2)
+ )
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.oddViews,
+ at: 1,
+ value: .identity
+ )
+
+ self.animationInstance = animation.perform(on: self.mainView)
+ }),
+ ("Bounce In Order", { [unowned self] in
+ self.reset()
+
+ var animation = Animation()
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.animatableViews,
+ at: { index, count in 0.33 * Double(index) / Double(count - 1) },
+ value: .identity
+ )
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.animatableViews,
+ at: { index, count in 0.33 + 0.33 * Double(index) / Double(count - 1) },
+ value: .init(translationX: 0, y: -self.mainView.bounds.height / 2)
+ )
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.animatableViews,
+ at: { index, count in 0.67 + 0.33 * Double(index) / Double(count - 1) },
+ value: .identity
+ )
+
+ self.animationInstance = animation.perform(on: self.mainView)
+ }),
+ ("Bounce All (in Child Animation)", { [unowned self] in
+ self.reset()
+
+ var animation = Animation()
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.animatableViews,
+ at: 0,
+ value: .identity
+ )
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.animatableViews,
+ at: 0.5,
+ value: .init(translationX: 0, y: -self.mainView.bounds.height / 2)
+ )
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.animatableViews,
+ at: 1,
+ value: .identity
+ )
+
+ var parentAnimation = Animation()
+ parentAnimation.addChild(animation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ self.animationInstance = parentAnimation.perform(on: self.mainView)
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ private var animationInstance: AnimationInstance?
+
+ // MARK: - Private Methods
+
+ private func reset() {
+ animationInstance?.cancel()
+ animationInstance = nil
+
+ mainView.animatableViews.forEach { $0.transform = .identity }
+ }
+
+}
+
+// MARK: -
+
+extension CollectionKeyframesViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableViews.forEach { view in
+ view.bounds.size = .init(width: 40, height: 40)
+ view.backgroundColor = .red
+ addSubview(view)
+ }
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let animatableViews: [UIView] = (0..<5).map { _ in UIView() }
+
+ var oddViews: [UIView] {
+ return animatableViews
+ .enumerated()
+ .filter { $0.0 % 2 != 0 }
+ .map { $0.1 }
+ }
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ for (index, view) in animatableViews.enumerated() {
+ view.center = .init(
+ x: bounds.minX + 50 + (CGFloat(index) * (bounds.width - 100) / CGFloat(animatableViews.count - 1)),
+ y: bounds.height * 3 / 4
+ )
+ }
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/ColorAnimationsViewController.swift b/Example/Stagehand/ColorAnimationsViewController.swift
new file mode 100644
index 0000000..9a1e3a2
--- /dev/null
+++ b/Example/Stagehand/ColorAnimationsViewController.swift
@@ -0,0 +1,114 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import UIKit
+
+final class ColorAnimationsViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = .init()
+ contentView.backgroundColor = .red
+
+ animationRows = [
+ ("Reset to Red (sRGB)", { [unowned self] in
+ self.animationInstance?.cancel()
+ self.contentView.backgroundColor = .red
+ }),
+ ("Reset to Red (P3)", { [unowned self] in
+ self.animationInstance?.cancel()
+ self.contentView.backgroundColor = UIColor(displayP3Red: 1, green: 0, blue: 0, alpha: 1)
+ }),
+ ("Red (sRGB) -> Green (sRGB)", { [unowned self] in
+ self.animationInstance?.cancel()
+
+ var animation = Animation()
+ animation.duration = 2
+
+ animation.addKeyframe(for: \.backgroundColor, at: 0, value: .red)
+ animation.addKeyframe(for: \.backgroundColor, at: 1, value: .green)
+
+ self.animationInstance = animation.perform(on: self.contentView)
+ }),
+ ("Red (sRGB) -> nil -> Green (sRGB)", { [unowned self] in
+ self.animationInstance?.cancel()
+
+ var animation = Animation()
+ animation.duration = 2
+
+ animation.addKeyframe(for: \.backgroundColor, at: 0, value: .red)
+ animation.addKeyframe(for: \.backgroundColor, at: 0.5, value: nil)
+ animation.addKeyframe(for: \.backgroundColor, at: 1, value: .green)
+
+ self.animationInstance = animation.perform(on: self.contentView)
+ }),
+ ("Red (P3) -> Green (P3)", { [unowned self] in
+ self.animationInstance?.cancel()
+
+ var animation = Animation()
+ animation.duration = 2
+
+ animation.addKeyframe(for: \UIView.backgroundColor, at: 0, value: UIColor(displayP3Red: 1, green: 0, blue: 0, alpha: 1))
+ animation.addKeyframe(for: \UIView.backgroundColor, at: 1, value: UIColor(displayP3Red: 0, green: 1, blue: 0, alpha: 1))
+
+ self.animationInstance = animation.perform(on: self.contentView)
+ }),
+ ("Red (sRGB) -> Green (P3)", { [unowned self] in
+ self.animationInstance?.cancel()
+
+ var animation = Animation()
+ animation.duration = 2
+
+ animation.addKeyframe(for: \UIView.backgroundColor, at: 0, value: .red)
+ animation.addKeyframe(for: \UIView.backgroundColor, at: 1, value: UIColor(displayP3Red: 0, green: 1, blue: 0, alpha: 1))
+
+ self.animationInstance = animation.perform(on: self.contentView)
+ }),
+ ("Red (sRGB) -> Red (P3)", { [unowned self] in
+ self.animationInstance?.cancel()
+
+ var animation = Animation()
+ animation.duration = 2
+
+ animation.addKeyframe(for: \UIView.backgroundColor, at: 0, value: .red)
+ animation.addKeyframe(for: \UIView.backgroundColor, at: 1, value: UIColor(displayP3Red: 1, green: 0, blue: 0, alpha: 1))
+
+ self.animationInstance = animation.perform(on: self.contentView)
+ }),
+ ("Red (sRGB), with alpha 1 -> 0.5 -> 1", { [unowned self] in
+ self.animationInstance?.cancel()
+
+ var animation = Animation()
+ animation.duration = 2
+
+ animation.addKeyframe(for: \UIView.backgroundColor, at: 0.0, value: UIColor.red)
+ animation.addKeyframe(for: \UIView.backgroundColor, at: 0.5, value: UIColor.red.withAlphaComponent(0.5))
+ animation.addKeyframe(for: \UIView.backgroundColor, at: 1.0, value: UIColor.red)
+
+ self.animationInstance = animation.perform(on: self.contentView)
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private var animationInstance: AnimationInstance?
+
+}
diff --git a/Example/Stagehand/DemoViewController.swift b/Example/Stagehand/DemoViewController.swift
new file mode 100644
index 0000000..c3315d5
--- /dev/null
+++ b/Example/Stagehand/DemoViewController.swift
@@ -0,0 +1,114 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import UIKit
+
+class DemoViewController: UIViewController {
+
+ // MARK: - Life Cycle
+
+ init() {
+ super.init(nibName: nil, bundle: nil)
+
+ tableView.dataSource = self
+ tableView.delegate = self
+ view.addSubview(tableView)
+
+ view.backgroundColor = .white
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ var animationRows: [(name: String, action: () -> Void)] = [] {
+ didSet {
+ tableView.reloadData()
+ }
+ }
+
+ var contentView: UIView = .init() {
+ didSet {
+ oldValue.removeFromSuperview()
+ view.addSubview(contentView)
+ view.setNeedsLayout()
+ }
+ }
+
+ var contentHeight: CGFloat = 200 {
+ didSet {
+ view.setNeedsLayout()
+ }
+ }
+
+ // MARK: - Private Properties
+
+ private let tableView: UITableView = .init()
+
+ // MARK: - UIView
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+
+ let topInset = [
+ UIApplication.shared.statusBarFrame.height,
+ navigationController?.navigationBar.frame.height,
+ ].compactMap { $0 }.reduce(0, +)
+
+ contentView.frame = CGRect(
+ x: 0,
+ y: topInset,
+ width: view.bounds.width,
+ height: contentHeight
+ )
+
+ tableView.frame = CGRect(
+ x: 0,
+ y: topInset + contentHeight,
+ width: view.bounds.width,
+ height: view.bounds.height - contentHeight - topInset
+ )
+ }
+
+}
+
+// MARK: -
+
+extension DemoViewController: UITableViewDataSource, UITableViewDelegate {
+
+ func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+ return animationRows.count
+ }
+
+ func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+ return UITableViewCell(style: .default, reuseIdentifier: nil)
+ }
+
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ let row = animationRows[indexPath.row]
+ cell.textLabel?.text = row.name
+ }
+
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ let row = animationRows[indexPath.row]
+ row.action()
+ tableView.deselectRow(at: indexPath, animated: true)
+ }
+
+}
diff --git a/Example/Stagehand/ExecutionBlockViewController.swift b/Example/Stagehand/ExecutionBlockViewController.swift
new file mode 100644
index 0000000..9fb5488
--- /dev/null
+++ b/Example/Stagehand/ExecutionBlockViewController.swift
@@ -0,0 +1,138 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import UIKit
+
+final class ExecutionBlockViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = mainView
+
+ animationRows = [
+ ("Color Change with Haptic Feedback", { [unowned self] in
+ self.reset()
+
+ let feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
+
+ var animation = self.makeAnimation()
+ animation.addExecution(
+ onForward: { view in
+ view.backgroundColor = .yellow
+ feedbackGenerator.impactOccurred()
+ },
+ onReverse: { view in
+ view.backgroundColor = .red
+ feedbackGenerator.impactOccurred()
+ },
+ at: 0.33
+ )
+ animation.addExecution(
+ onForward: { view in
+ view.backgroundColor = .green
+ feedbackGenerator.impactOccurred()
+ },
+ onReverse: { view in
+ view.backgroundColor = .yellow
+ feedbackGenerator.impactOccurred()
+ },
+ at: 0.66
+ )
+ animation.addExecution(
+ onForward: { _ in
+ feedbackGenerator.impactOccurred()
+ },
+ onReverse: { _ in
+ // No-op. Only use haptics on the forward direction, otherwise it will execute twice at the end.
+ // (once when it hits the end going forward, then immediately after when it starts the reverse
+ // animation cycle).
+ },
+ at: 1
+ )
+ animation.repeatStyle = .repeating(count: 2, autoreversing: true)
+
+ feedbackGenerator.prepare()
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ private var animationInstance: AnimationInstance?
+
+ // MARK: - Private Methods
+
+ private func makeAnimation() -> Animation {
+ var animation = Animation()
+ animation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: mainView.bounds.width - 100, y: 0))
+ animation.duration = 2
+ return animation
+ }
+
+ private func reset() {
+ animationInstance?.cancel()
+ animationInstance = nil
+
+ mainView.animatableView.transform = .identity
+ }
+
+}
+
+// MARK: -
+
+extension ExecutionBlockViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableView.backgroundColor = .red
+ addSubview(animatableView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let animatableView: UIView = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ animatableView.bounds.size = .init(width: 50, height: 50)
+ animatableView.center = .init(
+ x: bounds.minX + 50,
+ y: bounds.height / 2
+ )
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/Images.xcassets/AppIcon.appiconset/Contents.json b/Example/Stagehand/Images.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..7006c9e
--- /dev/null
+++ b/Example/Stagehand/Images.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,53 @@
+{
+ "images" : [
+ {
+ "idiom" : "iphone",
+ "size" : "20x20",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "20x20",
+ "scale" : "3x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "29x29",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "29x29",
+ "scale" : "3x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "40x40",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "40x40",
+ "scale" : "3x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "60x60",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "iphone",
+ "size" : "60x60",
+ "scale" : "3x"
+ },
+ {
+ "idiom" : "ios-marketing",
+ "size" : "1024x1024",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/Example/Stagehand/Info.plist b/Example/Stagehand/Info.plist
new file mode 100644
index 0000000..9e4cb72
--- /dev/null
+++ b/Example/Stagehand/Info.plist
@@ -0,0 +1,39 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ CFBundleDisplayName
+ Stagehand
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+
+
+
diff --git a/Example/Stagehand/PerformanceBenchmarkViewController.swift b/Example/Stagehand/PerformanceBenchmarkViewController.swift
new file mode 100644
index 0000000..4652a2f
--- /dev/null
+++ b/Example/Stagehand/PerformanceBenchmarkViewController.swift
@@ -0,0 +1,269 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import UIKit
+
+// This file is testing the collection keyframes feature of Stagehand, which is not yet ready for release. Use a
+// testable import here so we can call the internal methods.
+@testable import Stagehand
+
+final class PerformanceBenchmarkViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = mainView
+ contentHeight = 360
+
+ animationRows = [
+ ("Reset", { [unowned self] in
+ self.animationInstances.forEach { $0.cancel(behavior: .revert) }
+ self.animationInstances = []
+ }),
+ ("Add Rotating Children Animation", { [unowned self] in
+ var animation = Animation()
+ for degree in 0...360 {
+ animation.addKeyframe(
+ for: \.transform,
+ ofElementsIn: \.rotatingChildViews,
+ at: { index, count in
+ let adjustedByIndex = Double(degree) / 360 + Double(index) / Double(count)
+ return adjustedByIndex - floor(adjustedByIndex)
+ },
+ value: CGAffineTransform.identity
+ .translatedBy(
+ x: View.radius * cos(CGFloat(degree) * .pi / 180),
+ y: View.radius * sin(CGFloat(degree) * .pi / 180)
+ )
+ .rotated(by: CGFloat(degree) * .pi / 180)
+ )
+ }
+
+ animation.duration = 4
+ animation.repeatStyle = .infinitelyRepeating(autoreversing: false)
+
+ self.animationInstances.append(animation.perform(on: self.mainView))
+ }),
+ ("Add Rotating Center View Animation", { [unowned self] in
+ var animation = Animation()
+
+ animation.addKeyframe(for: \.centerView.transform, at: 0.00, value: .identity)
+ animation.addKeyframe(for: \.centerView.transform, at: 0.25, value: .init(rotationAngle: .pi / 2))
+ animation.addKeyframe(for: \.centerView.transform, at: 0.50, value: .init(rotationAngle: .pi))
+ animation.addKeyframe(for: \.centerView.transform, at: 0.75, value: .init(rotationAngle: .pi * 3 / 2))
+ animation.addKeyframe(for: \.centerView.transform, at: 1.00, value: .identity)
+
+ animation.duration = 4
+ animation.repeatStyle = .infinitelyRepeating(autoreversing: true)
+
+ self.animationInstances.append(animation.perform(on: self.mainView))
+ }),
+ ("Add Center View Color with Haptics", { [unowned self] in
+ var animation = Animation()
+
+ let colors: [UIColor] = [.red, .green, .yellow, .purple, .blue, .brown, .orange]
+
+ let feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
+
+ for (index, color) in colors.enumerated() {
+ let progress = (Double(index) / Double(colors.count))
+
+ animation.addAssignment(
+ for: \View.centerView.backgroundColor,
+ at: progress,
+ value: color
+ )
+
+ animation.addExecution(
+ onForward: { _ in feedbackGenerator.impactOccurred() },
+ at: progress
+ )
+ }
+
+ animation.duration = 3.5
+ animation.repeatStyle = .infinitelyRepeating(autoreversing: false)
+
+ self.animationInstances.append(animation.perform(on: self.mainView))
+ }),
+ ]
+
+ mainView.childViewCountSlider.minimumValue = 0
+ mainView.childViewCountSlider.maximumValue = 20
+ mainView.childViewCountSlider.addTarget(self, action: #selector(updateChildCount), for: .valueChanged)
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ private var fpsDisplayLink: CADisplayLink?
+
+ private var animationInstances: [AnimationInstance] = [] {
+ didSet {
+ mainView.childViewCountSlider.isEnabled = animationInstances.isEmpty
+ }
+ }
+
+ @objc private func updateChildCount() {
+ mainView.rotatingChildViewCount = Int(mainView.childViewCountSlider.value)
+ mainView.resetRotatingChildViewTransforms()
+ }
+
+ // MARK: - UIViewController
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+
+ fpsDisplayLink = CADisplayLink(target: self, selector: #selector(updateFPS))
+ fpsDisplayLink?.add(to: .current, forMode: .common)
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+
+ fpsDisplayLink?.invalidate()
+ fpsDisplayLink = nil
+ }
+
+ // MARK: - Private Methods
+
+ @objc private func updateFPS() {
+ guard let displayLink = fpsDisplayLink else {
+ return
+ }
+
+ mainView.fpsLabel.text = "\((1 / (displayLink.targetTimestamp - displayLink.timestamp)).rounded()) FPS"
+ }
+
+}
+
+// MARK: -
+
+extension PerformanceBenchmarkViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ addSubview(animatedSubviewsContainer)
+
+ centerView.backgroundColor = .red
+ animatedSubviewsContainer.addSubview(centerView)
+
+ addSubview(childViewCountSlider)
+
+ fpsLabel.textAlignment = .right
+ fpsLabel.text = "-- FPS"
+ addSubview(fpsLabel)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Static Properties
+
+ static let radius: CGFloat = 100
+
+ // MARK: - Public Properties
+
+ var rotatingChildViewCount: Int {
+ get {
+ return rotatingChildViews.count
+ }
+ set {
+ rotatingChildViews = (0.. Yellow -> Green", { [unowned self] in
+ var animation = self.makeAnimation()
+ animation.addAssignment(for: \.backgroundColor, at: 0.33, value: .yellow)
+ animation.addAssignment(for: \.backgroundColor, at: 0.66, value: .green)
+
+ self.mainView.initialColorSlider.isEnabled = false
+ self.animationInstance = animation.perform(
+ on: self.mainView.animatableView,
+ completion: { [weak self] _ in
+ self?.mainView.initialColorSlider.isEnabled = true
+ }
+ )
+ }),
+ ("Assignment in Child Animation", { [unowned self] in
+ var childAnimation = self.makeAnimation()
+ childAnimation.addAssignment(for: \.backgroundColor, at: 0.33, value: .yellow)
+ childAnimation.addAssignment(for: \.backgroundColor, at: 0.66, value: .green)
+
+ var animation = Animation()
+ animation.addChild(childAnimation, for: \.animatableView, startingAt: 0, relativeDuration: 1)
+ animation.duration = 2
+
+ self.animationInstance = animation.perform(on: self.mainView)
+ }),
+ ("Current -> Yellow -> Green, with reversal", { [unowned self] in
+ var animation = self.makeAnimation()
+ animation.addAssignment(for: \.backgroundColor, at: 0.33, value: .yellow)
+ animation.addAssignment(for: \.backgroundColor, at: 0.66, value: .green)
+ animation.repeatStyle = .repeating(count: 2, autoreversing: true)
+
+ self.mainView.initialColorSlider.isEnabled = false
+ self.animationInstance = animation.perform(
+ on: self.mainView.animatableView,
+ completion: { [weak self] _ in
+ self?.mainView.initialColorSlider.isEnabled = true
+ }
+ )
+ }),
+ ]
+
+ mainView.initialColorSlider.addTarget(self, action: #selector(updateInitialColor), for: .valueChanged)
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ private var animationInstance: AnimationInstance?
+
+ // MARK: - Private Methods
+
+ private func makeAnimation() -> Animation {
+ var animation = Animation()
+ animation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: mainView.bounds.width - 100, y: 0))
+ animation.duration = 2
+ return animation
+ }
+
+ private func reset() {
+ animationInstance?.cancel()
+ animationInstance = nil
+
+ mainView.animatableView.transform = .identity
+ updateInitialColor()
+ }
+
+ @objc func updateInitialColor() {
+ mainView.animatableView.backgroundColor = UIColor(
+ hue: CGFloat(mainView.initialColorSlider.value),
+ saturation: 1,
+ brightness: 1,
+ alpha: 1
+ )
+ }
+
+}
+
+// MARK: -
+
+extension PropertyAssignmentViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableView.backgroundColor = .red
+ addSubview(animatableView)
+
+ addSubview(initialColorSlider)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ var animatableView: UIView = .init()
+
+ let initialColorSlider: UISlider = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ animatableView.bounds.size = .init(width: 50, height: 50)
+ animatableView.center = .init(
+ x: bounds.minX + 50,
+ y: bounds.minY + 60
+ )
+
+ initialColorSlider.bounds.size = initialColorSlider.sizeThatFits(bounds.insetBy(dx: 24, dy: 0).size)
+ initialColorSlider.frame.origin = .init(
+ x: 24,
+ y: animatableView.bounds.maxY + 60
+ )
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/RelativeAnimationsViewController.swift b/Example/Stagehand/RelativeAnimationsViewController.swift
new file mode 100644
index 0000000..5a8bfe7
--- /dev/null
+++ b/Example/Stagehand/RelativeAnimationsViewController.swift
@@ -0,0 +1,167 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import UIKit
+
+final class RelativeAnimationsViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ self.gestureRecognizerDelegate = .init(transformableView: mainView.animatableView)
+ self.gestureRecognizer = .init(
+ target: self.gestureRecognizerDelegate,
+ action: #selector(TransformGestureRecognizerDelegate.handlePan(_:))
+ )
+
+ super.init()
+
+ contentView = mainView
+ contentHeight = 300
+
+ animationRows = [
+ ("Reset", { [unowned self] in
+ self.gestureRecognizer.isEnabled = false
+
+ let animation = AnimationFactory.makeResetTransformAnimation()
+ animation.perform(on: self.mainView.animatableView) { [weak self] _ in
+ self?.gestureRecognizer.isEnabled = true
+ }
+ }),
+ ("Rotate 45°", { [unowned self] in
+ self.gestureRecognizer.isEnabled = false
+
+ let animation = AnimationFactory.makeRotateAnimation()
+ animation.perform(on: self.mainView.animatableView) { [weak self] _ in
+ self?.gestureRecognizer.isEnabled = true
+ }
+ }),
+ ("Pop", { [unowned self] in
+ self.gestureRecognizer.isEnabled = false
+
+ let animation = AnimationFactory.makePopAnimation()
+ animation.perform(on: self.mainView.animatableView) { [weak self] _ in
+ self?.gestureRecognizer.isEnabled = true
+ }
+ }),
+ ]
+
+ mainView.animatableView.addGestureRecognizer(gestureRecognizer)
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ private let gestureRecognizer: UIPanGestureRecognizer
+
+ private let gestureRecognizerDelegate: TransformGestureRecognizerDelegate
+
+}
+
+// MARK: -
+
+extension RelativeAnimationsViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableView.frame.size = .init(width: 50, height: 50)
+ animatableView.backgroundColor = .red
+ addSubview(animatableView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let animatableView: UIView = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ animatableView.center = .init(
+ x: (bounds.maxX - bounds.minX) / 2,
+ y: (bounds.maxY - bounds.minY) / 2
+ )
+ }
+
+ }
+
+}
+
+// MARK: -
+
+extension RelativeAnimationsViewController {
+
+ final class TransformGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate {
+
+ // MARK: - Life Cycle
+
+ init(transformableView: UIView) {
+ self.transformableView = transformableView
+ }
+
+ // MARK: - Private Properties
+
+ private let transformableView: UIView
+
+ private var initialTransform: CGAffineTransform?
+
+ // MARK: - Public Methods
+
+ @objc func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
+ switch gestureRecognizer.state {
+ case .possible, .failed:
+ break // No-op.
+
+ case .began:
+ initialTransform = transformableView.transform
+
+ case .changed:
+ guard let initialTransform = initialTransform else {
+ return
+ }
+
+ let translation = gestureRecognizer.translation(in: transformableView.superview)
+ let translationTransform = CGAffineTransform(
+ translationX: translation.x,
+ y: translation.y
+ )
+ transformableView.transform = initialTransform.concatenating(translationTransform)
+
+ case .cancelled:
+ transformableView.transform = initialTransform ?? .identity
+ initialTransform = nil
+
+ case .ended:
+ initialTransform = nil
+
+ @unknown default:
+ break
+ }
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/RepeatingAnimationsViewController.swift b/Example/Stagehand/RepeatingAnimationsViewController.swift
new file mode 100644
index 0000000..133df8c
--- /dev/null
+++ b/Example/Stagehand/RepeatingAnimationsViewController.swift
@@ -0,0 +1,151 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import UIKit
+
+final class RepeatingAnimationsViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = mainView
+
+ animationRows = [
+ ("Non-Repeating", { [unowned self] in
+ // Cancel any existing animation
+ self.animationInstance?.cancel(behavior: .revert)
+
+ var animation = self.makeAnimation()
+ animation.repeatStyle = .none
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Repeat Once", { [unowned self] in
+ // Cancel any existing animation
+ self.animationInstance?.cancel(behavior: .revert)
+
+ var animation = self.makeAnimation()
+ animation.repeatStyle = .repeating(count: 2, autoreversing: false)
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Repeat Once, Autoreversing", { [unowned self] in
+ // Cancel any existing animation
+ self.animationInstance?.cancel(behavior: .revert)
+
+ var animation = self.makeAnimation()
+ animation.repeatStyle = .repeating(count: 2, autoreversing: true)
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Repeat Twice", { [unowned self] in
+ // Cancel any existing animation
+ self.animationInstance?.cancel(behavior: .revert)
+
+ var animation = self.makeAnimation()
+ animation.repeatStyle = .repeating(count: 3, autoreversing: false)
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Repeat Twice, Autoreversing", { [unowned self] in
+ // Cancel any existing animation
+ self.animationInstance?.cancel(behavior: .revert)
+
+ var animation = self.makeAnimation()
+ animation.repeatStyle = .repeating(count: 3, autoreversing: true)
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Repeat Infinitely", { [unowned self] in
+ // Cancel any existing animation
+ self.animationInstance?.cancel(behavior: .revert)
+
+ var animation = self.makeAnimation()
+ animation.repeatStyle = .infinitelyRepeating(autoreversing: false)
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Repeat Infinitely, Autoreversing", { [unowned self] in
+ // Cancel any existing animation
+ self.animationInstance?.cancel(behavior: .revert)
+
+ var animation = self.makeAnimation()
+ animation.repeatStyle = .infinitelyRepeating(autoreversing: true)
+ self.animationInstance = animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Cancel (Revert)", { [unowned self] in
+ self.animationInstance?.cancel(behavior: .revert)
+ self.animationInstance = nil
+ }),
+ ("Cancel (Complete)", { [unowned self] in
+ self.animationInstance?.cancel(behavior: .complete)
+ self.animationInstance = nil
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+ private var animationInstance: AnimationInstance?
+
+ // MARK: - Private Methods
+
+ private func makeAnimation() -> Animation {
+ var animation = Animation()
+ animation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.transform, at: 1, value: .init(translationX: mainView.bounds.width - 100, y: 0))
+ animation.duration = 1
+ return animation
+ }
+
+}
+
+// MARK: -
+
+extension RepeatingAnimationsViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableView.backgroundColor = .red
+ addSubview(animatableView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let animatableView: UIView = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ animatableView.bounds.size = .init(width: 50, height: 50)
+ animatableView.center = .init(
+ x: bounds.minX + 50,
+ y: bounds.height / 2
+ )
+ }
+
+ }
+
+}
diff --git a/Example/Stagehand/RootViewController.swift b/Example/Stagehand/RootViewController.swift
new file mode 100644
index 0000000..ac5040d
--- /dev/null
+++ b/Example/Stagehand/RootViewController.swift
@@ -0,0 +1,75 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import UIKit
+
+final class RootViewController: UITableViewController {
+
+ // MARK: - Life Cycle
+
+ init() {
+ super.init(style: .plain)
+
+ navigationItem.title = "Stagehand Demo"
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Private Properties
+
+ private let demoScreens: [(name: String, viewControllerFactory: () -> UIViewController)] = [
+ ("Simple Animations", { SimpleAnimationsViewController() }),
+ ("Relative Animations", { RelativeAnimationsViewController() }),
+ ("Color Animations", { ColorAnimationsViewController() }),
+ ("Child Animations", { ChildAnimationsViewController() }),
+ ("Animation Curves", { AnimationCurveViewController() }),
+ ("Child Animations with Curves", { ChildAnimationsWithCurvesViewController() }),
+ ("Animation Cancellation", { AnimationCancelationViewController() }),
+ ("Property Assignments", { PropertyAssignmentViewController() }),
+ ("Repeating Animations", { RepeatingAnimationsViewController() }),
+ ("Execution Blocks", { ExecutionBlockViewController() }),
+ ("Animation Groups", { AnimationGroupViewController() }),
+ ("Collection Keyframes", { CollectionKeyframesViewController() }),
+ ("Performance Benchmark", { PerformanceBenchmarkViewController() }),
+ ("Animation Queues", { AnimationQueueViewController() }),
+ ]
+
+ // MARK: - UITableViewController
+
+ override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+ return demoScreens.count
+ }
+
+ override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+ return UITableViewCell(style: .default, reuseIdentifier: nil)
+ }
+
+ override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ let screen = demoScreens[indexPath.row]
+ cell.textLabel?.text = screen.name
+ }
+
+ override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ let screen = demoScreens[indexPath.row]
+ navigationController?.pushViewController(screen.viewControllerFactory(), animated: true)
+ tableView.deselectRow(at: indexPath, animated: true)
+ }
+
+}
+
diff --git a/Example/Stagehand/SimpleAnimationsViewController.swift b/Example/Stagehand/SimpleAnimationsViewController.swift
new file mode 100644
index 0000000..503a4db
--- /dev/null
+++ b/Example/Stagehand/SimpleAnimationsViewController.swift
@@ -0,0 +1,82 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import UIKit
+
+final class SimpleAnimationsViewController: DemoViewController {
+
+ // MARK: - Life Cycle
+
+ override init() {
+ super.init()
+
+ contentView = mainView
+
+ animationRows = [
+ ("Fade Out", { [unowned self] in
+ let animation = AnimationFactory.makeFadeOutAnimation()
+ animation.perform(on: self.mainView.animatableView)
+ }),
+ ("Fade In", { [unowned self] in
+ let animation = AnimationFactory.makeFadeInAnimation()
+ animation.perform(on: self.mainView.animatableView)
+ }),
+ ]
+ }
+
+ // MARK: - Private Properties
+
+ private let mainView: View = .init()
+
+}
+
+// MARK: -
+
+extension SimpleAnimationsViewController {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableView.frame.size = .init(width: 50, height: 50)
+ animatableView.backgroundColor = .red
+ addSubview(animatableView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let animatableView: UIView = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ animatableView.center = .init(
+ x: (bounds.maxX - bounds.minX) / 2,
+ y: (bounds.maxY - bounds.minY) / 2
+ )
+ }
+
+ }
+
+}
diff --git a/Example/Unit Tests/AnimationInstanceTests.swift b/Example/Unit Tests/AnimationInstanceTests.swift
new file mode 100644
index 0000000..976f3ff
--- /dev/null
+++ b/Example/Unit Tests/AnimationInstanceTests.swift
@@ -0,0 +1,479 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import XCTest
+
+@testable import Stagehand
+
+final class AnimationInstanceTests: XCTestCase {
+
+ // MARK: - Tests - Keyframes
+
+ func testKeyframesWithFixedValues() {
+ let element = AnimatableElement(
+ propertyOne: 0,
+ propertyTwo: 0
+ )
+
+ var animation = Animation()
+
+ // Run the first property forward.
+ animation.addKeyframe(for: \.propertyOne, at: 0, value: 1)
+ animation.addKeyframe(for: \.propertyOne, at: 1, value: 2)
+
+ // Run the second property in reverse.
+ animation.addKeyframe(for: \.propertyTwo, at: 0, value: 2)
+ animation.addKeyframe(for: \.propertyTwo, at: 1, value: 1)
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ driver.runForward(to: 0)
+ XCTAssertEqual(element.propertyOne, 1)
+ XCTAssertEqual(element.propertyTwo, 2)
+
+ driver.runForward(to: 0.5)
+ XCTAssertEqual(element.propertyOne, 1.5)
+ XCTAssertEqual(element.propertyTwo, 1.5)
+
+ driver.runForward(to: 1)
+ XCTAssertEqual(element.propertyOne, 2)
+ XCTAssertEqual(element.propertyTwo, 1)
+
+ _ = animationInstance
+ }
+
+ func testKeyframesWithRelativeValues() {
+ let element = AnimatableElement(
+ propertyOne: 2,
+ propertyTwo: 2
+ )
+
+ var animation = Animation()
+
+ animation.addKeyframe(for: \.propertyOne, at: 0, value: 1)
+ animation.addKeyframe(for: \.propertyOne, at: 1, relativeValue: { $0 })
+
+ animation.addKeyframe(for: \.propertyTwo, at: 0, relativeValue: { $0 })
+ animation.addKeyframe(for: \.propertyTwo, at: 1, value: 1)
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ driver.runForward(to: 0)
+ XCTAssertEqual(element.propertyOne, 1)
+ XCTAssertEqual(element.propertyTwo, 2)
+
+ driver.runForward(to: 0.5)
+ XCTAssertEqual(element.propertyOne, 1.5)
+ XCTAssertEqual(element.propertyTwo, 1.5)
+
+ driver.runForward(to: 1)
+ XCTAssertEqual(element.propertyOne, 2)
+ XCTAssertEqual(element.propertyTwo, 1)
+
+ _ = animationInstance
+ }
+
+ func testKeyframesWithMissingTerminalValues() {
+ let element = AnimatableElement(
+ propertyOne: 0
+ )
+
+ var animation = Animation()
+
+ animation.addKeyframe(for: \.propertyOne, at: 0.25, value: 2)
+ animation.addKeyframe(for: \.propertyOne, at: 0.75, value: 4)
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ // Before the first keyframe, the value of the first keyframe should be used.
+ driver.runForward(to: 0)
+ XCTAssertEqual(element.propertyOne, 2)
+
+ driver.runForward(to: 0.25)
+ XCTAssertEqual(element.propertyOne, 2)
+
+ driver.runForward(to: 0.5)
+ XCTAssertEqual(element.propertyOne, 3)
+
+ driver.runForward(to: 0.75)
+ XCTAssertEqual(element.propertyOne, 4)
+
+ // After the last keyframe, the value of the last keyframe should be used.
+ driver.runForward(to: 1)
+ XCTAssertEqual(element.propertyOne, 4)
+
+ _ = animationInstance
+ }
+
+ func testKeyframesWithMultipleSegments() {
+ let element = AnimatableElement(
+ propertyOne: 0
+ )
+
+ var animation = Animation()
+
+ animation.addKeyframe(for: \.propertyOne, at: 0, value: 1)
+ animation.addKeyframe(for: \.propertyOne, at: 0.5, value: 2)
+ animation.addKeyframe(for: \.propertyOne, at: 1, value: 4)
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ driver.runForward(to: 0)
+ XCTAssertEqual(element.propertyOne, 1)
+
+ driver.runForward(to: 0.25)
+ XCTAssertEqual(element.propertyOne, 1.5)
+
+ driver.runForward(to: 0.5)
+ XCTAssertEqual(element.propertyOne, 2)
+
+ driver.runForward(to: 0.75)
+ XCTAssertEqual(element.propertyOne, 3)
+
+ driver.runForward(to: 1)
+ XCTAssertEqual(element.propertyOne, 4)
+
+ _ = animationInstance
+ }
+
+ func testKeyframesOfOptionalProperty() {
+ let element = AnimatableElement()
+
+ var animation = Animation()
+
+ animation.addKeyframe(for: \.propertyFive, at: 0, value: 0)
+ animation.addKeyframe(for: \.propertyFive, at: 1, value: 1)
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ driver.runForward(to: 0.5)
+ XCTAssertEqual(element.propertyFive, 0.5)
+
+ _ = animationInstance
+ }
+
+ // MARK: - Tests - Property Assignments
+
+ func testPropertyAssignment() {
+ let initialValue = "Hello world"
+ let midpointValue = "What's up world"
+ let finalValue = "Yo"
+
+ let element = AnimatableElement(
+ propertyThree: initialValue
+ )
+
+ var animation = Animation()
+ animation.addAssignment(for: \.propertyThree, at: 0.5, value: midpointValue)
+ animation.addAssignment(for: \.propertyThree, at: 1, value: finalValue)
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ driver.runForward(to: 0.33)
+ XCTAssertEqual(element.propertyThree, initialValue)
+
+ driver.runForward(to: 0.5)
+ XCTAssertEqual(element.propertyThree, midpointValue)
+
+ driver.runForward(to: 0.66)
+ XCTAssertEqual(element.propertyThree, midpointValue)
+
+ driver.runForward(to: 1)
+ XCTAssertEqual(element.propertyThree, finalValue)
+
+ animationInstance.executeBlocks(from: 1, .inclusive, to: 0.66)
+ XCTAssertEqual(element.propertyThree, midpointValue)
+
+ animationInstance.executeBlocks(from: 1, .inclusive, to: 0.5)
+ XCTAssertEqual(element.propertyThree, initialValue)
+
+ _ = animationInstance
+ }
+
+ // MARK: - Tests - Execution Blocks
+
+ func testExecutionBlocks() {
+ let element = AnimatableElement()
+
+ var executedBlocks: [String] = []
+
+ var animation = Animation()
+
+ animation.addExecution(
+ onForward: { _ in executedBlocks.append("A") },
+ onReverse: { _ in executedBlocks.append("A'") },
+ at: 0
+ )
+
+ animation.addExecution(
+ onForward: { _ in executedBlocks.append("C") },
+ onReverse: { _ in executedBlocks.append("C'") },
+ at: 0.75
+ )
+
+ animation.addExecution(
+ onForward: { _ in executedBlocks.append("B") },
+ onReverse: { _ in executedBlocks.append("B'") },
+ at: 0.5
+ )
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: TestDriver()
+ )
+
+ // Test that the starting timestamp is inclusive when specified as such.
+ animationInstance.executeBlocks(from: 0, .inclusive, to: 0.25)
+ XCTAssertEqual(executedBlocks, ["A"])
+
+ // Test that the ending timestamp is inclusive.
+ executedBlocks = []
+ animationInstance.executeBlocks(from: 0.25, .exclusive, to: 0.5)
+ XCTAssertEqual(executedBlocks, ["B"])
+
+ // Test that the execution blocks are executed in the correct order.
+ executedBlocks = []
+ animationInstance.executeBlocks(from: 0, .inclusive, to: 1)
+ XCTAssertEqual(executedBlocks, ["A", "B", "C"])
+
+ // Test that excluding the starting timestamp doesn't include a block at that timestamp.
+ executedBlocks = []
+ animationInstance.executeBlocks(from: 0, .exclusive, to: 1)
+ XCTAssertEqual(executedBlocks, ["B", "C"])
+
+ // Test that the ending timestamp is inclusive when running in reverse.
+ executedBlocks = []
+ animationInstance.executeBlocks(from: 1, .exclusive, to: 0.75)
+ XCTAssertEqual(executedBlocks, ["C'"])
+
+ // Test that the starting timestamp is inclusive when specified as such.
+ executedBlocks = []
+ animationInstance.executeBlocks(from: 0.75, .inclusive, to: 0.5)
+ XCTAssertEqual(executedBlocks, ["C'", "B'"])
+
+ // Test that the starting timestamp is exclusive when specified as such.
+ executedBlocks = []
+ animationInstance.executeBlocks(from: 0.75, .exclusive, to: 0.5)
+ XCTAssertEqual(executedBlocks, ["B'"])
+
+ // Test that the blocks are ordered correctly when running in reverse.
+ executedBlocks = []
+ animationInstance.executeBlocks(from: 1, .inclusive, to: 0)
+ XCTAssertEqual(executedBlocks, ["C'", "B'", "A'"])
+ }
+
+ // MARK: - Tests - Per-Frame Execution Blocks
+
+ func testPerFrameExecutionBlocks() {
+ let element = AnimatableElement()
+
+ var animation = Animation()
+ animation.curve = ReverseAnimationCurve()
+
+ var executionCount: Int = 0
+ var lastContext: Animation.FrameContext? = nil
+
+ func resetExecutedContext() {
+ executionCount = 0
+ lastContext = nil
+ }
+
+ animation.addPerFrameExecution { context in
+ executionCount += 1
+ lastContext = context
+ }
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: TestDriver()
+ )
+
+ animationInstance.renderFrame(at: 0)
+ XCTAssertEqual(executionCount, 1)
+ XCTAssertEqual(
+ lastContext,
+ Animation.FrameContext(
+ element: element,
+ uncurvedProgress: 0,
+ progress: 1
+ )
+ )
+
+ resetExecutedContext()
+
+ animationInstance.renderFrame(at: 0.25)
+ XCTAssertEqual(executionCount, 1)
+ XCTAssertEqual(
+ lastContext,
+ Animation.FrameContext(
+ element: element,
+ uncurvedProgress: 0.25,
+ progress: 0.75
+ )
+ )
+ }
+
+ // MARK: - Tests - Animation Curves
+
+ func testAnimationCurves() {
+ let element = AnimatableElement()
+
+ var animation = Animation()
+ animation.curve = SinusoidalEaseInEaseOutAnimationCurve()
+
+ animation.addKeyframe(for: \.propertyFour, at: 0, value: 0)
+ animation.addKeyframe(for: \.propertyFour, at: 1, value: 1)
+
+ animation.addPerFrameExecution { context in
+ XCTAssertEqual(context.progress, element.propertyFour)
+ }
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: TestDriver()
+ )
+
+ animationInstance.renderFrame(at: 0)
+ animationInstance.renderFrame(at: 0.25)
+ animationInstance.renderFrame(at: 0.5)
+ animationInstance.renderFrame(at: 0.75)
+ animationInstance.renderFrame(at: 1)
+ }
+
+}
+
+// MARK: -
+
+extension AnimationInstanceTests {
+
+ final class AnimatableElement: Equatable {
+
+ // MARK: - Life Cycle
+
+ init(
+ propertyOne: CGFloat = 0,
+ propertyTwo: CGFloat = 0,
+ propertyThree: String = "",
+ propertyFour: Double = 0,
+ propertyFive: Double? = nil
+ ) {
+ self.propertyOne = propertyOne
+ self.propertyTwo = propertyTwo
+ self.propertyThree = propertyThree
+ self.propertyFour = propertyFour
+ self.propertyFive = propertyFive
+ }
+
+ // MARK: - Public Properties
+
+ var propertyOne: CGFloat
+
+ var propertyTwo: CGFloat
+
+ var propertyThree: String
+
+ var propertyFour: Double
+
+ var propertyFive: Double?
+
+ // MARK: - Equatable
+
+ static func == (lhs: AnimationInstanceTests.AnimatableElement, rhs: AnimationInstanceTests.AnimatableElement) -> Bool {
+ return lhs.propertyOne == rhs.propertyOne
+ && lhs.propertyTwo == rhs.propertyTwo
+ && lhs.propertyThree == rhs.propertyThree
+ && lhs.propertyFour == rhs.propertyFour
+ && lhs.propertyFive == rhs.propertyFive
+ }
+
+ }
+
+}
+
+// MARK: -
+
+private struct ReverseAnimationCurve: AnimationCurve {
+
+ func adjustedProgress(for progress: Double) -> Double {
+ return 1 - progress
+ }
+
+}
+
+// MARK: -
+
+extension Animation.FrameContext: Equatable where ElementType: Equatable {
+
+ public static func == (lhs: Animation.FrameContext, rhs: Animation.FrameContext) -> Bool {
+ return lhs.element == rhs.element
+ && lhs.uncurvedProgress == rhs.uncurvedProgress
+ && lhs.progress == rhs.progress
+ }
+
+}
+
+// MARK: -
+
+extension Double: AnimatableOptionalProperty {
+
+ public static func optionalValue(between initialValue: Double?, and finalValue: Double?, at progress: Double) -> Double? {
+ guard let initialValue = initialValue, let finalValue = finalValue else {
+ return nil
+ }
+
+ return Double.value(between: initialValue, and: finalValue, at: progress)
+ }
+
+}
diff --git a/Example/Unit Tests/AnimationOptimizationTests.swift b/Example/Unit Tests/AnimationOptimizationTests.swift
new file mode 100644
index 0000000..a445ec5
--- /dev/null
+++ b/Example/Unit Tests/AnimationOptimizationTests.swift
@@ -0,0 +1,223 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import XCTest
+
+@testable import Stagehand
+
+final class AnimationOptimizationTests: XCTestCase {
+
+ // MARK: - Tests - Ubiquitous Bezier Curve
+
+ func testUbiquitousBezierCurveElevation_singleChild() {
+ var parentAnimation = Animation()
+
+ var childAnimation = Animation()
+ childAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ childAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
+ parentAnimation.addChild(childAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssertEqual(optimizedAnimation.curve as? CubicBezierAnimationCurve, CubicBezierAnimationCurve.easeInEaseOut)
+ XCTAssert(optimizedAnimation.children.allSatisfy { $0.animation.curve is LinearAnimationCurve })
+ }
+
+ func testUbiquitousBezierCurveElevation_multipleChildren() {
+ var parentAnimation = Animation()
+
+ var firstChildAnimation = Animation()
+ firstChildAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ firstChildAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
+ parentAnimation.addChild(firstChildAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ var secondChildAnimation = Animation()
+ secondChildAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ secondChildAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
+ parentAnimation.addChild(secondChildAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssertEqual(optimizedAnimation.curve as? CubicBezierAnimationCurve, CubicBezierAnimationCurve.easeInEaseOut)
+ XCTAssert(optimizedAnimation.children.allSatisfy { $0.animation.curve is LinearAnimationCurve })
+ }
+
+ func testUbiquitousBezierCurveElevation_grandchild() {
+ var parentAnimation = Animation()
+
+ var grandchildAnimation = Animation()
+ grandchildAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ grandchildAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
+
+ var childAnimation = Animation()
+ childAnimation.addChild(grandchildAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+ parentAnimation.addChild(childAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssertEqual(optimizedAnimation.curve as? CubicBezierAnimationCurve, CubicBezierAnimationCurve.easeInEaseOut)
+ XCTAssert(optimizedAnimation.children.allSatisfy {
+ $0.animation.curve is LinearAnimationCurve
+ && $0.animation.children.allSatisfy { $0.animation.curve is LinearAnimationCurve }
+ })
+ }
+
+ func testUbiquitousBezierCurveElevation_notElevatedWhenParentHasContent() {
+ var parentAnimation = Animation()
+ parentAnimation.addKeyframe(for: \.alpha, at: 0, value: 1)
+
+ var childAnimation = Animation()
+ childAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ childAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
+ parentAnimation.addChild(childAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssert(optimizedAnimation.curve is LinearAnimationCurve)
+ XCTAssert(optimizedAnimation.children.allSatisfy {
+ $0.animation.curve as? CubicBezierAnimationCurve == CubicBezierAnimationCurve.easeInEaseOut
+ })
+ }
+
+ func testUbiquitousBezierCurveElevation_notElevatedWhenParentCurveIsNotLinear() {
+ var parentAnimation = Animation()
+ parentAnimation.curve = ParabolicEaseInAnimationCurve()
+
+ var childAnimation = Animation()
+ childAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ childAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
+ parentAnimation.addChild(childAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssert(optimizedAnimation.curve is ParabolicEaseInAnimationCurve)
+ XCTAssert(optimizedAnimation.children.allSatisfy {
+ $0.animation.curve as? CubicBezierAnimationCurve == CubicBezierAnimationCurve.easeInEaseOut
+ })
+ }
+
+ func testUbiquitousBezierCurveElevation_notElevatedWhenAChildDoesNotCoverFullInterval() {
+ var parentAnimation = Animation()
+
+ var firstChildAnimation = Animation()
+ firstChildAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ firstChildAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
+ parentAnimation.addChild(firstChildAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ var secondChildAnimation = Animation()
+ secondChildAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ secondChildAnimation.curve = CubicBezierAnimationCurve.easeInEaseOut
+ parentAnimation.addChild(secondChildAnimation, for: \.self, startingAt: 0.5, relativeDuration: 0.5)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssert(optimizedAnimation.curve is LinearAnimationCurve)
+ XCTAssert(optimizedAnimation.children.allSatisfy {
+ $0.animation.curve as? CubicBezierAnimationCurve == CubicBezierAnimationCurve.easeInEaseOut
+ })
+ }
+
+ func testUbiquitousBezierCurveElevation_notElevatedWhenNotAllChildrenHaveSameCurve() {
+ var parentAnimation = Animation()
+
+ var firstChildAnimation = Animation()
+ firstChildAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ firstChildAnimation.curve = CubicBezierAnimationCurve.easeIn
+ parentAnimation.addChild(firstChildAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ var secondChildAnimation = Animation()
+ secondChildAnimation.addKeyframe(for: \.alpha, at: 0, value: 0)
+ secondChildAnimation.curve = CubicBezierAnimationCurve.easeOut
+ parentAnimation.addChild(secondChildAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssert(optimizedAnimation.curve is LinearAnimationCurve)
+ XCTAssert(optimizedAnimation.children[0].animation.curve as? CubicBezierAnimationCurve == .easeIn)
+ XCTAssert(optimizedAnimation.children[1].animation.curve as? CubicBezierAnimationCurve == .easeOut)
+ }
+
+ // MARK: - Tests - Remove Obsolete Keyframes
+
+ func testObsoleteKeyframeRemoval_selfProperty() {
+ var parentAnimation = Animation()
+ parentAnimation.addKeyframe(for: \.alpha, at: 0, value: 1)
+
+ var childAnimation = Animation()
+ childAnimation.addKeyframe(for: \.alpha, at: 0.5, value: 0.5)
+ childAnimation.addKeyframe(for: \.transform, at: 0, value: .identity)
+ parentAnimation.addChild(childAnimation, for: \.self, startingAt: 0, relativeDuration: 1)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssertEqual(Array(optimizedAnimation.keyframeSeriesByProperty.keys), [\UIView.alpha])
+ XCTAssertEqual(Array(optimizedAnimation.children[0].animation.keyframeSeriesByProperty.keys), [\UIView.transform])
+ }
+
+ func testObsoleteKeyframeRemoval_subelementProperty() {
+ var parentAnimation = Animation()
+ parentAnimation.addKeyframe(for: \.subelement.propertyOne, at: 0, value: 1)
+
+ var childAnimation = Animation()
+ childAnimation.addKeyframe(for: \.propertyOne, at: 0.5, value: 0.5)
+ childAnimation.addKeyframe(for: \.propertyTwo, at: 0.5, value: 0.5)
+ parentAnimation.addChild(childAnimation, for: \.subelement, startingAt: 0, relativeDuration: 1)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssertEqual(Array(optimizedAnimation.keyframeSeriesByProperty.keys), [\Element.subelement.propertyOne])
+ XCTAssertEqual(Array(optimizedAnimation.children[0].animation.keyframeSeriesByProperty.keys), [\Element.subelement.propertyTwo])
+ }
+
+ func testObsoleteKeyframeRemoval_removesEmptyChildAfterRemovingKeyframes() {
+ var parentAnimation = Animation()
+ parentAnimation.addKeyframe(for: \.subelement.propertyOne, at: 0, value: 1)
+
+ var childAnimation = Animation()
+ childAnimation.addKeyframe(for: \.propertyOne, at: 0.5, value: 0.5)
+ parentAnimation.addChild(childAnimation, for: \.subelement, startingAt: 0, relativeDuration: 1)
+
+ let optimizedAnimation = parentAnimation.optimized()
+
+ XCTAssertEqual(Array(optimizedAnimation.keyframeSeriesByProperty.keys), [\Element.subelement.propertyOne])
+ XCTAssert(optimizedAnimation.children.isEmpty)
+ }
+
+}
+
+// MARK: -
+
+private extension AnimationOptimizationTests {
+
+ final class Element {
+
+ init() { }
+
+ var subelement: Subelement = .init()
+
+ }
+
+ final class Subelement {
+
+ init() { }
+
+ var propertyOne: Double = 0
+
+ var propertyTwo: Double = 0
+
+ }
+
+}
diff --git a/Example/Unit Tests/AnimationSnapshotTests.swift b/Example/Unit Tests/AnimationSnapshotTests.swift
new file mode 100644
index 0000000..741ecc5
--- /dev/null
+++ b/Example/Unit Tests/AnimationSnapshotTests.swift
@@ -0,0 +1,178 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Stagehand
+import StagehandTesting
+import FBSnapshotTestCase
+
+final class AnimationSnapshotTests: FBSnapshotTestCase {
+
+ override func setUp() {
+ super.setUp()
+
+ recordMode = false
+ }
+
+ // MARK: - Tests - Frame Snapshots
+
+ func testSimpleAnimationSnapshot() {
+ let view = View(frame: .init(x: 0, y: 0, width: 200, height: 40))
+
+ var animation = Animation()
+ animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
+
+ SnapshotVerify(animation: animation, on: view, at: 0, identifier: "start")
+ SnapshotVerify(animation: animation, on: view, at: 0.5, identifier: "middle")
+ SnapshotVerify(animation: animation, on: view, at: 1, identifier: "end")
+
+ // This intentionally uses the same identifier as the animation at 0 to ensure that the view is restored to its
+ // original state after snapshotting.
+ FBSnapshotVerifyView(view, identifier: "start")
+ }
+
+ func testAnimationWithExecutionBlocksSnapshot() {
+ let view = View(frame: .init(x: 0, y: 0, width: 200, height: 40))
+
+ var animation = Animation()
+ animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
+ animation.addExecution(
+ onForward: { $0.animatableView.backgroundColor = .green },
+ onReverse: { $0.animatableView.backgroundColor = .red },
+ at: 0.5
+ )
+
+ SnapshotVerify(animation: animation, on: view, at: 0, identifier: "start")
+ SnapshotVerify(animation: animation, on: view, at: 0.5, identifier: "middle")
+ SnapshotVerify(animation: animation, on: view, at: 1, identifier: "end")
+
+ // This intentionally uses the same identifier as the animation at 0 to ensure that the view is restored to its
+ // original state after snapshotting.
+ FBSnapshotVerifyView(view, identifier: "start")
+ }
+
+ // MARK: - Tests - Animated GIF
+
+ func testSimpleAnimationSnapshotGIF() {
+ let view = View(frame: .init(x: 0, y: 0, width: 200, height: 40))
+
+ var animation = Animation()
+ animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
+
+ SnapshotVerify(animation: animation, on: view)
+ }
+
+ func testSimpleAnimationSnapshotGIFAtHighFPS() {
+ let view = View(frame: .init(x: 0, y: 0, width: 200, height: 40))
+
+ var animation = Animation()
+ animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
+
+ SnapshotVerify(animation: animation, on: view, fps: 30)
+ }
+
+ func testLongAnimationSnapshotGIF() {
+ let view = View(frame: .init(x: 0, y: 0, width: 200, height: 40))
+
+ var animation = Animation()
+ animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
+ animation.duration = 2
+
+ SnapshotVerify(animation: animation, on: view)
+ }
+
+ func testAutoreversingAnimationSnapshotGIF() {
+ let view = View(frame: .init(x: 0, y: 0, width: 200, height: 40))
+
+ var animation = Animation()
+ animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
+ animation.repeatStyle = .infinitelyRepeating(autoreversing: true)
+
+ SnapshotVerify(animation: animation, on: view, bookendFrameDuration: .matchIntermediateFrames)
+ }
+
+ func testAnimationWithExecutionBlocksSnapshotGIF() {
+ let view = View(frame: .init(x: 0, y: 0, width: 200, height: 40))
+
+ var animation = Animation()
+ animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
+ animation.addExecution(
+ onForward: { $0.animatableView.backgroundColor = .green },
+ onReverse: { $0.animatableView.backgroundColor = .red },
+ at: 0.5
+ )
+
+ SnapshotVerify(animation: animation, on: view)
+ }
+
+ func testAutoreversingAnimationWithExecutionBlocksSnapshotGIF() {
+ let view = View(frame: .init(x: 0, y: 0, width: 200, height: 40))
+
+ var animation = Animation()
+ animation.addKeyframe(for: \.animatableView.transform, at: 0, value: .identity)
+ animation.addKeyframe(for: \.animatableView.transform, at: 1, value: .init(translationX: 160, y: 0))
+ animation.addExecution(
+ onForward: { $0.animatableView.backgroundColor = .green },
+ onReverse: { $0.animatableView.backgroundColor = .red },
+ at: 0.5
+ )
+ animation.repeatStyle = .infinitelyRepeating(autoreversing: true)
+
+ SnapshotVerify(animation: animation, on: view)
+ }
+
+}
+
+// MARK: -
+
+extension AnimationSnapshotTests {
+
+ final class View: UIView {
+
+ // MARK: - Life Cycle
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ animatableView.backgroundColor = .red
+ addSubview(animatableView)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: - Public Properties
+
+ let animatableView: UIView = .init()
+
+ // MARK: - UIView
+
+ override func layoutSubviews() {
+ animatableView.bounds.size = .init(width: 20, height: 20)
+ animatableView.center = .init(x: 20, y: bounds.midY)
+ }
+
+ }
+
+}
diff --git a/Example/Unit Tests/ChildAnimationTests.swift b/Example/Unit Tests/ChildAnimationTests.swift
new file mode 100644
index 0000000..d22e1a0
--- /dev/null
+++ b/Example/Unit Tests/ChildAnimationTests.swift
@@ -0,0 +1,207 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import XCTest
+
+@testable import Stagehand
+
+final class ChildAnimationTests: XCTestCase {
+
+ // MARK: - Tests - Keyframes
+
+ func testKeyframes_simpleConfiguration() {
+ var childAnimation = Animation()
+ childAnimation.addKeyframe(for: \.propertyOne, at: 0, value: 0)
+ childAnimation.addKeyframe(for: \.propertyOne, at: 1, value: 1)
+
+ var animation = Animation()
+ animation.addChild(childAnimation, for: \.subelementOne, startingAt: 0, relativeDuration: 1)
+ animation.addChild(childAnimation, for: \.subelementTwo, startingAt: 0, relativeDuration: 1)
+
+ let element = Element()
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ driver.runForward(to: 0.25)
+ XCTAssertEqual(element.subelementOne.propertyOne, 0.25)
+ XCTAssertEqual(element.subelementTwo.propertyOne, 0.25)
+
+ _ = animationInstance
+ }
+
+ func testKeyframes_twoChildrenForSameSubelement() {
+ var childAnimation1 = Animation()
+ childAnimation1.addKeyframe(for: \.propertyOne, at: 0, value: 0)
+ childAnimation1.addKeyframe(for: \.propertyOne, at: 1, value: 1)
+
+ var childAnimation2 = Animation()
+ childAnimation2.addKeyframe(for: \.propertyTwo, at: 0, value: 0)
+ childAnimation2.addKeyframe(for: \.propertyTwo, at: 1, value: 1)
+
+ var animation = Animation()
+ animation.addChild(childAnimation1, for: \.subelementOne, startingAt: 0, relativeDuration: 1)
+ animation.addChild(childAnimation2, for: \.subelementOne, startingAt: 0, relativeDuration: 1)
+
+ let element = Element()
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ driver.runForward(to: 0.25)
+ XCTAssertEqual(element.subelementOne.propertyOne, 0.25)
+ XCTAssertEqual(element.subelementOne.propertyTwo, 0.25)
+
+ _ = animationInstance
+ }
+
+ func testKeyframes_childOverPartialCycle() {
+ var childAnimation = Animation()
+ childAnimation.addKeyframe(for: \.propertyOne, at: 0, value: 0)
+ childAnimation.addKeyframe(for: \.propertyOne, at: 1, value: 1)
+
+ var animation = Animation()
+ animation.addChild(childAnimation, for: \.subelementOne, startingAt: 0.25, relativeDuration: 0.5)
+
+ let element = Element()
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ // Until the child animation begins, the value should be the initial value.
+ driver.runForward(to: 0.2)
+ XCTAssertEqual(element.subelementOne.propertyOne, 0)
+
+ driver.runForward(to: 0.5)
+ XCTAssertEqual(element.subelementOne.propertyOne, 0.5)
+
+ // After the child ends, the value should be the final value.
+ driver.runForward(to: 0.8)
+ XCTAssertEqual(element.subelementOne.propertyOne, 1)
+
+ _ = animationInstance
+ }
+
+ func testKeyframes_childOverriddenByParent() {
+ var childAnimation = Animation()
+ childAnimation.addKeyframe(for: \.propertyOne, at: 0, value: 1)
+ childAnimation.addKeyframe(for: \.propertyOne, at: 1, value: 0)
+
+ var animation = Animation()
+ animation.addKeyframe(for: \.subelementOne.propertyOne, at: 0, value: 0)
+ animation.addKeyframe(for: \.subelementOne.propertyOne, at: 1, value: 1)
+
+ animation.addChild(childAnimation, for: \.subelementOne, startingAt: 0, relativeDuration: 1)
+
+ let element = Element()
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ // When the parent animation specifies keyframes for a property, those keyframes should be preferred over that
+ // of the child animation (even when the child animation is added after the keyframes are defined).
+ driver.runForward(to: 0.25)
+ XCTAssertEqual(element.subelementOne.propertyOne, 0.25)
+
+ _ = animationInstance
+ }
+
+ // This test is currently disabled because it doesn't handle delaying the start of the second child animation until
+ // the first has finished.
+ func testKeyframes_sequentialChildrenForSameProperty() {
+ var downChildAnimation = Animation()
+ downChildAnimation.addKeyframe(for: \.propertyOne, at: 0, value: 1)
+ downChildAnimation.addKeyframe(for: \.propertyOne, at: 1, value: 0)
+
+ var upChildAnimation = Animation()
+ upChildAnimation.addKeyframe(for: \.propertyOne, at: 0, value: 0)
+ upChildAnimation.addKeyframe(for: \.propertyOne, at: 1, value: 1)
+
+ var animation = Animation()
+ animation.addChild(downChildAnimation, for: \.subelementOne, startingAt: 0, relativeDuration: 0.5)
+ animation.addChild(upChildAnimation, for: \.subelementOne, startingAt: 0.5, relativeDuration: 0.5)
+
+ let element = Element()
+
+ let driver = TestDriver()
+
+ let animationInstance = AnimationInstance(
+ animation: animation,
+ element: element,
+ driver: driver
+ )
+
+ driver.runForward(to: 0)
+ XCTAssertEqual(element.subelementOne.propertyOne, 1)
+
+ driver.runForward(to: 0.25)
+ XCTAssertEqual(element.subelementOne.propertyOne, 0.5)
+
+ driver.runForward(to: 0.5)
+ XCTAssertEqual(element.subelementOne.propertyOne, 0)
+
+ driver.runForward(to: 0.75)
+ XCTAssertEqual(element.subelementOne.propertyOne, 0.5)
+
+ driver.runForward(to: 1)
+ XCTAssertEqual(element.subelementOne.propertyOne, 1)
+
+ _ = animationInstance
+ }
+
+}
+
+// MARK: -
+
+private extension ChildAnimationTests {
+
+ final class Element {
+
+ var subelementOne: Subelement = .init()
+
+ var subelementTwo: Subelement = .init()
+
+ }
+
+ final class Subelement {
+
+ var propertyOne: CGFloat = -1
+
+ var propertyTwo: CGFloat = -1
+
+ }
+
+}
diff --git a/Example/Unit Tests/DisplayLinkDriverTests.swift b/Example/Unit Tests/DisplayLinkDriverTests.swift
new file mode 100644
index 0000000..d24e8a6
--- /dev/null
+++ b/Example/Unit Tests/DisplayLinkDriverTests.swift
@@ -0,0 +1,1017 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import XCTest
+
+@testable import Stagehand
+
+final class DisplayLinkDriverTests: XCTestCase {
+
+ // MARK: - Tests - Rendering
+
+ func testZeroDurationZeroDelay() {
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 0,
+ repeatStyle: .none,
+ completion: nil
+ )
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start()
+
+ // When there is a zero duration animation with no delay, the final frame should be rendered immediately.
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .inclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 1)
+
+ driver.animationInstanceDidCancel(behavior: .halt)
+ }
+
+ func testZeroDurationNonZeroDelay() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 1,
+ duration: 0,
+ repeatStyle: .none,
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ // When there is an animation with a non-zero delay, no frames should be rendered immediately.
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 0)
+ XCTAssertEqual(instance.renderedFrames, [])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ // Until the delay is met, nothing should be rendering.
+ displayLink.simulateRunLoop(at: 0.99)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 0)
+ XCTAssertEqual(instance.renderedFrames, [])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ // As soon as the delay has been met, the final frame of the animation should be rendered.
+ displayLink.simulateRunLoop(at: 1)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .inclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 1)
+
+ driver.animationInstanceDidCancel(behavior: .halt)
+ }
+
+ func testNonZeroDurationZeroDelay() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .none,
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ // When there is a non-zero duration animation with no delay, the initial frame should be rendered immediately.
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .inclusive, 0))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // On the next run loop, the animation should be executed to that point.
+ displayLink.simulateRunLoop(at: 0.5)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .exclusive, 0.5))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.5])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // On the final run loop, the animation should be executed to the end.
+ displayLink.simulateRunLoop(at: 1)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 1)
+
+ driver.animationInstanceDidCancel(behavior: .halt)
+ }
+
+ func testCompletesPastEndPoint() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .none,
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 0.5)
+ instance.clearGatheredData()
+
+ // There usually isn't a run loop at _exactly_ the end point of the animation. As soon as the first run loop
+ // occurs after the animation should have ended, complete the animation.
+ displayLink.simulateRunLoop(at: 1.1)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 1)
+
+ driver.animationInstanceDidCancel(behavior: .halt)
+ }
+
+ func testRelativeTimestampCalculation() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 1,
+ duration: 4,
+ repeatStyle: .none,
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ // At 2 seconds, the animation should have passed the delay (1 second) and be 1 second into the animation (which
+ // is 25% of the duration).
+ displayLink.simulateRunLoop(at: 2)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .inclusive, 0.25))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.25])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // At 4 seconds, the animation should be 3 seconds into the duration (75%).
+ displayLink.simulateRunLoop(at: 4)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.25, .exclusive, 0.75))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.75])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // At 5 seconds, the animation should be complete.
+ displayLink.simulateRunLoop(at: 5)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.75, .exclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 1)
+
+ driver.animationInstanceDidCancel(behavior: .halt)
+ }
+
+ func testLooping() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .repeating(count: 2, autoreversing: false),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ // The initial frame should be rendered immediately.
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .inclusive, 0))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // The first cycle should behave the same as a non-looping animation, except it doesn't complete at the end.
+ displayLink.simulateRunLoop(at: 0.5)
+ displayLink.simulateRunLoop(at: 0.75)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 2)
+
+ if instance.executedBlockSequences.count >= 2 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .exclusive, 0.5))
+ XCTAssert(instance.executedBlockSequences[1] == (0.5, .exclusive, 0.75))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.5, 0.75])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // When we loop around to the second cycle, the first cycle should be completed, then the entire animation
+ // should be run in reverse (to allow execution blocks to be undone), then the second cycle should be run up to
+ // its current point.
+ displayLink.simulateRunLoop(at: 1.25)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 3)
+
+ if instance.executedBlockSequences.count >= 3 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.75, .exclusive, 1))
+ XCTAssert(instance.executedBlockSequences[1] == (1, .inclusive, 0))
+ XCTAssert(instance.executedBlockSequences[2] == (0, .inclusive, 0.25))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.25])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // When we pass the end of the second cycle, the animation should complete.
+ displayLink.simulateRunLoop(at: 2.1)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.25, .exclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 1)
+
+ driver.animationInstanceDidCancel(behavior: .halt)
+ }
+
+ func testLoopingWithDelay() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 1,
+ duration: 1,
+ repeatStyle: .repeating(count: 2, autoreversing: false),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ // When the first run loop past the delay occurs, we should render from the beggining (inclusive) to the current
+ // relative timestamp.
+ displayLink.simulateRunLoop(at: 1.5)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .inclusive, 0.5))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.5])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ // The rest of the animation should behave identically to a looping animation without a delay.
+
+ driver.animationInstanceDidCancel(behavior: .halt)
+ }
+
+ func testLoopingWithAutoreversing() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .repeating(count: 8, autoreversing: true),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ // The initial frame should be rendered immediately.
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .inclusive, 0))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // The first cycle should behave the same as a non-looping animation, except it doesn't complete at the end.
+ displayLink.simulateRunLoop(at: 0.5)
+ displayLink.simulateRunLoop(at: 0.75)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 2)
+
+ if instance.executedBlockSequences.count >= 2 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .exclusive, 0.5))
+ XCTAssert(instance.executedBlockSequences[1] == (0.5, .exclusive, 0.75))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.5, 0.75])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // When we loop around to the second cycle, the first cycle should be completed, then the second cycle should be
+ // run up to its current point (in reverse).
+ displayLink.simulateRunLoop(at: 1.5)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 2)
+
+ if instance.executedBlockSequences.count >= 2 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.75, .exclusive, 1))
+ XCTAssert(instance.executedBlockSequences[1] == (1, .inclusive, 0.5))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.5])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // The second cycle should continue to execute in reverse.
+ displayLink.simulateRunLoop(at: 1.75)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 0.25))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.25])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // The third cycle should be back to forward execution
+ displayLink.simulateRunLoop(at: 2.5)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 2)
+
+ if instance.executedBlockSequences.count >= 2 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.25, .exclusive, 0))
+ XCTAssert(instance.executedBlockSequences[1] == (0, .inclusive, 0.5))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.5])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // If we somehow skip an entire cycle, execute the missing cycle.
+ displayLink.simulateRunLoop(at: 4.25)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 3)
+
+ if instance.executedBlockSequences.count >= 3 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 1))
+ XCTAssert(instance.executedBlockSequences[1] == (1, .inclusive, 0))
+ XCTAssert(instance.executedBlockSequences[2] == (0, .inclusive, 0.25))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.25])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ displayLink.simulateRunLoop(at: 5.5)
+ instance.clearGatheredData()
+
+ // Same thing going the other direction.
+ displayLink.simulateRunLoop(at: 7.25)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 3)
+
+ if instance.executedBlockSequences.count >= 3 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 0))
+ XCTAssert(instance.executedBlockSequences[1] == (0, .inclusive, 1))
+ XCTAssert(instance.executedBlockSequences[2] == (1, .inclusive, 0.75))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.75])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ instance.clearGatheredData()
+
+ // Once we have passed the end of the final cycle, the animation should complete.
+ displayLink.simulateRunLoop(at: 8.1)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 3 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.75, .exclusive, 0))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 1)
+
+ driver.animationInstanceDidCancel(behavior: .halt)
+ }
+
+ func testLoopingWithAutoreversingAndDelayAndLateFirstRunLoop() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 1,
+ duration: 1,
+ repeatStyle: .repeating(count: 2, autoreversing: true),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ // In the edge case where our first render pass occurs in a reverse cycle, we should execute the first (forward)
+ // cycle of the animation, then execute the second cycle up to the current relative timestamp.
+ displayLink.simulateRunLoop(at: 2.5)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 2)
+
+ if instance.executedBlockSequences.count >= 2 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .inclusive, 1))
+ XCTAssert(instance.executedBlockSequences[1] == (1, .inclusive, 0.5))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0.5])
+ XCTAssertEqual(instance.completeCount, 0)
+
+ driver.animationInstanceDidCancel(behavior: .halt)
+ }
+
+ func testCallsCompletion() {
+ let expectation = self.expectation(description: "calls completion")
+ let completion: (Bool) -> Void = { success in
+ XCTAssertTrue(success)
+ expectation.fulfill()
+ }
+
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .none,
+ completion: completion,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 1)
+
+ waitForExpectations(timeout: 1, handler: nil)
+ }
+
+ // MARK: - Tests - Cancellation
+
+ func testCancelRevert() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .none,
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 0.5)
+ instance.clearGatheredData()
+
+ // Reverting the animation should finish executing the (forward) cycle, then reverse back to the beginning.
+ driver.animationInstanceDidCancel(behavior: .revert)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 2)
+
+ if instance.executedBlockSequences.count >= 2 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 1))
+ XCTAssert(instance.executedBlockSequences[1] == (1, .inclusive, 0))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+ func testCancelRevertLooping() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .repeating(count: 3, autoreversing: false),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 0.5)
+ instance.clearGatheredData()
+
+ // Reverting the animation should finish executing the (forward) cycle, then reverse back to the beginning.
+ driver.animationInstanceDidCancel(behavior: .revert)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 2)
+
+ if instance.executedBlockSequences.count >= 2 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 1))
+ XCTAssert(instance.executedBlockSequences[1] == (1, .inclusive, 0))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+ func testCancelRevertLoopingDuringReverseCycle() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .repeating(count: 3, autoreversing: true),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 1.5)
+ instance.clearGatheredData()
+
+ // Reverting the animation should finish executing the (reverse) cycle.
+ driver.animationInstanceDidCancel(behavior: .revert)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 0))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+ func testCancelComplete() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .none,
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 0.5)
+ instance.clearGatheredData()
+
+ // Reverting the animation should finish executing the (forward) cycle.
+ driver.animationInstanceDidCancel(behavior: .complete)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+ func testCancelCompleteLoopingWithOddCount() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .repeating(count: 3, autoreversing: true),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 0.5)
+ instance.clearGatheredData()
+
+ // An odd loop count means the final cycle is forward. Reverting the animation should finish executing the
+ // (forward) cycle.
+ driver.animationInstanceDidCancel(behavior: .complete)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+ func testCancelCompleteLoopingWithOddCountDuringReverseCycle() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .repeating(count: 3, autoreversing: true),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 1.5)
+ instance.clearGatheredData()
+
+ // An odd loop count means the final cycle is forward. Reverting the animation should finish executing the
+ // (reverse) cycle, then run a full forward cycle.
+ driver.animationInstanceDidCancel(behavior: .complete)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 2)
+
+ if instance.executedBlockSequences.count >= 2 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 0))
+ XCTAssert(instance.executedBlockSequences[1] == (0, .inclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+ func testCancelCompleteLoopingWithOddCountBeforeFirstFrameRendered() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 1,
+ duration: 1,
+ repeatStyle: .repeating(count: 3, autoreversing: true),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ // An odd loop count means the final cycle is forward. If the animation is cancelled before it begins, we should
+ // execute a single forward cycle.
+ driver.animationInstanceDidCancel(behavior: .complete)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0, .inclusive, 1))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [1])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+ func testCancelCompleteLoopingWithEvenCount() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .repeating(count: 4, autoreversing: true),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 0.5)
+ instance.clearGatheredData()
+
+ // An even loop count means the final cycle is reversed. Reverting the animation should finish executing the
+ // (forward) cycle, then run a full reverse cycle.
+ driver.animationInstanceDidCancel(behavior: .complete)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 2)
+
+ if instance.executedBlockSequences.count >= 2 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 1))
+ XCTAssert(instance.executedBlockSequences[1] == (1, .inclusive, 0))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+ func testCancelCompleteLoopingWithEvenCountDuringReverseCycle() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 0,
+ duration: 1,
+ repeatStyle: .repeating(count: 4, autoreversing: true),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ displayLink.simulateRunLoop(at: 1.5)
+ instance.clearGatheredData()
+
+ // An even loop count means the final cycle is reversed. Reverting the animation should finish executing the
+ // (reverse) cycle.
+ driver.animationInstanceDidCancel(behavior: .complete)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 1)
+
+ if instance.executedBlockSequences.count >= 1 {
+ XCTAssert(instance.executedBlockSequences[0] == (0.5, .exclusive, 0))
+ }
+
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+ func testCancelCompleteLoopingWithEvenCountBeforeFirstFrameRendered() {
+ let displayLink = TestDisplayLink()
+
+ let driver = DisplayLinkDriver(
+ delay: 1,
+ duration: 1,
+ repeatStyle: .repeating(count: 4, autoreversing: true),
+ completion: nil,
+ displayLinkFactory: { _, _ in displayLink }
+ )
+
+ displayLink.driver = driver
+
+ let instance = TestAnimationInstance()
+ driver.animationInstance = instance
+ driver.animationInstanceDidInitialize()
+
+ driver.start(timeFactory: Factory.timeFactory)
+
+ // An even loop count means the final cycle is reversed. If the animation is cancelled before it begins, we
+ // don't need to execute any frames.
+ driver.animationInstanceDidCancel(behavior: .complete)
+
+ XCTAssertEqual(instance.executedBlockSequences.count, 0)
+ XCTAssertEqual(instance.renderedFrames, [0])
+ XCTAssertEqual(instance.completeCount, 0)
+ }
+
+}
+
+// MARK: -
+
+private final class TestAnimationInstance: DrivenAnimationInstance {
+
+ // MARK: - Public Properties
+
+ private(set) var executedBlockSequences: [(Double, AnimationInstance.Inclusivity, Double)] = []
+
+ private(set) var renderedFrames: [Double] = []
+
+ private(set) var completeCount: Int = 0
+
+ // MARK: - Public Methods
+
+ func clearGatheredData() {
+ executedBlockSequences = []
+ renderedFrames = []
+ completeCount = 0
+ }
+
+ // MARK: - DrivenAnimationInstance
+
+ func executeBlocks(from startingRelativeTimestamp: Double, _ fromInclusivity: AnimationInstance.Inclusivity, to endingRelativeTimestamp: Double) {
+ executedBlockSequences.append((startingRelativeTimestamp, fromInclusivity, endingRelativeTimestamp))
+ }
+
+ func renderFrame(at relativeTimestamp: Double) {
+ renderedFrames.append(relativeTimestamp)
+ }
+
+ func markAnimationAsComplete() {
+ completeCount += 1
+ }
+
+}
+
+// MARK: -
+
+private final class TestDisplayLink: DisplayLinkDriverDisplayLink {
+
+ // MARK: - DisplayLinkDriverDisplayLink
+
+ private(set) var timestamp: CFTimeInterval = 0
+
+ func add(to runloop: RunLoop, forMode mode: RunLoop.Mode) {
+ // No-op.
+ }
+
+ func invalidate() {
+ // No-op.
+ }
+
+ // MARK: - Private Properties
+
+ unowned var driver: DisplayLinkDriver!
+
+ // MARK: - Public Methods
+
+ func simulateRunLoop(at timeOffset: CFTimeInterval) {
+ timestamp = Factory.startTime + timeOffset
+
+ driver.renderCurrentFrame()
+ }
+
+}
+
+// MARK: -
+
+private enum Factory {
+ // An arbitrarily selected start time for the display link to begin, which must be greater than zero to
+ // differentiate between not being added to the run loop.
+ static let startTime: CFTimeInterval = 1000
+
+ static let timeFactory: () -> CFTimeInterval = { Factory.startTime }
+
+}
diff --git a/Example/Unit Tests/Info.plist b/Example/Unit Tests/Info.plist
new file mode 100644
index 0000000..ba72822
--- /dev/null
+++ b/Example/Unit Tests/Info.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ BNDL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1
+
+
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshotGIF@3x.gif b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshotGIF@3x.gif
new file mode 100644
index 0000000..a2c18bc
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshotGIF@3x.gif differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_end@3x.png b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_end@3x.png
new file mode 100644
index 0000000..11a8020
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_end@3x.png differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_middle@3x.png b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_middle@3x.png
new file mode 100644
index 0000000..b097316
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_middle@3x.png differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_start@3x.png b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_start@3x.png
new file mode 100644
index 0000000..9a767b4
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAnimationWithExecutionBlocksSnapshot_start@3x.png differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationSnapshotGIF@3x.gif b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationSnapshotGIF@3x.gif
new file mode 100644
index 0000000..00d70ef
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationSnapshotGIF@3x.gif differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationWithExecutionBlocksSnapshotGIF@3x.gif b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationWithExecutionBlocksSnapshotGIF@3x.gif
new file mode 100644
index 0000000..b789056
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testAutoreversingAnimationWithExecutionBlocksSnapshotGIF@3x.gif differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testLongAnimationSnapshotGIF@3x.gif b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testLongAnimationSnapshotGIF@3x.gif
new file mode 100644
index 0000000..9ee40af
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testLongAnimationSnapshotGIF@3x.gif differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotGIF@3x.gif b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotGIF@3x.gif
new file mode 100644
index 0000000..0965398
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotGIF@3x.gif differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotGIFAtHighFPS@3x.gif b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotGIFAtHighFPS@3x.gif
new file mode 100644
index 0000000..ac528b7
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshotGIFAtHighFPS@3x.gif differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_end@3x.png b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_end@3x.png
new file mode 100644
index 0000000..300bddb
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_end@3x.png differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_middle@3x.png b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_middle@3x.png
new file mode 100644
index 0000000..926827a
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_middle@3x.png differ
diff --git a/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_start@3x.png b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_start@3x.png
new file mode 100644
index 0000000..9a767b4
Binary files /dev/null and b/Example/Unit Tests/ReferenceImages/_64/Stagehand_UnitTests.AnimationSnapshotTests/testSimpleAnimationSnapshot_start@3x.png differ
diff --git a/Example/Unit Tests/TestDriver.swift b/Example/Unit Tests/TestDriver.swift
new file mode 100644
index 0000000..9dff9f8
--- /dev/null
+++ b/Example/Unit Tests/TestDriver.swift
@@ -0,0 +1,51 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+@testable import Stagehand
+
+final class TestDriver: Driver {
+
+ // MARK: - Private Properties
+
+ private var renderedRelativeTimestamp: Double?
+
+ // MARK: - Driver
+
+ weak var animationInstance: DrivenAnimationInstance!
+
+ func animationInstanceDidInitialize() {
+ // No-op.
+ }
+
+ func animationInstanceDidCancel(behavior: AnimationInstance.CancelationBehavior) {
+ // No-op.
+ }
+
+ // MARK: - Public Methods
+
+ func runForward(to relativeTimestamp: Double) {
+ if let lastRenderedTimestamp = renderedRelativeTimestamp {
+ animationInstance.executeBlocks(from: lastRenderedTimestamp, .exclusive, to: relativeTimestamp)
+ } else {
+ animationInstance.executeBlocks(from: 0, .inclusive, to: relativeTimestamp)
+ }
+
+ animationInstance.renderFrame(at: relativeTimestamp)
+
+ renderedRelativeTimestamp = relativeTimestamp
+ }
+
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..301ab80
--- /dev/null
+++ b/README.md
@@ -0,0 +1,85 @@
+# Stagehand
+
+[](https://travis-ci.org/CashApp/Stagehand)
+[](https://cocoapods.org/pods/Stagehand)
+[](https://cocoapods.org/pods/Stagehand)
+[](https://cocoapods.org/pods/Stagehand)
+
+Stagehand provides a modern, type-safe API for building animations on iOS. Stagehand is designed around a set of core ideas:
+
+* Composition of Structures
+* Separation of Construction and Execution
+* Compiler Safety
+* Testability
+
+## Installation
+
+Stagehand is available through [CocoaPods](https://cocoapods.org). To install it, simply add the following line to your Podfile:
+
+```ruby
+pod 'Stagehand', '~> 1.0'
+```
+
+## Getting Started with Stagehand
+
+An animation begins with the construction of an `Animation`. An `Animation` is generic over a type of element and acts as a definition of how that element should be animated.
+
+As an example, we can write an animation that highlights a view by fading its alpha to 0.8 and back:
+
+```swift
+var highlightAnimation = Animation()
+highlightAnimation.addKeyframe(for: \.alpha, at: 0, value: 1)
+highlightAnimation.addKeyframe(for: \.alpha, at: 0.5, value: 0.8)
+highlightAnimation.addKeyframe(for: \.alpha, at: 1, value: 1)
+```
+
+Let's say we've defined a view, which we'll call `BinaryView`, that has two subviews, `leftView` and `rightView`, and we want to highlight each of the subviews in sequence. We can define an animation for our `BinaryView` with two child animations:
+
+```swift
+var binaryAnimation = Animation()
+binaryAnimation.addChild(highlightAnimation, for: \.leftView, startingAt: 0, relativeDuration: 0.5)
+binaryAnimation.addChild(highlightAnimation, for: \.rightView, startingAt: 0.5, relativeDuration: 0.5)
+```
+
+Once we've set up our view and we're ready to execute our animation, we can call the `perform` method to start animating:
+
+```swift
+let view = BinaryView()
+// ...
+
+binaryAnimation.perform(on: view)
+```
+
+## Running the Demo App
+
+Stagehand ships with a demo app that shows examples of many of the features provided by Stagehand. To run the demo app, open the `Example` directory and run:
+
+```bash
+bundle install
+bundle exec pod install
+open Stagehand.xcworkspace
+```
+
+From here, you can run the demo app and see a variety of examples for how to use the framework. In that workspace, there is also a playground that includes documentation and tutorials for how each feature works.
+
+## Contributing
+
+We’re glad you’re interested in Stagehand, and we’d love to see where you take it. Please read our [contributing guidelines](CONTRIBUTING.md) prior to submitting a Pull Request.
+
+## License
+
+```
+Copyright 2020 Square, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+```
\ No newline at end of file
diff --git a/Stagehand.podspec b/Stagehand.podspec
new file mode 100644
index 0000000..d76a9b5
--- /dev/null
+++ b/Stagehand.podspec
@@ -0,0 +1,23 @@
+Pod::Spec.new do |s|
+ s.name = 'Stagehand'
+ s.version = '1.0'
+ s.summary = 'Modern, type-safe API for building animations on iOS'
+ s.homepage = 'https://github.com/CashApp/Stagehand'
+ s.license = { :type => 'Apache License, Version 2.0', :file => 'LICENSE' }
+ s.author = 'Square'
+ s.source = { :git => 'https://github.com/CashApp/Stagehand.git', :tag => s.version.to_s }
+
+ s.ios.deployment_target = '10.0'
+
+ s.swift_version = '5.0.1'
+
+ s.source_files = 'Stagehand/Classes/Core/**/*'
+
+ s.frameworks = 'CoreGraphics', 'UIKit'
+
+ # In order for StagehandTesting to publish correctly, we need to allow Stagehand to be accessible
+ # using `@testable import`. This allows StagehandTesting to build using a RELEASE config.
+ s.pod_target_xcconfig = {
+ 'ENABLE_TESTABILITY' => 'YES'
+ }
+end
diff --git a/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+Color.swift b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+Color.swift
new file mode 100644
index 0000000..24e8597
--- /dev/null
+++ b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+Color.swift
@@ -0,0 +1,178 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import CoreGraphics
+import UIKit
+
+extension CGColor: AnimatableProperty {
+
+ /// Interpolates between two `CGColor`s.
+ ///
+ /// If one or both of the colors cannot be represented in an RGBA color space, we will fall back to step-wise
+ /// interpolation where the initial value is used in the range `[0,0.5)` and the final value is used in the range
+ /// `[0.5,1]`.
+ public static func value(between initialValue: CGColor, and finalValue: CGColor, at progress: Double) -> Self {
+ guard
+ let initialComponents = RGBAComponents(cgColor: initialValue),
+ let finalComponents = RGBAComponents(cgColor: finalValue),
+ initialComponents.colorSpace == finalComponents.colorSpace
+ else {
+ // We failed to get the RGBA components from at least one of the colors, or at least failed to get the
+ // components converted into the same color space. Fall back to a non-animated behavior where the color
+ // changes half way through the animation. This is less than ideal, but will still end the animation in the
+ // correct place.
+ let stepWiseValue = ((progress < 0.5) ? initialValue : finalValue)
+ return valueOfSelf(from: stepWiseValue)
+ }
+
+ return self.init(
+ colorSpace: initialComponents.colorSpace,
+ components: [
+ CGFloat.value(between: initialComponents.red, and: finalComponents.red, at: progress),
+ CGFloat.value(between: initialComponents.green, and: finalComponents.green, at: progress),
+ CGFloat.value(between: initialComponents.blue, and: finalComponents.blue, at: progress),
+ CGFloat.value(between: initialComponents.alpha, and: finalComponents.alpha, at: progress),
+ ]
+ )!
+ }
+
+}
+
+extension CGColor: AnimatableOptionalProperty {
+
+ /// Interpolates between two optional `CGColor`s.
+ ///
+ /// A boundary value of `nil` is interpreted as a zero-alpha version of the opposite boundary value. If both
+ /// boundary values are `nil`, all values between are `nil` as well.
+ public static func optionalValue(between initialValue: CGColor?, and finalValue: CGColor?, at progress: Double) -> Self? {
+ switch (initialValue, finalValue) {
+ case (.none, .none):
+ return nil
+
+ case let (.some(initialColor), .some(finalColor)):
+ return self.value(between: initialColor, and: finalColor, at: progress)
+
+ case let (.none, .some(finalColor)):
+ if let finalComponents = CGColor.RGBAComponents(cgColor: finalColor) {
+ let initialColor = CGColor(
+ colorSpace: finalComponents.colorSpace,
+ components: [finalComponents.red, finalComponents.green, finalComponents.blue, 0]
+ )!
+
+ return self.value(between: initialColor, and: finalColor, at: progress)
+
+ } else {
+ return (progress < 0.5) ? nil : valueOfSelf(from: finalColor)
+ }
+
+ case let (.some(initialColor), .none):
+ if let initialComponents = CGColor.RGBAComponents(cgColor: initialColor) {
+ let finalColor = CGColor(
+ colorSpace: initialComponents.colorSpace,
+ components: [initialComponents.red, initialComponents.green, initialComponents.blue, 0]
+ )!
+
+ return self.value(between: initialColor, and: finalColor, at: progress)
+
+ } else {
+ return (progress < 0.5) ? valueOfSelf(from: initialColor) : nil
+ }
+ }
+ }
+
+}
+
+extension UIColor: AnimatableProperty {
+
+ public static func value(between initialValue: UIColor, and finalValue: UIColor, at progress: Double) -> Self {
+ return self.init(cgColor: CGColor.value(between: initialValue.cgColor, and: finalValue.cgColor, at: progress))
+ }
+
+}
+
+extension UIColor: AnimatableOptionalProperty {
+
+ public static func optionalValue(
+ between initialValue: UIColor?,
+ and finalValue: UIColor?,
+ at progress: Double
+ ) -> Self? {
+ return CGColor
+ .optionalValue(between: initialValue?.cgColor, and: finalValue?.cgColor, at: progress)
+ .map(self.init(cgColor:))
+ }
+
+}
+
+extension CGColor {
+
+ // MARK: - Private Static Methods
+
+ private static func valueOfSelf(from cgColor: CGColor) -> Self {
+ if let pattern = cgColor.pattern {
+ return self.init(
+ patternSpace: cgColor.colorSpace!,
+ pattern: pattern,
+ components: cgColor.components!
+ )!
+ } else {
+ return self.init(
+ colorSpace: cgColor.colorSpace!,
+ components: cgColor.components!
+ )!
+ }
+ }
+
+ // MARK: - Private Types
+
+ fileprivate struct RGBAComponents {
+
+ // MARK: - Public Properties
+
+ var red: CGFloat
+
+ var green: CGFloat
+
+ var blue: CGFloat
+
+ var alpha: CGFloat
+
+ var colorSpace: CGColorSpace
+
+ // MARK: - Life Cycle
+
+ init?(cgColor: CGColor) {
+ // Try to use the P3 color space, since this is the widest display color space supported by current devices,
+ // and fall back to device RGB if P3 is unavailable.
+ self.colorSpace = CGColorSpace(name: CGColorSpace.displayP3) ?? CGColorSpaceCreateDeviceRGB()
+
+ guard let rgbColor = cgColor.converted(to: colorSpace, intent: .defaultIntent, options: nil) else {
+ return nil
+ }
+
+ guard let components = rgbColor.components, components.count == 4 else {
+ return nil
+ }
+
+ self.red = components[0]
+ self.green = components[1]
+ self.blue = components[2]
+ self.alpha = components[3]
+ }
+
+ }
+
+}
diff --git a/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+CoreGraphics.swift b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+CoreGraphics.swift
new file mode 100644
index 0000000..1fb50bc
--- /dev/null
+++ b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+CoreGraphics.swift
@@ -0,0 +1,131 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import CoreGraphics
+
+extension CGFloat: AnimatableProperty {
+
+ public static func value(between initialValue: CGFloat, and finalValue: CGFloat, at progress: Double) -> CGFloat {
+ return initialValue + CGFloat(progress) * (finalValue - initialValue)
+ }
+
+}
+
+extension CGPoint: AnimatableProperty {
+
+ public static func value(between initialValue: CGPoint, and finalValue: CGPoint, at progress: Double) -> CGPoint {
+ return CGPoint(
+ x: CGFloat.value(between: initialValue.x, and: finalValue.x, at: progress),
+ y: CGFloat.value(between: initialValue.y, and: finalValue.y, at: progress)
+ )
+ }
+
+}
+
+extension CGSize: AnimatableProperty {
+
+ public static func value(between initialValue: CGSize, and finalValue: CGSize, at progress: Double) -> CGSize {
+ return CGSize(
+ width: CGFloat.value(between: initialValue.width, and: finalValue.width, at: progress),
+ height: CGFloat.value(between: initialValue.height, and: finalValue.height, at: progress)
+ )
+ }
+
+}
+
+extension CGRect: AnimatableProperty {
+
+ public static func value(between initialValue: CGRect, and finalValue: CGRect, at progress: Double) -> CGRect {
+ return CGRect(
+ origin: CGPoint.value(between: initialValue.origin, and: finalValue.origin, at: progress),
+ size: CGSize.value(between: initialValue.size, and: finalValue.size, at: progress)
+ )
+ }
+
+}
+
+extension CGAffineTransform: AnimatableProperty {
+
+ /// Interpolates between the `initialValue` and `finalValue`.
+ ///
+ /// This supports transforms that are composed of translations, scales, and rotations; where `M' = R * S * T * M`.
+ /// In order words, the matrix must be mutated in order of (1) translations, (2) scales, then (3) rotations. It does
+ /// not support transforms that have had a skew/distort applied.
+ public static func value(
+ between initialValue: CGAffineTransform,
+ and finalValue: CGAffineTransform,
+ at progress: Double
+ ) -> CGAffineTransform {
+ let initialRotation = initialValue.rotation
+
+ // Pick the shortest route to the between the transforms by adjusting the final angle by ±2π.
+ let calculatedFinalRotation = finalValue.rotation
+ let finalRotationCandidates = [
+ calculatedFinalRotation - 2 * .pi,
+ calculatedFinalRotation,
+ calculatedFinalRotation + 2 * .pi
+ ]
+ let finalRotation = finalRotationCandidates.min(by: { abs($0 - initialRotation) < abs($1 - initialRotation) })!
+
+ return CGAffineTransform.identity
+ .translatedBy(
+ x: CGFloat.value(between: initialValue.tx, and: finalValue.tx, at: progress),
+ y: CGFloat.value(between: initialValue.ty, and: finalValue.ty, at: progress)
+ )
+ .scaledBy(
+ x: CGFloat.value(between: initialValue.scaleX, and: finalValue.scaleX, at: progress),
+ y: CGFloat.value(between: initialValue.scaleY, and: finalValue.scaleY, at: progress)
+ )
+ .rotated(
+ by: CGFloat.value(between: initialRotation, and: finalRotation, at: progress)
+ )
+ }
+
+ // MARK: - Private Computed Properties
+
+ private var rotation: CGFloat {
+ return atan2(b, d)
+ }
+
+ private var scaleX: CGFloat {
+ let signProvider: CGFloat
+ switch (a.sign, rotation) {
+ case (.plus, (-.pi/2)...(.pi/2)):
+ signProvider = 1
+ case (.minus, (-.pi/2)...(.pi/2)), (.plus, _):
+ signProvider = -1
+ case (.minus, _):
+ signProvider = 1
+ }
+
+ return CGFloat(signOf: signProvider, magnitudeOf: sqrt(a * a + c * c))
+ }
+
+ private var scaleY: CGFloat {
+ let signProvider: CGFloat
+ switch (d.sign, rotation) {
+ case (.plus, (-.pi/2)...(.pi/2)):
+ signProvider = 1
+ case (.minus, (-.pi/2)...(.pi/2)), (.plus, _):
+ signProvider = -1
+ case (.minus, _):
+ signProvider = 1
+ }
+
+ return CGFloat(signOf: signProvider, magnitudeOf: sqrt(b * b + d * d))
+ }
+
+}
diff --git a/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+FloatingPoint.swift b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+FloatingPoint.swift
new file mode 100644
index 0000000..76b2cad
--- /dev/null
+++ b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+FloatingPoint.swift
@@ -0,0 +1,33 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+extension Float: AnimatableProperty {
+
+ public static func value(between initialValue: Float, and finalValue: Float, at progress: Double) -> Float {
+ return initialValue + Float(progress) * (finalValue - initialValue)
+ }
+
+}
+
+extension Double: AnimatableProperty {
+
+ public static func value(between initialValue: Double, and finalValue: Double, at progress: Double) -> Double {
+ return initialValue + progress * (finalValue - initialValue)
+ }
+
+}
diff --git a/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+Optional.swift b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+Optional.swift
new file mode 100644
index 0000000..360ef5b
--- /dev/null
+++ b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty+Optional.swift
@@ -0,0 +1,43 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+// Unfortunately, Swift doesn't support multiple conditional conformances for the same type, so we can't declare
+// separate conformances of `Optional` to `AnimatableProperty` for each optional type that can be animated. To work
+// around this, we can define the `AnimatableOptionalProperty` protocol and have `Optional` conform to
+// `AnimatableProperty` whenever its `Wrapped` type conforms to this protocol.
+
+/// Defines the interface of a type for which optional values of the type can be animated. More specifically,
+/// interpolates between two optional values (the `initialValue` and `finalValue`) at a given `progress`.
+public protocol AnimatableOptionalProperty {
+
+ /// Returns an interpolation between the `initialValue` and `finalValue` at the given `progress` in the range.
+ ///
+ /// - parameter initialValue: The initial value of the interpolation, i.e. the value when the `progress` is 0.
+ /// - parameter finalValue: The final value of the interpolation, i.e. the value when the `progress` is 1.
+ /// - parameter progress: The progress along the interpolation, in the range `[0,1]`.
+ static func optionalValue(between initialValue: Self?, and finalValue: Self?, at progress: Double) -> Self?
+
+}
+
+extension Optional: AnimatableProperty where Wrapped: AnimatableOptionalProperty {
+
+ public static func value(between initialValue: Optional, and finalValue: Optional, at progress: Double) -> Optional {
+ return Wrapped.optionalValue(between: initialValue, and: finalValue, at: progress)
+ }
+
+}
diff --git a/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty.swift b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty.swift
new file mode 100644
index 0000000..b9c6782
--- /dev/null
+++ b/Stagehand/Classes/Core/AnimatableProperty/AnimatableProperty.swift
@@ -0,0 +1,30 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+/// Defines the interface of a type for which the value can be animated. More specifically, interpolates between two
+/// values (the `initialValue` and `finalValue`) at a given `progress` in the range `[0,1]`.
+public protocol AnimatableProperty {
+
+ /// Returns an interpolation between the `initialValue` and `finalValue` at the given `progress` in the range.
+ ///
+ /// - parameter initialValue: The initial value of the interpolation, i.e. the value when the `progress` is 0.
+ /// - parameter finalValue: The final value of the interpolation, i.e. the value when the `progress` is 1.
+ /// - parameter progress: The progress along the interpolation, in the range `[0,1]`.
+ static func value(between initialValue: Self, and finalValue: Self, at progress: Double) -> Self
+
+}
diff --git a/Stagehand/Classes/Core/Animation/Animation+Optimization.swift b/Stagehand/Classes/Core/Animation/Animation+Optimization.swift
new file mode 100644
index 0000000..c2195c0
--- /dev/null
+++ b/Stagehand/Classes/Core/Animation/Animation+Optimization.swift
@@ -0,0 +1,135 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+extension Animation {
+
+ // MARK: - Internal Methods
+
+ func optimized() -> Animation {
+ var animation = self
+
+ animation.children = animation.children.map { child in
+ var child = child
+ child.animation = child.animation.optimized()
+ return child
+ }
+
+ animation.elevateUbiquitousBezierCurve()
+
+ animation.removeObsoleteKeyframes()
+
+ // Now that we've potentially removed keyframes from our children, we can simplify the animation by removing any
+ // empty children.
+ animation.removeEmptyChildren()
+
+ return animation
+ }
+
+ // MARK: - Private Methods
+
+ /// Optimizes for the case where an animation is used as container for a set of child animations that all have the
+ /// same cubic Bézier curve. Bézier curves are relatively expensive to calculate, so avoiding calculating values for
+ /// the same curve many times each frame can help save a fair amount of computation.
+ private mutating func elevateUbiquitousBezierCurve() {
+ // In order to elevate a curve from children, the parent animation needs to strictly be a container - i.e. an
+ // animation that doesn't define any content (keyframes, execution blocks, etc.) itself, only acts as a
+ // container for child animations.
+ guard
+ keyframeSeriesByProperty.isEmpty
+ && collectionKeyframeSeriesByProperty.isEmpty
+ && assignments.isEmpty
+ && executionBlocks.isEmpty
+ && perFrameExecutionBlocks.isEmpty
+ && !children.isEmpty
+ else {
+ return
+ }
+
+ // The curve can only be elevated to replace the parent curve if the parent curve is linear. In the future, this
+ // requirement could be removed by creating a wrapper animation curve that stacks the two curves.
+ guard curve is LinearAnimationCurve else {
+ return
+ }
+
+ // The curve can only be elevated if all children are animated over the entire parent animation. In the future,
+ // this requirement could be relaxed to check that all children are animated over the same interval, even if it
+ // is not 0 to 1, by creating a wrapper animation curve that applies the wrapped curve over that interval and
+ // otherwise returns an out of bounds (i.e. < 0 or > 1) adjusted progress outside that interval.
+ guard children.allSatisfy({ $0.relativeStartTimestamp == 0 && $0.relativeDuration == 1 }) else {
+ return
+ }
+
+ // The curve can only be elevated if all children use the same cubic Bézier animation curve.
+ guard
+ let curve = children.first?.animation.curve as? CubicBezierAnimationCurve,
+ children.allSatisfy({ ($0.animation.curve as? CubicBezierAnimationCurve) == curve })
+ else {
+ return
+ }
+
+ // Elevate the curve by replacing the (linear) curve of the parent with the Bézier curve, and the curve of each
+ // child with a linear curve.
+ self.curve = curve
+ self.children = children.map { child in
+ var child = child
+ child.animation.curve = LinearAnimationCurve()
+ return child
+ }
+ }
+
+ /// Removes keyframes in children that would be overridden by their parent. Since keyframes in parents override any
+ /// keyframes for the same property in their children, those keyframes are obsolete.
+ private mutating func removeObsoleteKeyframes() {
+ // If we don't have any children, there's nothing to do.
+ guard !children.isEmpty else {
+ return
+ }
+
+ let propertiesInParent = Set(keyframeSeriesByProperty.keys)
+
+ for (index, child) in children.enumerated() {
+ let obsoleteProperties = child.animation.propertiesWithKeyframes.intersection(propertiesInParent)
+
+ for property in obsoleteProperties {
+ children[index].animation.removeKeyframes(for: property)
+ }
+ }
+ }
+
+ private mutating func removeKeyframes(for property: PartialKeyPath) {
+ keyframeSeriesByProperty.removeValue(forKey: property)
+
+ children = children.map { child in
+ var child = child
+ child.animation.removeKeyframes(for: property)
+ return child
+ }
+ }
+
+ private mutating func removeEmptyChildren() {
+ self.children = children.filter { child in
+ return !child.animation.keyframeSeriesByProperty.isEmpty
+ || !child.animation.collectionKeyframeSeriesByProperty.isEmpty
+ || !child.animation.assignments.isEmpty
+ || !child.animation.executionBlocks.isEmpty
+ || !child.animation.perFrameExecutionBlocks.isEmpty
+ || !child.animation.children.isEmpty
+ }
+ }
+
+}
diff --git a/Stagehand/Classes/Core/Animation/Animation.swift b/Stagehand/Classes/Core/Animation/Animation.swift
new file mode 100644
index 0000000..c414af6
--- /dev/null
+++ b/Stagehand/Classes/Core/Animation/Animation.swift
@@ -0,0 +1,901 @@
+//
+// Copyright 2019 Square Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import UIKit
+
+/// An `Animation` is the core data structure that defines an animation that can be applied to any elements of a
+/// specific type (`ElementType`). Animations consist of a series of keyframes, property assignments, and execution
+/// blocks.
+///
+/// Most simple animations can be made using keyframes. Keyframes defines the value of a given property at a specific
+/// point in the animation. During the animation, the value of each property is interpolated between the values in the
+/// animation's keyframes.
+///
+/// Property assignments also specify the value of a given property at a specific point in the animation, but do not
+/// interpolate the value between these points. Property assignments can be used for values that cannot be interpolated,
+/// or should change in discrete assignments, rather than continuosly over the course of the animation.
+///
+/// Execution blocks enable code to be executed at a specific point in the animation. Execution blocks are similar to
+/// property assignments in that they enable discrete changes, except in a slightly more free-form manner. For even less
+/// structured changes, per-frame execution blocks can be added to be executed every time the animation renders a frame.
+///
+/// Animations are composable. Complex animations can be composed of smaller logical pieces by constructing a hierarchy
+/// of child animations.
+public struct Animation {
+
+ // MARK: - Public Types
+
+ public struct FrameContext {
+
+ /// The element being animated.
+ public let element: ElementType
+
+ /// Value in the range [0, 1] representing the uncurved progress of the animation.
+ public let uncurvedProgress: Double
+
+ /// Value representing the progress into the animation, adjusted based on the animation's curve.
+ public let progress: Double
+
+ }
+
+ public typealias PerFrameExecutionBlock = (FrameContext) -> Void
+
+ // MARK: - Life Cycle
+
+ public init() { }
+
+ // MARK: - Public Properties
+
+ /// The duration of the animation.
+ ///
+ /// More specifically, this is the duration of one cycle of the animation. An animation that repeats will take a
+ /// total duration equal to the duration of one cycle (the animation's `duration`) multiplied by the number of
+ /// cycles (as specified by the animation's `repeatStyle`).
+ ///
+ /// When animations are composed, the duration is controlled by the top-most parent animation. The `duration` of any
+ /// child animations are ignored.
+ public var duration: TimeInterval = 1
+
+ /// The way in which the animation should repeat.
+ ///
+ /// When animations are composed, the repeat style is controlled by the top-most parent animation. The `repeatStyle`
+ /// of any child animations are ignored.
+ public var repeatStyle: AnimationRepeatStyle = .none
+
+ /// The curve applied to the animation.
+ ///
+ /// Curves in child animations are applied on top of the curve(s) already applied by their parent(s). This allows
+ /// each child animation to have a different animation curve.
+ public var curve: AnimationCurve = LinearAnimationCurve()
+
+ // MARK: - Internal Computed Properties
+
+ internal var propertiesWithKeyframes: Set> {
+ var properties = Set(keyframeSeriesByProperty.keys)
+
+ for child in children {
+ properties.formUnion(child.animation.propertiesWithKeyframes)
+ }
+
+ return properties
+ }
+
+ internal var keyframeRelativeTimestamps: [Double] {
+ var keyframeRelativeTimestamps = Set(keyframeSeriesByProperty.flatMap { $0.value.keyframeRelativeTimestamps })
+
+ for child in children {
+ let childKeyframeRelativeTimestamps = child.animation.keyframeRelativeTimestamps
+ let adjustedRelativeTimestamps = childKeyframeRelativeTimestamps.map { childRelativeTimestamp in
+ return child.relativeStartTimestamp + childRelativeTimestamp * child.relativeDuration
+ }
+ keyframeRelativeTimestamps.formUnion(Set(adjustedRelativeTimestamps))
+ }
+
+ return keyframeRelativeTimestamps.sorted()
+ }
+
+ // MARK: - Internal Properties
+
+ internal var keyframeSeriesByProperty: [PartialKeyPath: AnyKeyframeSeries] = [:]
+
+ internal private(set) var collectionKeyframeSeriesByProperty: [CollectionKeyframeSeriesKey: AnyCollectionKeyframeSeries] = [:]
+
+ internal private(set) var assignments: [Assignment] = []
+
+ internal private(set) var executionBlocks: [ExecutionBlock] = []
+
+ internal private(set) var perFrameExecutionBlocks: [PerFrameExecutionBlock] = []
+
+ internal var children: [ChildAnimation] = []
+
+ // MARK: - Public Methods - Construction
+
+ /// Add a keyframe for the given `property` with a fixed value.
+ ///
+ /// - parameter property: The key path for the property to be animated.
+ /// - parameter relativeTimestamp: The relative timestamp at which this should be the value of the property. Must
+ /// be in the range [0,1], where 0 is the beginning of the animation and 1 is the end.
+ /// - parameter value: The value of the property at this keyframe.
+ public mutating func addKeyframe(
+ for property: WritableKeyPath,
+ at relativeTimestamp: Double,
+ value: PropertyType
+ ) {
+ addKeyframe(for: property, at: relativeTimestamp, relativeValue: { _ in value })
+ }
+
+ /// Add a keyframe for the given `property` with a fixed value.
+ ///
+ /// - parameter property: The key path for the property to be animated.
+ /// - parameter relativeTimestamp: The relative timestamp at which this should be the value of the property. Must
+ /// be in the range [0,1], where 0 is the beginning of the animation and 1 is the end.
+ /// - parameter value: The value of the property at this keyframe.
+ public mutating func addKeyframe(
+ for property: WritableKeyPath,
+ at relativeTimestamp: Double,
+ value: PropertyType
+ ) {
+ // This method shouldn't be necessary to define, since the property type is really `Optional`. Unfortunately,
+ // Swift sometimes has trouble resolving inferred key paths (i.e. key paths that don't specify the class name)
+ // for optional property types with a non-optional value. This makes inferred key paths work in this situation,
+ // since this method will be preferred.
+
+ addKeyframe(for: property, at: relativeTimestamp, relativeValue: { _ in value })
+ }
+
+ /// Add a keyframe for the given `property` with a value relative to the property's value at the beginning of the
+ /// animation.
+ ///
+ /// - parameter property: The key path for the property to be animated.
+ /// - parameter relativeTimestamp: The relative timestamp at which this should be the value of the property. Must
+ /// be in the range [0,1], where 0 is the beginning of the animation and 1 is the end.
+ /// - parameter relativeValue: The value of the property at this keyframe, determined from the `initialValue` of the
+ /// property when the animation begins.
+ public mutating func addKeyframe(
+ for property: WritableKeyPath,
+ at relativeTimestamp: Double,
+ relativeValue: @escaping (_ initialValue: PropertyType) -> PropertyType
+ ) {
+ if var keyframeSeries = keyframeSeriesByProperty[property] as? KeyframeSeries {
+ keyframeSeries.valuesByRelativeTimestamp[relativeTimestamp] = relativeValue
+ keyframeSeriesByProperty[property] = keyframeSeries
+
+ } else {
+ let keyframeSeries = KeyframeSeries(
+ property: property,
+ valuesByRelativeTimestamp: [
+ relativeTimestamp: relativeValue
+ ]
+ )
+ keyframeSeriesByProperty[property] = keyframeSeries
+ }
+ }
+
+ /// Add a keyframe for the given `property` of each element in the given `collection` with a fixed value.
+ ///
+ /// - Note: Collection keyframes are still a work in progress, and so are currently `internal`. Once they have been
+ /// fully implemented and documented, this method will be changed to be `public`.
+ ///
+ /// - parameter property: The key path for the property to be animated.
+ /// - parameter collection: The key path for the collection containing the elements to be animated.
+ /// - parameter relativeTimestamp: The relative timestamp at which this should be the value of the property. Must
+ /// be in the range [0,1], where 0 is the beginning of the animation and 1 is the end.
+ /// - parameter value: The value of the property at this keyframe.
+ internal mutating func addKeyframe(
+ for property: WritableKeyPath,
+ ofElementsIn collection: KeyPath,
+ at relativeTimestamp: Double,
+ value: PropertyType
+ ) {
+ addKeyframe(
+ for: property,
+ ofElementsIn: collection,
+ at: { _, _ in relativeTimestamp },
+ value: value
+ )
+ }
+
+ /// Add a keyframe for the given `property` of each element in the given `collection` with a fixed value.
+ ///
+ /// - Note: Collection keyframes are still a work in progress, and so are currently `internal`. Once they have been
+ /// fully implemented and documented, this method will be changed to be `public`.
+ ///
+ /// - parameter property: The key path for the property to be animated.
+ /// - parameter collection: The key path for the collection containing the elements to be animated.
+ /// - parameter relativeTimestamp: A block to calculate the relative timestamp at which this should be the value of
+ /// the property, based on the element's position in the collection. The return value of this block must be in the
+ /// range [0,1], where 0 is the beginning of the animation and 1 is the end.
+ /// - parameter value: The value of the property at this keyframe.
+ internal mutating func addKeyframe(
+ for property: WritableKeyPath,
+ ofElementsIn collection: KeyPath,
+ at relativeTimestamp: @escaping (_ index: Int, _ count: Int) -> Double,
+ value: PropertyType
+ ) {
+ let key = CollectionKeyframeSeriesKey(collection: collection, property: property)
+
+ let keyframe = CollectionKeyframeSeries.Keyframe(
+ relativeTimestamp: relativeTimestamp,
+ value: value
+ )
+
+ if var keyframeSeries = collectionKeyframeSeriesByProperty[key] as? CollectionKeyframeSeries {
+ keyframeSeries.keyframes.append(keyframe)
+ collectionKeyframeSeriesByProperty[key] = keyframeSeries
+
+ } else {
+ let keyframeSeries = CollectionKeyframeSeries(
+ collection: collection,
+ property: property,
+ keyframes: [keyframe]
+ )
+ collectionKeyframeSeriesByProperty[key] = keyframeSeries
+ }
+ }
+
+ /// Add an assignment for the given `property` at the `relativeTimestamp`.
+ ///
+ /// When the animation is run in reverse, the property will be returned to its value prior to the assignment.
+ ///
+ /// - parameter property: The key path for the property to be assigned.
+ /// - parameter relativeTimestamp: The relative timestamp at which this should be the value of the property. Must
+ /// be in the range [0,1], where 0 is the beginning of the animation and 1 is the end.
+ /// - parameter value: The value to assign to the property.
+ public mutating func addAssignment(
+ for property: WritableKeyPath,
+ at relativeTimestamp: Double,
+ value: PropertyType
+ ) {
+ assignments.append(.init(
+ relativeTimestamp: relativeTimestamp,
+ assignBlock: { element in
+ var element = element
+ element[keyPath: property] = value
+ },
+ generateReverseAssignBlock: { element in
+ let originalValue = element[keyPath: property]
+ return { element in
+ var element = element
+ element[keyPath: property] = originalValue
+ }
+ }
+ ))
+ }
+
+ /// Add an execution block at the given `relativeTimestamp`.
+ ///
+ /// This method takes two closures to execute at the given timestamp, one to be executed when the animation is
+ /// run in the forward direction and another to be executed when the animation is run in reverse.
+ ///
+ /// If an animation autoreverses, an execution block at the boundary of a cycle (i.e. having a `relativeTimestamp`
+ /// of either `0` or `1`) will be executed once in the direction of that cycle, then again in the direction of the
+ /// next cycle, unless it is the final cycle.
+ ///
+ /// If an animation loops, but does not autoreverse, between cycles each execution block will be executed in reverse
+ /// order. This allows execution blocks to be treated as discrete units where the `reverseBlock` is the opposite of
+ /// the `forwardBlock`, in effect reverting the changes made by the `forwardBlock`. The default value of
+ /// `reverseBlock` is a no-op closure, as a convenience for defining execution blocks for animations that are only
+ /// intended to run in the forward direction, or for which the action taken in the `forwardBlock` does not need to
+ /// be reverted (e.g. playing a sound or triggering a haptic).
+ ///
+ /// When an animation is cancelled, the execution blocks will be executed such that each cycle will be completed in
+ /// full. This allows execution blocks to depend on the effects of prior execution blocks. If you have an execution
+ /// block that should _not_ execute when cancelling (e.g. if it has side effects outside the animation), you can
+ /// check the `status` of the animation instance in the block.
+ ///
+ /// - parameter forwardBlock: The closure to execute when the animation is run in the forward direction.
+ /// - parameter reverseBlock: The closure to execute when the animation is run in the reverse direction.
+ /// - parameter relativeTimestamp: The relative timestamp at which this should be the value of the property. Must
+ /// be in the range [0,1], where 0 is the beginning of the animation and 1 is the end.
+ public mutating func addExecution(
+ onForward forwardBlock: @escaping (ElementType) -> Void,
+ onReverse reverseBlock: @escaping (ElementType) -> Void = { _ in },
+ at relativeTimestamp: Double
+ ) {
+ executionBlocks.append(.init(
+ relativeTimestamp: relativeTimestamp,
+ forwardBlock: forwardBlock,
+ reverseBlock: reverseBlock
+ ))
+ }
+
+ /// Add an execution block that will be called during each frame of the animation.
+ ///
+ /// The per-frame execution blocks will executed after the keyframes, property assignments, and other execution
+ /// blocks for the given frame have been applied.
+ ///
+ /// - parameter block: The block to call during each frame of the animation.
+ public mutating func addPerFrameExecution(
+ _ block: @escaping PerFrameExecutionBlock
+ ) {
+ perFrameExecutionBlocks.append(block)
+ }
+
+ /// Add a child animation.
+ ///
+ /// The `childAnimation`'s `duration` and `repeatStyle` will be ignored.
+ ///
+ /// Keyframes in a child animation for the same property as keyframes in the parent will be overridden by the values
+ /// of the keyframes in the parent.
+ ///
+ /// - parameter childAnimation: The child animation to be performed on the `subelement`.
+ /// - parameter subelement: The key path for the subelement on which the child animation should be performed.
+ /// - parameter relativeStartTimestamp: The relative timestamp at which the animation should begin. Must be in the
+ /// range [0,1), where 0 is the beginning of the animation and 1 is the end.
+ /// - parameter relativeDuration: The relative duration over which the child animation should be performed. Must be
+ /// in the range (0,(1 - relativeStartTimestamp)], where 0 is the beginning of the animation and 1 is the end.
+ public mutating func addChild(
+ _ childAnimation: Animation,
+ for subelement: WritableKeyPath,
+ startingAt relativeStartTimestamp: Double,
+ relativeDuration: Double
+ ) {
+ var child = ChildAnimation(
+ animation: .init(),
+ relativeStartTimestamp: relativeStartTimestamp,
+ relativeDuration: relativeDuration
+ )
+
+ child.animation.curve = childAnimation.curve
+
+ // Map the child's keyframes into the child animation.
+ for (_, childKeyframeSeries) in childAnimation.keyframeSeriesByProperty {
+ let (property, keyframeSeries) = childKeyframeSeries.mapForParentElement(subelement)
+
+ child.animation.keyframeSeriesByProperty[property] = keyframeSeries
+ }
+
+ // Map the child's collection keyframes into the child animation.
+ for (_, childKeyframeSeries) in childAnimation.collectionKeyframeSeriesByProperty {
+ let (key, keyframeSeries) = childKeyframeSeries.mapForParentElement(
+ subelement,
+ relativeStartTimestamp: relativeStartTimestamp,
+ relativeDuration: relativeDuration
+ )
+
+ child.animation.collectionKeyframeSeriesByProperty[key] = keyframeSeries
+ }
+
+ // Collapse the property assignments from the child into the parent.
+ assignments.append(
+ contentsOf: childAnimation.assignments.map { childAssignment in
+ // Adjust the relative timestamp for the child's animation curve.
+ let relativeTimestamp = relativeStartTimestamp + (childAssignment.relativeTimestamp / relativeDuration)
+ let adjustedRelativeTimestamp = childAnimation.curve.adjustedProgress(for: relativeTimestamp)
+
+ return Assignment(
+ relativeTimestamp: adjustedRelativeTimestamp,
+ assignBlock: { element in
+ childAssignment.assignBlock(element[keyPath: subelement])
+ },
+ generateReverseAssignBlock: { element -> ((ElementType) -> Void) in
+ let subelementAssignBlock = childAssignment.generateReverseAssignBlock(element[keyPath: subelement])
+ return { element in
+ subelementAssignBlock(element[keyPath: subelement])
+ }
+ }
+ )
+ }
+ )
+
+ // Collapse the execution blocks from the child into the parent.
+ executionBlocks.append(
+ contentsOf: childAnimation.executionBlocks.map { childExecutionBlock in
+ // Adjust the relative timestamp for the child's animation curve.
+ let relativeTimestamp = relativeStartTimestamp + (childExecutionBlock.relativeTimestamp / relativeDuration)
+ let adjustedRelativeTimestamp = childAnimation.curve.adjustedProgress(for: relativeTimestamp)
+
+ return ExecutionBlock(
+ relativeTimestamp: adjustedRelativeTimestamp,
+ forwardBlock: { element in
+ childExecutionBlock.forwardBlock(element[keyPath: subelement])
+ },
+ reverseBlock: { element in
+ childExecutionBlock.reverseBlock(element[keyPath: subelement])
+ }
+ )
+ }
+ )
+
+ // Collapse per-frame execution blocks from the child into the parent.
+ perFrameExecutionBlocks.append(
+ contentsOf: childAnimation.perFrameExecutionBlocks.map { childExecutionBlock in
+ return { context in
+ guard context.uncurvedProgress >= relativeStartTimestamp else {
+ // The child animation hasn't started yet.
+ return
+ }
+
+ guard context.uncurvedProgress <= (relativeStartTimestamp + relativeDuration) else {
+ // The child animation already ended.
+ return
+ }
+
+ // The uncurved progress of the child animation is based on the curved progress of the parent.
+ let uncurvedProgress = context.progress
+
+ childExecutionBlock(
+ .init(
+ element: context.element[keyPath: subelement],
+ uncurvedProgress: uncurvedProgress,
+ progress: childAnimation.curve.adjustedProgress(for: uncurvedProgress)
+ )
+ )
+ }
+ }
+ )
+
+ // Integrate the grandchildren.
+ for grandchild in childAnimation.children {
+ child.animation.addChild(
+ grandchild.animation,
+ for: subelement,
+ startingAt: grandchild.relativeStartTimestamp,
+ relativeDuration: grandchild.relativeDuration
+ )
+ }
+
+ children.append(child)
+ }
+
+ // MARK: - Public Methods - Execution
+
+ /// Perform the animation on the given `element`.
+ ///
+ /// - parameter element: The element to be animated.
+ /// - parameter delay: The time interval to wait before performing the animation.
+ /// - parameter completion: The completion block to call when the animation has concluded, with a parameter
+ /// indicated whether the animation completed (as opposed to being cancelled).
+ @discardableResult
+ public func perform(
+ on element: ElementType,
+ delay: TimeInterval = 0,
+ completion: ((_ finished: Bool) -> Void)? = nil
+ ) -> AnimationInstance {
+ let driver = DisplayLinkDriver(
+ delay: delay,
+ duration: duration,
+ repeatStyle: repeatStyle,
+ completion: completion
+ )
+
+ let instance = AnimationInstance(
+ animation: self,
+ element: element,
+ driver: driver
+ )
+
+ driver.start()
+
+ return instance
+ }
+
+ // MARK: - Internal Methods
+
+ /// Applies the animatable properties (those defined by keyframes, including collection keyframes) to the `element`
+ /// at the given `relativeTimestamp`.
+ ///
+ /// - parameter element: The element being animated.
+ /// - parameter relativeTimestamp: The raw (non-curved) timestamp at which to apply the values.
+ /// - parameter initialValues: A dictionary mapping the property animated by each keyframe series to the value of
+ /// that property when the animation began.
+ internal func apply(
+ to element: inout ElementType,
+ at relativeTimestamp: Double,
+ initialValues: [PartialKeyPath: Any]
+ ) {
+ let adjustedRelativeTimestamp = curve.adjustedProgress(for: relativeTimestamp)
+
+ for child in children {
+ // Allow for the child to be rendered _slightly_ outside its applied timestamp range to account for rounding
+ // error when applying a timestamp corresponding to a keyframe.
+ let ε = 0.0000000001
+
+ guard adjustedRelativeTimestamp >= child.relativeStartTimestamp - ε else {
+ continue
+ }
+
+ guard adjustedRelativeTimestamp <= (child.relativeStartTimestamp + child.relativeDuration) + ε else {
+ continue
+ }
+
+ child.animation.apply(
+ to: &element,
+ at: (adjustedRelativeTimestamp - child.relativeStartTimestamp) / child.relativeDuration,
+ initialValues: initialValues
+ )
+ }
+
+ var element = element as AnyObject
+
+ for series in self.keyframeSeriesByProperty {
+ series.value.applyToElement(
+ &element,
+ at: adjustedRelativeTimestamp,
+ initialValue: initialValues[series.key]!
+ )
+ }
+
+ for collectionSeries in self.collectionKeyframeSeriesByProperty {
+ collectionSeries.value.applyToElement(
+ &element,
+ at: adjustedRelativeTimestamp
+ )
+ }
+ }
+
+ /// Applies the first value of each animatable property (those defined by keyframes, _not_ including collection
+ /// keyframes) to the `element`.
+ ///
+ /// - parameter element: The element being animated.
+ /// - parameter initialValues: A dictionary mapping the property animated by each keyframe series to the value of
+ /// that property when the animation began.
+ internal func applyInitialKeyframes(
+ to element: inout ElementType,
+ initialValues: [PartialKeyPath: Any]
+ ) {
+ var element = element as AnyObject
+
+ for property in propertiesWithKeyframes {
+ let keyframeSeries = self.keyframeSeries(for: property)!.0
+ keyframeSeries.applyToElement(&element, at: 0, initialValue: initialValues[property]!)
+ }
+ }
+
+ // MARK: - Private Methods
+
+ private func keyframeSeries(for property: PartialKeyPath) -> (AnyKeyframeSeries, startingAt: Double)? {
+ if let keyframeSeries = keyframeSeriesByProperty[property] {
+ return (keyframeSeries, startingAt: 0)
+ }
+
+ var earliestKeyframeSeries: (AnyKeyframeSeries, Double)?
+ for child in children.sorted(by: { $0.relativeStartTimestamp < $1.relativeStartTimestamp }) {
+ if let candidateKeyframeSeries = child.animation.keyframeSeries(for: property) {
+ let adjustedStartTimestamp = child.relativeStartTimestamp + candidateKeyframeSeries.startingAt * child.relativeDuration
+
+ if earliestKeyframeSeries == nil {
+ earliestKeyframeSeries = candidateKeyframeSeries
+ } else if let existingKeyframeSeries = earliestKeyframeSeries, adjustedStartTimestamp <= existingKeyframeSeries.1 {
+ earliestKeyframeSeries = candidateKeyframeSeries
+ }
+ }
+ }
+
+ return earliestKeyframeSeries
+ }
+
+}
+
+// MARK: -
+
+public enum AnimationRepeatStyle {
+
+ /// Animation will execute `count` times.
+ ///
+ /// - `count`: The number of times the animation will be executed. A count of `0` represents an animation that
+ /// repeats indefinitely (until canceled). A count of `1` will run the animation a single time from start to end.
+ /// - `autoreversing`: Whether or not the animation should alternative direction on each execution. The first
+ /// execution will always run in the forwards direction, optionally alternating begining on the second run.
+ case repeating(count: UInt, autoreversing: Bool)
+
+ /// Animation will execute once.
+ public static let none: AnimationRepeatStyle = .repeating(count: 1, autoreversing: false)
+
+ /// Animation will execute indefinitely (until canceled).
+ /// - parameter autoreversing: Whether or not the animation should alternative direction on each cycle. The first
+ /// cycle will always run in the forwards direction, optionally alternating begining on the second cycle.
+ public static func infinitelyRepeating(autoreversing: Bool) -> AnimationRepeatStyle {
+ return .repeating(count: 0, autoreversing: autoreversing)
+ }
+
+}
+
+// MARK: -
+
+extension Animation {
+
+ private struct KeyframeSeries: AnyKeyframeSeries {
+
+ // MARK: - Public Properties
+
+ var property: WritableKeyPath
+
+ var valuesByRelativeTimestamp: [Double: (PropertyType) -> PropertyType]
+
+ // MARK: - Public Methods
+
+ func apply(to element: inout ElementType, at relativeTimestamp: Double, initialValue: PropertyType) {
+ if let value = valuesByRelativeTimestamp[relativeTimestamp] {
+ element[keyPath: property] = value(initialValue)
+ } else {
+ let values = valuesByRelativeTimestamp.sorted { $0.key < $1.key }
+
+ guard let previousIndex = values.lastIndex(where: { $0.key < relativeTimestamp }) else {
+ element[keyPath: property] = values.first!.value(initialValue)
+ return
+ }
+
+ let (previousTimestamp, previousValue) = values[previousIndex]
+
+ let nextIndex = values.index(after: previousIndex)
+ guard nextIndex != values.endIndex else {
+ element[keyPath: property] = previousValue(initialValue)
+ return
+ }
+
+ let (nextTimestamp, nextValue) = values[nextIndex]
+
+ element[keyPath: property] = PropertyType.value(
+ between: previousValue(initialValue),
+ and: nextValue(initialValue),
+ at: ((relativeTimestamp - previousTimestamp) / (nextTimestamp - previousTimestamp))
+ )
+ }
+ }
+
+ func mapForParent(
+ _ subelementPath: WritableKeyPath
+ ) -> (PartialKeyPath, Animation.KeyframeSeries) {
+ let mappedProperty = subelementPath.appending(path: property)
+ return (mappedProperty, .init(
+ property: mappedProperty,
+ valuesByRelativeTimestamp: valuesByRelativeTimestamp
+ ))
+ }
+
+ // MARK: - AnyKeyframeSeries
+
+ var propertyPath: AnyKeyPath {
+ return property
+ }
+
+ var keyframeRelativeTimestamps: [Double] {
+ return valuesByRelativeTimestamp.keys.sorted()
+ }
+
+ func applyToElement(_ element: inout AnyObject, at relativeTimestamp: Double, initialValue: Any) {
+ var element = element as! ElementType
+ apply(to: &element, at: relativeTimestamp, initialValue: initialValue as! PropertyType)
+ }
+
+ func mapForParentElement(
+ _ subelementPath: PartialKeyPath
+ ) -> (PartialKeyPath, AnyKeyframeSeries) {
+ let (keyPath, keyframeSeries) = mapForParent(
+ subelementPath as! WritableKeyPath
+ )
+ return (keyPath, keyframeSeries)
+ }
+
+ }
+
+}
+
+internal protocol AnyKeyframeSeries {
+
+ var propertyPath: AnyKeyPath { get }
+
+ var keyframeRelativeTimestamps: [Double] { get }
+
+ func applyToElement(_ element: inout AnyObject, at relativeTimestamp: Double, initialValue: Any)
+
+ func mapForParentElement(
+ _ subelementPath: PartialKeyPath
+ ) -> (PartialKeyPath, AnyKeyframeSeries)
+
+}
+
+// MARK: -
+
+extension Animation {
+
+ private struct CollectionKeyframeSeries: AnyCollectionKeyframeSeries {
+
+ // MARK: - Public Types
+
+ struct Keyframe {
+
+ var relativeTimestamp: (Int, Int) -> Double
+
+ var value: PropertyType
+
+ }
+
+ // MARK: - Public Properties
+
+ var collection: KeyPath
+
+ var property: WritableKeyPath
+
+ var keyframes: [Keyframe]
+
+ // MARK: - Public Methods
+
+ func apply(to element: inout ElementType, at relativeTimestamp: Double) {
+ let collection = element[keyPath: self.collection]
+ for (index, var item) in collection.enumerated() {
+ let valuesByRelativeTimestamp = Dictionary(
+ keyframes.map { ($0.relativeTimestamp(index, collection.count), $0.value) },
+ uniquingKeysWith: { $1 }
+ )
+
+ if let value = valuesByRelativeTimestamp[relativeTimestamp] {
+ item[keyPath: property] = value
+
+ } else {
+ let values = valuesByRelativeTimestamp.sorted { $0.key < $1.key }
+
+ guard let previousIndex = values.lastIndex(where: { $0.key < relativeTimestamp }) else {
+ item[keyPath: property] = values.first!.value
+ continue
+ }
+
+ let (previousTimestamp, previousValue) = values[previousIndex]
+
+ let nextIndex = values.index(after: previousIndex)
+ guard nextIndex != values.endIndex else {
+ item[keyPath: property] = previousValue
+ continue
+ }
+
+ let (nextTimestamp, nextValue) = values[nextIndex]
+
+ item[keyPath: property] = PropertyType.value(
+ between: previousValue,
+ and: nextValue,
+ at: ((relativeTimestamp - previousTimestamp) / (nextTimestamp - previousTimestamp))
+ )
+ }
+ }
+ }
+
+ func mapForParent(
+ _ subelementPath: WritableKeyPath,
+ relativeStartTimestamp: Double,
+ relativeDuration: Double
+ ) -> (Animation.CollectionKeyframeSeriesKey, Animation.CollectionKeyframeSeries) {
+ let reinterpolatedKeyframes = keyframes.map { keyframe in
+ return Animation.CollectionKeyframeSeries.Keyframe(
+ relativeTimestamp: { index, count in
+ let initialRelativeTimestamp = keyframe.relativeTimestamp(index, count)
+ return (relativeStartTimestamp + (initialRelativeTimestamp * relativeDuration))
+ },
+ value: keyframe.value
+ )
+ }
+
+ let mappedCollectionPath: KeyPath